RAG system

I fuse BM25 and vector search instead of picking one.

Work
Hybrid search over a price catalogue
Role
Architecture and build
Period
2025 — 2026
  • Hybrid search
  • RAG
  • MCP
  • FastAPI

The brief

A catalogue of 73 products across 537 dates and 37,893 price rows, and a team asking about it in plain language. Keyword search failed the moment a query mixed a product with a period. I designed and built the whole system: indexing, retrieval, evaluation, and the surface it is consumed through.

How it is built

Keyword search failed on queries mixing a product with a period, and pure vector search lost exact codes. I fuse both with Reciprocal Rank Fusion and evaluate the result against a gold set of 32 cases I wrote myself.

The system exposes 8 tools over an MCP server and 13 routes over FastAPI, across a domain of 73 products, 537 dates and 37,893 price rows.

One caveat

This system is not the search on this page. The one here I wrote for this site and it is far smaller: 39 passages against 9,125 chunks, and no deterministic floor. It does now have a gold set of its own, 26 questions, and its figures are further down the home page. PrecioVivo runs over a price catalogue, not over my CV.

Measured, source unpublished: gold set evaluation output1.00recall@1029 of 29 cases perfect, with the real jina-embeddings-v3 embedder at 256 dimensions. The deterministic floor alone, with no search at all, already reaches 1.00: this number is not the fusion's doing
Measured, source unpublished: gold set evaluation output0.816MRRThe same gold set, weekly granularity. Monthly grain scores 0.955 and was still rejected, because it averages 29 days of evidence against 5
Measured, source unpublished: indexing script9,125chunks73 products, 537 dates, 37,893 rows
chunk_3311 chunk_3295 chunk_3357
THE QUESTIONprice of P-065 in marchBM25exact wordsVECTORmeaningRRFfuses both1P-070·march2P-067·march3P-066·march4P-065·march5P-004·march6P-006·march1P-065·march2P-065·083P-065·104P-065·september5P-065·june6P-065·111P-065·march2P-070·march3P-066·march4P-067·march5P-065·086P-065·104 → 1light = matches the question, grey = does not
THE QUESTIONprice of P-065 in marchBM25wordsVECTORmeaningRRFfusion1070·mar2067·mar3066·mar4065·mar5004·mar1065·mar2065·083065·104065·sep5065·jun1065·mar2070·mar3066·mar4067·mar5065·084 → 1light matches, grey does not
A vector index, up close. 1,334 of 9,125 points carrying the cluster structure a real catalogue has, projected with t-SNE. The vectors are a stand-in, not real embeddings: this demonstrates the technique, it does not measure a system. The cosine search over them is real, and the three marked points are its argmax.BM25 gets the month right and the product wrong. Vector search gets the product right and the month wrong. Of their top 6 they share 1: P-065 · march, the answer. That is why RRF puts it 1, when it was 4 for one and 1 for the other.

Where it breaks

Measured, source unpublished: the evaluation run with the real embedder

Papaya returns potato, and I did not fix it in retrieval

Where and why

Asking for a product that is not in the catalogue returns the closest one by name. Papaya scores 0.6444 and potato 0.6656: two hundredths apart. I tried the obvious threshold and it does not work, because any cut that separates them kills the aggregate questions, which score 0.5151. I solved it by warning the model from the lexical filter, which already knew no word matched the catalogue. The evaluation still reports both violations: I did not relax the gold set.

Measured, source unpublished: the full gold set

recall@10 holds inside the indexed domain

Where and why

The gold set is 32 cases I wrote myself over 73 products and 537 dates. It is a small, single-author sample: it catches regressions, it does not prove the system generalises.