A cheap hybrid retrieval RAG for product search, and where it will break

┌─[fahim@BIDADARI:~]─[19:33]

└─$ less posts/2026-09-22-product-rag-search-cheap-hybrid-retrieval.md

20 Sept 2026

I built product-rag-search as a showcase for a RAG pipeline over a product catalog: intent classification, hybrid retrieval (vector + graph + full-text), Reciprocal Rank Fusion re-ranking, and answer generation with self-reported citations. This post is about the decisions behind it, not a tutorial. If you want to run it, the README covers that.

Query pipeline: intent classify, embed, fan out to vector/full-text/graph search, reciprocal rank fusion, hydrate, generate answer

Cheap by design

Most write-ups about hybrid RAG assume you already have a vector database, a graph database, and a search engine running somewhere, each its own service to deploy and pay for. I wanted to know how far I could get with just one Postgres instance.

The stack ended up being:

One Postgres instance instead of three separate stores means one thing to back up, one connection pool, one set of credentials. pgvector and AGE aren’t necessarily better than Pinecone or Neo4j on their own terms, but for a catalog this size, running three services to search one products table would solve a scale problem I don’t have.

The LLM side is the same idea. Gemini’s free tier covers the classifier, the embeddings, and the generation model, so the only real cost in this project is the Postgres instance itself. Claude’s citations API is genuinely better (more on that below), but it has no free tier, so it’s not the default here.

RRF, and why not something else

Once the three retrieval sources come back, something has to merge them into one ranked list. This project uses Reciprocal Rank Fusion:

score = Σ 1/(k + rank)

For each candidate product, sum 1/(k + rank) across every signal it appears in, where rank is its position in that signal’s result list (1st, 2nd, 3rd…) and k is a constant that softens the weight of low ranks. A product that’s #1 in vector search and #3 in full-text scores higher than one that’s only #1 in a single signal.

The appeal is that RRF only needs each source’s rank order, not a comparable score. Cosine distance, ts_rank, and graph hop count are not on the same scale, so trying to combine their raw numbers directly would need calibration work. Rank position sidesteps that entirely, and it’s the same technique Elasticsearch and OpenSearch use for their own hybrid search.

RRF has no idea what the query or the documents say, though. It just looks at rank agreement. Two products that land at #1 in two different signals for completely unrelated reasons score exactly the same as two products that are both genuinely excellent matches. It can’t tell a coincidence from real relevance.

The alternative would be a cross-encoder, hosted (Voyage, Cohere) or self-hosted (bge-reranker-v2-m3), which reads the actual query and document text instead of just rank positions. That’s a real quality upgrade, but it’s also a network call or a service to run per query. RRF was the right starting point because it’s free and needs nothing extra; a cross-encoder is the first thing I’d reach for if RRF’s ceiling ever becomes the actual bottleneck.

Caveats

This is a demo, so I want to be upfront about where it cuts corners rather than let someone find out the hard way:

Data growth and indexing

At 24 seed products, none of the indexing choices matter much. A few things start to matter as the catalog grows:

The HNSW index is at default build parameters right now, m and ef_construction untouched. Those knobs trade recall for build time and memory, and raising hnsw.ef_search per query buys recall back at the cost of latency. pgvector also has IVFFlat, which builds faster and uses less memory, but it needs lists tuned to the row count plus an ANALYZE pass to train the clusters, and it generally loses to HNSW on recall anyway. None of this is worth touching at 24 rows. It becomes worth touching once the catalog is large enough for query latency or recall to visibly drift. There’s no fixed row count where that happens: it depends on dimensionality and query load, and 24 products won’t show it either way.

Full-text

The GIN index on the generated tsvector column scales fine with row count; the real limitation is ts_rank’s lack of BM25-style scoring, not the index.

Graph

This is the one that will hurt first, because there’s no index at all today. CREATE INDEX ON product_graph."Product" USING btree ((properties->>'product_id')), plus the same for Category.name and Brand.name, is the cheap fix, and it should happen before catalog size becomes the excuse to rip out AGE for something else.

Everything here is sized for a 24-product demo catalog, and every limit above is a known, deliberate deferral, not something I missed. If the catalog ever grows past a demo, index tuning comes before swapping engines.

More on this

The future improvements doc in the repo has more detail on all of the above, plus provider swaps (Claude for verified citations), alternative rerankers, and the evaluation gap. If any of this is useful to you, the code is MIT licensed.

← back to work