If your RAG runs on Postgres + pgvector, you have probably added full-text search next to vector search, because every production write-up says pure vector search misses exact identifiers: error codes, SKUs, version strings, product names.

Anthropic's own example is a user searching for "Error code TS-999": embeddings find content about error codes in general, while keyword search finds the exact match (Contextual Retrieval).

So you write this:

sql SELECT * FROM chunks WHERE tsv @@ websearch_to_tsquery('english', :question) ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', :question)) DESC LIMIT 50; 

It looks right. It passes your test query. And for most real questions it returns nothing.

Why

websearch_to_tsquery joins every word with AND. A user asks "how many requests per minute does the growth plan allow?". Here is what Postgres actually searches for:

'mani' & 'request' & 'per' & 'minut' & 'growth' & 'plan' & 'allow' 

Every one of those seven stems must appear in the same chunk. The rate-limit table has most of them but not "many" or "allow". I ran it on a 31-chunk test corpus: the AND query returned 0 rows; the same terms OR-ed together returned 18, with the rate-limit table on top.

Your vector search still returns results, so nothing errors and nothing looks broken. You just silently lose the keyword side of hybrid search on exactly the natural-language questions users type.

The fix: OR the terms, let ranking decide

Build one tsquery per term and OR them together (|| on tsquery is OR):

terms = list(dict.fromkeys(re.findall(r"[A-Za-z0-9][A-Za-z0-9\-_.]*", question)))[:32] tsq = " || ".join(["websearch_to_tsquery('english', %s)"] * len(terms)) sql = f""" WITH q AS (SELECT ({tsq}) AS query) SELECT chunk_id, ts_rank_cd(tsv, q.query, 1) AS rank FROM chunks, q WHERE tenant_id = %s AND tsv @@ q.query ORDER BY rank DESC LIMIT 50""" cur.execute(sql, (*terms, tenant_id)) 

Chunks matching more terms rank higher, and a chunk containing the rare identifier still surfaces even if the user wrapped it in five filler words.

Then fuse with Reciprocal Rank Fusion

ts_rank and cosine similarity live on different scales, so don't add them. Use ranks:

score[d] = sum(w / (60 + rank_in_list(d)) for each list) 

RRF with k = 60 comes from Cormack et al. (SIGIR 2009). On the WANDS benchmark, plain RRF scored 0.7068 nDCG versus 0.6983 for BM25 alone, and tuned weights reached 0.7497 (Techsy). Modest out of the box, better once you tune the weights on your own eval set.

Two honest caveats
ts_rank_cd is not true BM25. It slows down and ranks worse on large corpora. RRF only uses the rank, which hides most of that, but past about a million chunks install pg_textsearch or ParadeDB pg_search (TigerData, ParadeDB).
Filter by tenant before search, in the WHERE clause, never after. On pgvector 0.8+, turn on hnsw.iterative_scan so a selective tenant filter doesn't starve your top-k.

The complete hybrid search (in-memory store + this Postgres version), plus a relevance gate that answers "I don't know" instead of guessing, is open source: groundgate-lite on GitHub (MIT).

If you need the production version with claim-by-claim grounding, prompt-injection defence, multi-tenancy and a CI eval gate, that's GroundGate.

Has this bitten you too? What does your keyword side return for long questions?