Back to Posts
Hybrid RAG in PHP — Without Pinecone, Without Python, Without Apology
May 23, 2026 By : MD Maruf Sheikh

Hybrid RAG in PHP — Without Pinecone, Without Python, Without Apology

3 min read Engineering

In a landscape often dominated by Python and vector databases, one developer challenges the status quo by creating a hybrid Retrieval-Augmented Generation (RAG) system solely in PHP, leveraging MySQL and the OpenAI API. This innovative approach combines keyword recall with semantic re-ranking, delivering precise search results without the need for complex dependencies or external services, proving that effective AI solutions can be built with simpler technologies. The article delves into the architecture, implementation, and performance considerations of this PHP-based system, offering valuable insights for developers facing similar challenges.

Hybrid RAG in PHP — Without Pinecone, Without Python, Without Apology

There's a quiet assumption in the AI tooling conversation: serious RAG means Python, LangChain, and a managed vector database. If you're on PHP, you are either ignored or pitied. I am here to disagree.

I built a working hybrid RAG system — keyword recall plus semantic re-ranking — entirely in PHP, on top of MySQL, with no vector database, no Python sidecar, and no external dependencies beyond an OpenAI API key for the embeddings. It's running today behind a CodeIgniter 4 admin panel. The math is in pure PHP. The vectors live in a JSON column. The code is small enough that a junior engineer can audit it in an afternoon.

This is how it works, why it works, and what I'd change if I were doing it again.

What "hybrid RAG" means

A pure keyword search (FULLTEXT, LIKE, BM25) is fast, simple, and wrong about half the time. It can't find "fastest electric car" when the document says "quickest EV." It hits on every document that contains the literal word and misses every document that paraphrases.

A pure vector search (cosine similarity over embeddings) is the opposite: it finds semantically related content beautifully and recovers nothing if your query is literal. Search for an exact product code and a vector store will helpfully return five documents that "feel like" your query.

Hybrid RAG runs both. Keyword first (broad recall), vectors second (semantic precision), and a weighted combination decides the final ranking. In my system the formula is:

final_score = 0.4 × keyword_score + 0.6 × cosine_similarity

Sixty per cent semantic, forty per cent literal. The weights are configurable, but I have not found cause to change them.

The data shape

One table, four columns that matter:

sql
CREATE TABLE knowledge_base (
  id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  title VARCHAR(255) NOT NULL,
  content LONGTEXT NOT NULL,
  keywords VARCHAR(500),
  embedding_vector JSON,
  embedding_status ENUM('pending','processing','completed','failed') DEFAULT 'pending',
  embedding_model VARCHAR(80),
  embedding_dimensions INT,
  -- chunking for long docs
  parent_id INT UNSIGNED NULL,
  chunk_index INT DEFAULT 0,
  total_chunks INT DEFAULT 1,
  -- standard
  created_at DATETIME,
  updated_at DATETIME,
  FULLTEXT KEY ft_content (title, content, keywords)
);

The embedding_vector column is JSON. MySQL 5.7+ supports it natively. A 1536-dimensional vector serialised as JSON is about 30 KB per row. For a knowledge base of 10,000 chunks, that's 300 MB — well within the comfort zone of a single MySQL instance.

I track embedding status separately from the vector itself. Three reasons:

  1. Newly inserted documents start as pending and the embedding job picks them up later. The system never assumes "embedded" because a column is non-null.
  2. If the embedding model changes (3-small to 3-large, say), I can mark all rows as pending and re-process in batches without losing the existing vectors mid-run.
  3. Failed embeddings are visible. A 500 from OpenAI doesn't silently leave a document unsearchable; it leaves it failed with an embedding_error for review.

The embedding service

php
namespace App\Services;

class EmbeddingService
{
    private const MODEL = 'text-embedding-3-small';
    private const DIMENSIONS = 1536;
    private const MAX_INPUT_CHARS = 8000;

    public function __construct(private CURLRequest $http) {}

    public function generate(string $text): array
    {
        $normalised = $this->normalise($text);

        $response = $this->http->post('https://api.openai.com/v1/embeddings', [
            'headers' => [
                'Authorization' => 'Bearer ' . env('openai.key'),
                'Content-Type'  => 'application/json',
            ],
            'json' => [
                'model' => self::MODEL,
                'input' => $normalised,
            ],
        ]);

        $body = json_decode($response->getBody(), true);

        if (!isset($body['data'][0]['embedding'])) {
            throw new \RuntimeException('Embedding API returned no vector');
        }

        return [
            'vector' => $body['data'][0]['embedding'],
            'model'  => self::MODEL,
            'dims'   => self::DIMENSIONS,
            'tokens' => $body['usage']['total_tokens'] ?? 0,
        ];
    }

    private function normalise(string $text): string
    {
        // Strip markdown/HTML for embedding purposes
        $text = strip_tags($text);
        $text = preg_replace('/\s+/u', ' ', $text);
        $text = trim($text);

        // Truncate at character boundary, multibyte-safe
        if (mb_strlen($text) > self::MAX_INPUT_CHARS) {
            $text = mb_substr($text, 0, self::MAX_INPUT_CHARS);
        }

        return $text;
    }
}

Two details that took me longer than they should have. First, mb_strlen and mb_substr, not strlen and substr. If your knowledge base has Arabic, Chinese, Bengali, Hindi — any of which take multiple bytes per character — substr will cut in the middle of a codepoint and you'll get an invalid string at the embedding endpoint. Multibyte-aware string functions are the only safe option.

Second, normalisation. The embedding model is sensitive to noise. Stripping HTML and markdown before embedding produces measurably better similarity scores. The cost is one regex and one strip_tags() call per document.

Cosine similarity in pure PHP

php
public function cosineSimilarity(array $a, array $b): float
{
    $dimA = count($a);
    $dimB = count($b);

    if ($dimA === 0 || $dimA !== $dimB) {
        log_message('warning', "Vector dimension mismatch: {$dimA} vs {$dimB}");
        return 0.0;
    }

    $dot = 0.0;
    $magA = 0.0;
    $magB = 0.0;

    for ($i = 0; $i < $dimA; $i++) {
        $dot  += $a[$i] * $b[$i];
        $magA += $a[$i] * $a[$i];
        $magB += $b[$i] * $b[$i];
    }

    if ($magA == 0.0 || $magB == 0.0) {
        return 0.0;
    }

    return $dot / (sqrt($magA) * sqrt($magB));
}

Twenty lines, no dependencies. For 1536-dimensional vectors this runs in roughly 50 microseconds per pair on a single Xeon core. For a 5,000-document KB with a keyword pre-filter that narrows to 50 candidates, you're looking at ~2.5 ms of cosine math per query. Imperceptible.

The dimension guard is non-optional. I learned the hard way: I changed embedding models, queries went out at 1536 dims, half the docs were still stored at 3072 dims, the cosine math happily produced numbers between 0 and 1 that were meaningless. The system kept "working" — wrong results, no errors, no clue why retrieval got bad. Now mismatched dimensions log a warning, return 0, and the doc is excluded from cosine scoring.

The retrieval flow

php
namespace App\Services;

class RAGService
{
    public function __construct(
        private KnowledgeBaseModel $kb,
        private EmbeddingService $embedder,
    ) {}

    public function retrieveDocuments(string $query, int $limit = 5): array
    {
        // Step 1: keyword recall
        $candidates = $this->kb->keywordSearch($query, limit: 50);
        if (empty($candidates)) {
            return [];
        }

        // Step 2: embed the query
        try {
            $queryVector = $this->embedder->generate($query)['vector'];
        } catch (\Throwable $e) {
            // Fallback to keyword-only ranking
            return array_slice($candidates, 0, $limit);
        }

        // Step 3: cosine re-rank
        foreach ($candidates as &$doc) {
            $doc['keyword_score'] = $doc['relevance'] ?? 0;
            $doc['cosine_score']  = $this->scoreDocument($doc, $queryVector);
            $doc['final_score']   = 0.4 * $doc['keyword_score']
                                  + 0.6 * $doc['cosine_score'];
        }
        unset($doc);

        // Step 4: sort and trim
        usort($candidates, fn($a, $b) => $b['final_score'] <=> $a['final_score']);

        return array_slice($candidates, 0, $limit);
    }

    private function scoreDocument(array $doc, array $queryVector): float
    {
        if (empty($doc['embedding_vector'])) {
            return 0.0;
        }

        $docVector = is_array($doc['embedding_vector'])
            ? $doc['embedding_vector']
            : json_decode($doc['embedding_vector'], true);

        if (count($docVector) !== count($queryVector)) {
            return 0.0;
        }

        return $this->cosineSimilarity($queryVector, $docVector);
    }
}

Four steps. Keyword recall via the FULLTEXT index. Query embedding (with a fallback to keyword-only ranking if the embedding API fails — never break retrieval because an external service is sad). Cosine re-rank. Sort and trim.

The pattern's strength is that each step degrades gracefully. No FULLTEXT match? Empty result, no LLM hallucination from "anything goes." Embedding API down? Keyword-only ranking returns reasonable results. Mismatched vector dimensions? Excluded from cosine scoring, keyword score still applies.

Auto-embedding on save

A bug worth not making twice: I shipped the first version with manual "Generate Embeddings" buttons in the admin. New knowledge base entries had to be embedded by hand after save. People forgot. Half the KB was unsearchable.

The fix was four lines on the model:

php
class KnowledgeBaseModel extends Model
{
    protected $beforeInsert = ['markPending'];
    protected $beforeUpdate = ['markPendingOnContentChange'];

    protected function markPending(array $data): array
    {
        $data['data']['embedding_status'] = 'pending';
        $data['data']['embedding_vector'] = null;
        return $data;
    }

    protected function markPendingOnContentChange(array $data): array
    {
        $changedFields = ['title', 'content', 'keywords', 'tags'];
        $changed = array_intersect($changedFields, array_keys($data['data']));

        // Skip if this update is itself setting embedding_status
        if (isset($data['data']['embedding_status'])) {
            return $data;
        }

        if (!empty($changed)) {
            $data['data']['embedding_status'] = 'pending';
            $data['data']['embedding_vector'] = null;
        }

        return $data;
    }
}

Every new document is pending. Every content edit invalidates the vector. A scheduled job (php spark kb:embed --limit=50) picks up pending rows in batches and embeds them. The admin doesn't have to think about it. Stale vectors are an entire class of bug that ceases to exist.

When PostgreSQL or Qdrant is the right call

A fair question: when does this approach run out of road?

At a few hundred thousand documents. The keyword pre-filter is doing real work for you — it narrows millions of rows to dozens before the cosine step. If your keyword pass returns thousands of candidates, the cosine loop in PHP becomes the bottleneck and you want approximate-nearest-neighbour search.

When you want ANN index types. HNSW, IVF, scalar quantisation — these are useful at scale. MySQL JSON columns don't have them. PostgreSQL with pgvector has them. Qdrant, Weaviate, Pinecone all have them.

When you want vector + metadata filtering at the index level. "Find similar docs but only in this category for this tenant from the last 30 days" is easy in pgvector or Qdrant. Doable in MySQL+PHP but you'll feel it past a certain scale.

For most products — internal knowledge bases, customer-facing chat KBs, support FAQ retrieval — you are nowhere near these limits. A single MySQL server with FULLTEXT and a JSON vector column will serve hybrid RAG to thousands of users without breaking a sweat.

I have plans to migrate one of my larger systems to PostgreSQL + pgvector for exactly the index-level filtering reasons. That doesn't make the PHP/MySQL approach wrong. It makes it the right answer for a long stretch of a product's life.

What this enables, in product terms

The behaviour the user actually sees: ask a question of the system in any phrasing — terse, verbose, paraphrased, with typos — and get back the documents that are about the same thing, not just the ones that contain the same words. A support chat that finds the right help article even when the user spells "subscription" three different ways. A lead-reply bot that uses your case studies because the comment is "about" similar work, not because it shares vocabulary.

That is the only thing RAG is actually for. Everything else is implementation detail.

What I'd build differently next time

  1. Embedding queue, not synchronous embed-on-save. Even with the pending-status pattern, I'd push embedding off the request thread entirely. A queue worker picks up pending rows, calls OpenAI, writes back. Saves are fast; embeddings happen in the background.
  2. Reusable HTTP client. Per-call curl initialisation is fine until you batch 50 docs and pay 50 connection setups. Inject a single keep-alive client.
  3. Per-tenant collections. If your product is multi-tenant, prefix the keyword index and partition vectors by tenant from the start. Retrofitting is a project.
  4. Cache the query embedding. Same question asked twice should hit one OpenAI call, not two. A short-TTL cache keyed on the query string saves real money on chat-style workloads.
  5. Track recall, not just retrieval. Log the top-k scores per query. If final_score[0] is below 0.4, the system probably has no good answer — and the responder should say "I don't know" instead of hallucinating off the second-best match.

Wrap-up

Hybrid RAG isn't a Python-only club. The pieces are: a place to store text and vectors, a function to compute cosine similarity, and the discipline to make the two scores work together. PHP and MySQL handle all of that. The vector database market exists for reasons — those reasons mostly arrive after your product is already successful.

Ship the simpler version first. Measure the limits. Migrate when the limits actually bite.

Share: LinkedIn

Tags

Hybrid RAG PHP MySQL semantic re-ranking keyword recall OpenAI API cosine similarity vector search