DEV Community

Cover image for Vector search kept missing rootstock codes, so I went hybrid
Kantemir Satibalov
Kantemir Satibalov

Posted on

Vector search kept missing rootstock codes, so I went hybrid

Grounded RAG in production (8 Part Series)

  1. I stopped trusting generic LLMs for horticulture — so I built a grounded assistant on ~500 scientific articles
  2. 68 questions before a single token: eval-first RAG
  3. Vector search kept missing rootstock codes, so I went hybridyou are here
  4. Scientific articles aren't FAQ-shaped: chunking a 500-article corpus — coming soon
  5. Gate the answer, not just the retrieval: verifying LLM output against sources — coming soon
  6. Go for the product, Python for the models: anatomy of a two-service RAG — coming soon
  7. Hardening a side project like it's production (and the outage that caused) — coming soon
  8. One RAG platform, swappable domains: what 500 articles taught me about product shape — coming soon

When the eval suite first ran against pure vector search, the failures clustered in one place: exact identifiers. Rootstock codes like "SK-4", cultivar names, dosage lines. Embeddings are great at "this paragraph is about slope planting" and terrible at "this exact token matters more than the topic."

A typical miss looked like this — vector search returned paragraphs about rootstocks, but not the cultivar token the eval expected:

The fix wasn't a bigger model. It was accepting that I needed two retrievers with opposite failure modes, and a principled way to merge them.

The pipeline

query
  └─ glossary expansion (synonyms appended)
       ├─ Chroma vector search (multilingual-e5-small), top-16, filtered by crop
       └─ BM25 per-crop index, top-16
             └─ RRF merge → candidates
                   └─ cross-encoder rerank (bge-reranker-base) — only for some categories
                         └─ per-article diversification → top-8 fragments
Enter fullscreen mode Exit fullscreen mode

Every stage exists because a specific eval failure demanded it. Layer by layer:

Embeddings: intfloat/multilingual-e5-small. Multilingual because the corpus is Russian and queries can be either language. One non-obvious gotcha: e5 models require query: / passage: prefixes at query and index time. Without them similarity scores quietly degrade — no error, just worse ranking. I subclassed the embedding wrapper so the prefixes can't be forgotten.

BM25, one index per crop. Classic lexical scoring over tokenized chunks. This is what catches "SK-4" — the exact token is either in the chunk or it isn't. The indexes are built at reindex time and persisted alongside the vector store, so a container restart doesn't silently drop the lexical half (an early bug the eval caught: exact-code questions failing while everything else passed).

RRF merge. Reciprocal Rank Fusion is embarrassingly simple — each list contributes
1 / (k + rank) per document, sum, sort (k=60):

def rrf_merge(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1)
    return [cid for cid, _ in sorted(scores.items(), key=lambda x: -x[1])]
Enter fullscreen mode Exit fullscreen mode

I tried score normalization schemes first. RRF won because it needs no calibration between BM25 scores and cosine similarities — it only trusts ranks — and it's ten lines you can hold in your head.

Conditional reranking. A cross-encoder (BAAI/bge-reranker-base) reads the query and each candidate together and re-scores the top candidates. It measurably improves ranking for dense technical questions — and costs real CPU time. So it's category-gated: the question classifier (rule-based, config-driven) tags questions as rootstock, disease, variety, fertilizer, relief, or general, and only the complex categories pay the reranker tax. "When should I water?" doesn't need a cross-encoder.

Glossary expansion. A curated JSON of domain synonyms; if the query contains a known term, its synonyms are appended to the search string. This is where user vocabulary meets literature vocabulary — the colloquial disease name pulls in Marssonina spellings the embeddings alone ranked too low. Curated beats automatic here: the glossary is small, reviewable, and each entry exists because a real query missed.

Diversification. Top-ranked chunks tend to come from the same article. The last step caps fragments per source before returning the top-8, so the LLM sees multiple studies instead of one article shredded eight ways.

What I didn't do

  • Fine-tune embeddings. Tempting, but the eval said ranking was mostly fine once lexical search covered the identifier cases. Not worth the MLOps overhead yet.
  • LLM query rewriting. Adds latency and a failure mode to the cheap half of the system. The glossary covers the actual observed misses deterministically.
  • A vector DB migration. Chroma with a persistent local directory is unglamorous and entirely sufficient at ~14.5k chunks. The interesting problems were above the storage layer.

Results

With the full stack, the 68-question suite passes at 100% retrieval, and — the part I care about more — hit_rate@3 stays high enough that the LLM's context isn't padded with near-misses (apple suite: MRR 0.938, hit@3 0.953). With --fast (reranker off) it still passes 68/68 today, which tells me the reranker is currently insurance rather than load-bearing. I keep it because insurance is what you want the week a new corpus batch lands.

Top comments (0)