Pagination breaks on tied similarity scores
Problem
Section titled “Problem”You ship a hybrid-search API combining vector and text scores. Paginate with LIMIT 5 OFFSET 0, then LIMIT 5 OFFSET 5, etc. Users on the second page see articles they already saw on the first, and articles they should see vanish entirely.
The code
Section titled “The code”Why review misses it
Section titled “Why review misses it”The reviewer sees ORDER BY score DESC and assumes stable ordering. Postgres doesn’t promise a stable order for tied rows — and ties are far more common in hybrid search (sparse embeddings, short text matches that snap to 0/1) than reviewers expect.
There’s a second, subtler problem: the JOIN between kb_articles and kb_article_embeddings is one-to-many (one article may have multiple embedding chunks). Without deduplication, a single article with N chunks appears N times in the result, inflating page sizes and corrupting pagination even after the tiebreaker is added.
The example test that passes
Section titled “The example test that passes”Doesn’t paginate. Doesn’t compare across pages.
The SqlProof property
Section titled “The SqlProof property”The teaching beat: when you can articulate the property as “paging is a partition of the full set,” any pagination bug is testable.
The counterexample
Section titled “The counterexample”Illustrative — Hypothesis would print the actual sequence and assertion:
The fix
Section titled “The fix”Two changes are needed. First, add DISTINCT ON (a.id) in the inner query to collapse the one-article-many-chunks JOIN fanout, keeping the best-scoring chunk per article. Second, add a stable tiebreaker on a unique column in the outer ORDER BY:
Related
Section titled “Related”For the general “vector RPC missing tenant filter” pattern, see Tenant-scoped vector search.