<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ayush Gupta</title>
    <description>The latest articles on DEV Community by Ayush Gupta (@theayushgupta08).</description>
    <link>https://dev.to/theayushgupta08</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2477161%2F57e481b9-c871-4d6f-a357-88e402310f68.jpg</url>
      <title>DEV Community: Ayush Gupta</title>
      <link>https://dev.to/theayushgupta08</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theayushgupta08"/>
    <language>en</language>
    <item>
      <title>The one-word bug that kills keyword search in your Postgres RAG</title>
      <dc:creator>Ayush Gupta</dc:creator>
      <pubDate>Mon, 09 Nov 2026 11:48:54 +0000</pubDate>
      <link>https://dev.to/theayushgupta08/the-one-word-bug-that-kills-keyword-search-in-your-postgres-rag-509m</link>
      <guid>https://dev.to/theayushgupta08/the-one-word-bug-that-kills-keyword-search-in-your-postgres-rag-509m</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;So you write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tsv&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks right. It passes your test query. And for most real questions it returns nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;websearch_to_tsquery&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mani&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;per&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;minut&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;growth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; OR the terms, let ranking decide&lt;/p&gt;

&lt;p&gt;Build one tsquery per term and OR them together (|| on tsquery is OR):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromkeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z0-9][A-Za-z0-9\-_.]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)))[:&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;tsq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; || &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;websearch_to_tsquery(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;english&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, %s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
  WITH q AS (SELECT (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tsq&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) 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&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Then fuse with Reciprocal Rank Fusion&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ts_rank&lt;/code&gt; and cosine similarity live on different scales, so don't add them. Use ranks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score[d] = sum(w / (60 + rank_in_list(d)) for each list)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two honest caveats&lt;br&gt;
&lt;code&gt;ts_rank_cd&lt;/code&gt; 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 &lt;code&gt;pg_textsearch&lt;/code&gt; or ParadeDB &lt;code&gt;pg_search&lt;/code&gt; (TigerData, ParadeDB).&lt;br&gt;
Filter by tenant before search, in the WHERE clause, never after. On pgvector 0.8+, turn on &lt;code&gt;hnsw.iterative_scan&lt;/code&gt; so a selective tenant filter doesn't starve your top-k.&lt;/p&gt;

&lt;p&gt;The complete hybrid search (in-memory store + this Postgres version), plus a relevance gate that answers "&lt;strong&gt;I don't know&lt;/strong&gt;" instead of guessing, is open source: &lt;a href="https://github.com/theayushgupta08/groundgate-lite" rel="noopener noreferrer"&gt;groundgate-lite on GitHub&lt;/a&gt; (MIT).&lt;/p&gt;

&lt;p&gt;If you need the production version with claim-by-claim grounding, prompt-injection defence, multi-tenancy and a CI eval gate, that's &lt;a href="https://shrialok.gumroad.com/l/groundgate" rel="noopener noreferrer"&gt;GroundGate&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Has this bitten you too? What does your keyword side return for long questions?&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
    </item>
  </channel>
</rss>
