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.

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.

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,281 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.

1.00recall@10 29 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
0.816MRR The same gold set, weekly granularity. Monthly grain scores 0.955 and was still rejected, because it averages 29 days of evidence against 5
9,281chunks 73 products, 537 dates, 37,893 rows
chunk_8434 chunk_8440 chunk_8433
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
The index, up close. 667 of 9,281 chunks, projected with t-SNE. The three marked points are the true nearest neighbours by cosine of a real query.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

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

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.

recall@10 holds inside the indexed domain

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.