<?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: Pavan Gupta</title>
    <description>The latest articles on DEV Community by Pavan Gupta (@pavangupta352).</description>
    <link>https://dev.to/pavangupta352</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%2F4103319%2F2a16c7a2-a509-4fd7-9bfa-c485835c7e46.jpg</url>
      <title>DEV Community: Pavan Gupta</title>
      <link>https://dev.to/pavangupta352</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pavangupta352"/>
    <language>en</language>
    <item>
      <title>Paginating hybrid search on Postgres, and why the obvious fix is worse</title>
      <dc:creator>Pavan Gupta</dc:creator>
      <pubDate>Mon, 31 Aug 2026 20:42:21 +0000</pubDate>
      <link>https://dev.to/pavangupta352/paginating-hybrid-search-on-postgres-and-why-the-obvious-fix-is-worse-2kmd</link>
      <guid>https://dev.to/pavangupta352/paginating-hybrid-search-on-postgres-and-why-the-obvious-fix-is-worse-2kmd</guid>
      <description>&lt;p&gt;If you have built hybrid search on Postgres, your query probably looks close to this. One CTE ranks by vector distance, one ranks by &lt;code&gt;ts_rank_cd&lt;/code&gt;, and Reciprocal Rank Fusion combines the two rankings.&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;with&lt;/span&gt; &lt;span class="n"&gt;vector_candidates&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&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;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&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;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&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;span class="n"&gt;text_candidates&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&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;fts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;documents&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;fts&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;query&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;fts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&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;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vector_candidates&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
&lt;span class="k"&gt;full&lt;/span&gt; &lt;span class="k"&gt;outer&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;text_candidates&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;score&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works fine until someone asks for page two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages just stop
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;offset 50&lt;/code&gt; and you get an empty result. No error, nothing in the logs, just zero rows, which looks exactly like reaching the end of the results.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;limit 50&lt;/code&gt; inside each CTE is the cause. Those two limits are the entire universe the fusion can see, so the outer &lt;code&gt;limit 10 offset 50&lt;/code&gt; is slicing a list that is at most 50 rows long after the join collapses duplicates. Page six is empty regardless of how many rows matched.&lt;/p&gt;

&lt;p&gt;I found this on a 500 row table where 490 rows matched the query. Pages one through five were fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix is worse
&lt;/h2&gt;

&lt;p&gt;So you size the pool from the offset:&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;limit&lt;/span&gt; &lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Page one pulls 60 candidates, page five pulls 100, page eight pulls 130. Every page returns rows now.&lt;/p&gt;

&lt;p&gt;I did this, then paginated eight pages of ten and counted. 71 distinct rows instead of 80. Nine rows that a single &lt;code&gt;limit 80&lt;/code&gt; query returns never appeared on any page, and several rows showed up twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranks only exist inside the pool
&lt;/h2&gt;

&lt;p&gt;RRF does not score documents. It scores ranks, and a rank is meaningless except relative to the other rows in the same candidate list.&lt;/p&gt;

&lt;p&gt;Widening the text CTE from 60 rows to 130 does not just append 70 rows at the bottom. It admits rows that were not in the text candidate list at all, and any of those already sitting in the vector list now pick up a second contribution they did not have before. Their fused score jumps and they climb past rows the user already saw on page three.&lt;/p&gt;

&lt;p&gt;Each page is computed against a different candidate set, so each page is a different ranking. RRF is extremely sensitive to which rows are in the pool, which is the property that makes it work in the first place.&lt;/p&gt;

&lt;p&gt;Empty pages are at least visible. Duplicates and silently missing rows are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works
&lt;/h2&gt;

&lt;p&gt;Make the pool a function of the query and never of the page. Pick the deepest page you are willing to serve, set both CTE limits to that constant, and refuse to go past it.&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="c1"&gt;-- in both CTEs. a constant, not derived from the offset&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every page is now a window onto one stable ranking. Page one and page eight are slices of the same list.&lt;/p&gt;

&lt;p&gt;When someone asks for offset 600, return an error instead of an empty page. Elasticsearch does this with &lt;code&gt;index.max_result_window&lt;/code&gt;, which defaults to 10,000 and throws past it. That felt hostile to me until I understood the alternative was a different ranking on every page.&lt;/p&gt;

&lt;p&gt;The cost is predictable too: one constant-size candidate scan per query no matter how deep anyone scrolls. Sizing from the offset means scroll depth drives query cost up forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller things
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;row_number() over ()&lt;/code&gt; with no &lt;code&gt;order by&lt;/code&gt; shows up in several published hybrid search functions:&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;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row_number&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It follows the subquery's order today. Nothing guarantees that, and it is the kind of assumption that breaks under a different plan or a parallel scan. Write &lt;code&gt;row_number() over (order by distance)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other is tiebreakers. &lt;code&gt;ts_rank_cd&lt;/code&gt; ties heavily. On one corpus I measured it produced 3 distinct values across 3,399 matching rows. With &lt;code&gt;order by ts_rank_cd(...) desc limit 50&lt;/code&gt; and nothing after it, which 50 rows come back is arbitrary and can differ between two identical queries. Add &lt;code&gt;, id&lt;/code&gt; to that order by, to the vector side, and to the outer query. Without it a row can legitimately appear on two different pages of the same result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The candidate pool is your whole result set, so size it once from the query and never from the offset. Error past it rather than returning an empty page. Put a tiebreaker on every &lt;code&gt;order by&lt;/code&gt; that feeds a &lt;code&gt;limit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I packaged this and a few other things into &lt;a href="https://github.com/pavangupta352/pghybrid" rel="noopener noreferrer"&gt;pghybrid&lt;/a&gt;, which does hybrid search on plain pgvector without any extension you cannot install on managed Postgres. MIT, Python and TypeScript. The rules above matter more than the library though, and they apply to whatever you have already written.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>search</category>
    </item>
  </channel>
</rss>
