fix(ci): validate_queries.sql — propagate LIMIT 10 to BMW index scan; document #365 trap - #366
Conversation
Follow-up to #360. The doc-set-based validation in validate_queries.sql generates non-deterministic failures on the concurrent-INSERT path because BM25 can produce *tied clusters* of 3+ docs at the rank-10 boundary, and the tie-break ordering varies between: - single-writer COPY vs concurrent INSERT (different CTID order) - BMW top-k vs sequential scan - across pgbench runs (timing-dependent ordering) Both runs that surfaced after #360's watchdog hardening (#363 query 1267 in one run, query 130 in another) were exactly this pattern -- overlapping docs matched scores at micro-parts precision (3e-6 to 8e-6), but the docs at the rank-10 boundary differed because a 4-doc cluster of identical scores has 4! orderings, only one of which matches the ground truth. Confirmed locally on a 200K-doc msmarco subset with concurrent pgbench inserts: SELECT passage_id, -(passage_text <@> to_bm25query('how to make bread at home', 'msmarco_bm25_idx'))::float8 FROM msmarco_passages WHERE passage_id IN (77517, 162506); passage_id | idx_score ------------+-------------------- 77517 | 12.595187187194824 162506 | 12.595187187194824 Two docs tied at the EXACT same score, swapped at ranks 6/7 between index and GT. Doc-set comparison would flag this; per-rank score comparison sees identical scores at every rank and passes. Switch to per-rank score comparison: for each rank 1..10, compare the score at that rank in the index's result to the score at that rank in ground truth, within the existing 0.001 absolute tolerance. The specific doc IDs at each rank are reported as supplementary info but do not enter the pass/fail decision. This: - eliminates the tie-cluster false-positive class entirely - keeps catching real correctness regressions: any genuine per-rank score divergence (like #361 / query 158, which has a rank-3 score off by 1.5) still fails the run - lets us remove allowlist entries that were really just tie-break noise (query 1267, query 130) Allowlist now contains only query 158 -- the one query whose per-rank score genuinely diverges from ground truth on the single-txn COPY path. #363 (the concurrent-only query 1267 issue) turned out to be a tie-cluster too; once this lands, that issue can be re-evaluated and likely closed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #360's score-rank validation watchdog kept surfacing non-deterministic failures on the concurrent-INSERT MS MARCO path -- queries 1267, 130, 1517, 222, 9351, 9340, ... depending on the run. Detailed investigation (PRs #360, #364, issue #363) couldn't isolate a concurrent-insert correctness bug; once the BMW score-sort root cause from #360 landed, scoring on local single-txn and concurrent 8.8M-passage builds matched ground truth on every spot check. Root cause turned out to be in the validator, not the index. validate_queries.sql had this shape: SELECT row_number() OVER (ORDER BY content <@> q)::int as rank, passage_id, -(content <@> q)::float8 as score FROM msmarco_passages ORDER BY content <@> q LIMIT 10; Postgres can't push the outer LIMIT 10 past the WindowAgg the row_number() introduces, so the underlying BM25 index scan is asked for default_limit (1000) rows. BMW with K=1000 reports subtly-wrong scores for some docs on real corpora -- bug documented in #365 and reproduced locally on pg17 + full 8.8M corpus + concurrent pgbench inserts. In one concrete case, doc 3906880 (query 1267) gets BM25 score -23.0376 from BMW at K=10 (matches standalone scoring and ground truth) but -20.6486 at K=1000 -- 2.39 too low. The window function re-evaluates the score expression standalone, which gives 23.0376 both in K=10 and K=1000 cases; but the ORDER BY (driving the result set returned to the SELECT) uses BMW's score, so the rows come back in the order BMW thinks is right at K=1000 -- with doc 3906880 placed lower than it should be. validation then compares the standalone-recomputed-per-rank score against ground truth and sees a mismatch. Fix: wrap the inner ORDER BY ... LIMIT 10 in a subquery so the LIMIT propagates to the index scan, then apply row_number() to the resulting 10-row materialized result. Output identical to the buggy shape on healthy data, but K=10 is now used for the index scan, so BMW gives correct scores. Also update issue #361's allowlist note to reflect the now-confirmed root cause for query 158 (libstemmer/snowball rule drift between the pg17 minor that generated ground_truth_pg17.tsv in Feb 2026 and current pg17; "Adding" used to stem to "add", now stems to "ad", which changes doc 1838655's BM25 score by ~1.5). The fix for that is regenerating ground_truth_pg17.tsv, tracked separately. ## Verification Local pg17 + concurrent-pgbench 8.8M corpus, run the same 80 validation queries before and after this change: Before this fix: 9 queries fail (1267, 1977, 158, 1522, 222, 807, 9351, 215, 9340) with max_diff up to 1.46 After this fix: Only query 158 fails (allowlisted as stemmer-drift / stale GT in #361) The 8 queries that disappear from the failure list were all false positives from the K=1000 BMW score under-reporting. The single remaining failure (158) is the genuine stemmer-drift issue tracked in #361. Refs: #360, #363, #364, #365. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
LGTM — CI-only validator fix with solid repro evidence.
Extended reasoning...
Overview
This PR modifies a single CI/benchmark validation script (benchmarks/datasets/msmarco/validate_queries.sql) to fix a self-inflicted false-positive in the MS MARCO query watchdog. The previous SQL shape SELECT row_number() OVER (ORDER BY ...) ... ORDER BY ... LIMIT 10 couldn't push the outer LIMIT past the WindowAgg, so the BM25 index scan ran with K=1000 (default_limit) instead of K=10, which exposes the BMW K-dependent score under-reporting tracked in #365. The fix wraps the ORDER BY ... LIMIT 10 in a subquery so the LIMIT actually reaches the index scan, and row_number() runs on the 10-row materialized output. The PR also rewrites the validator to compare per-rank score (rather than per-doc set with tie-boundary heuristics) and updates the #361 allowlist note to reflect the now-confirmed Snowball stemmer drift root cause.
Security risks
None. This is a test/validation SQL script that runs against a benchmark corpus in CI. No auth, crypto, permission, user-input, or network-facing code is touched.
Level of scrutiny
Low. This is CI infrastructure for validating BM25 ranking against a precomputed ground truth file — it does not run in production, does not affect end users, and the worst-case failure mode is a noisy or quiet watchdog. The fix itself is mechanical (subquery wrapper around LIMIT 10) with strong repro evidence in the description (BMW score table for query 1267 / doc 3906880 across K=10 vs K=1000) and verification numbers (9 failing queries → 1 allowlisted) that match the stated mechanism.
Other factors
The only inline comment is a nit flagging that the new per-rank score comparison silently passes empty / partial tapir_results (the INNER JOIN on rank skips unmatched ranks, and docs_match is computed but excluded from the pass predicate). That's a real but minor weakening of defensive coverage in the watchdog and does not affect the validity of this fix — the BMW index returning fewer than 10 rows on the 8.8M-doc msmarco corpus is not currently observed, and the author can address the row-count guard in a follow-up if desired. The fix being applied here resolves a concrete, reproduced false-positive that was masking real signal in CI.
| FOR r IN | ||
| SELECT | ||
| gt.doc_id, | ||
| gt.rank, | ||
| gt.score as gt_score, | ||
| t.score as tapir_score, | ||
| gt.doc_id as gt_doc, | ||
| t.doc_id as tapir_doc, | ||
| ABS(gt.score - t.score) as abs_diff | ||
| FROM ground_truth gt | ||
| JOIN tapir_results t ON gt.doc_id = t.doc_id | ||
| JOIN tapir_results t ON t.rank = gt.rank | ||
| WHERE gt.query_id = p_query_id | ||
| ORDER BY gt.rank | ||
| LOOP |
There was a problem hiding this comment.
🟡 The refactor makes the watchdog silently pass when tapir_results is empty or has fewer rows than ground truth for a query. v_all_match is initialized to true (line 68), the FOR loop's INNER JOIN on t.rank = gt.rank (line 115) skips unmatched ranks, and the pass/fail predicate at line 217 only checks NOT scores_match — docs_match is computed but excluded. Suggest adding a row-count guard before/after the loop (e.g., IF (SELECT count(*) FROM tapir_results) <> (SELECT count(*) FROM ground_truth WHERE query_id = p_query_id) THEN v_all_match := false; END IF;) or including docs_match in the pass predicate.
Extended reasoning...
What the bug is. The new score-validation logic decides pass/fail by comparing scores at each rank via an INNER JOIN between ground_truth and tapir_results on rank. If a future regression causes the BM25 query to return zero or fewer than 10 rows for some query, the missing ranks silently fall out of the join and the query is reported as passing.
Code path. v_all_match is initialized to true at line 68. The FOR loop at lines 106–133 iterates over ground_truth gt JOIN tapir_results t ON t.rank = gt.rank WHERE gt.query_id = p_query_id. Unmatched ranks produce no rows, so the loop body simply doesn't execute for them and v_all_match is never flipped to false. Then scores_match := v_all_match (line 144) returns true. The DO block at line 215–221 uses WHERE NOT scores_match exclusively; docs_match is computed at lines 136–141 but the in-file comment explicitly states it is 'supplementary info (not part of pass/fail).' The failure-detail SELECT (line 252) also filters on NOT scores_match, so an empty/partial result wouldn't even surface in the failure list.
Why existing code doesn't prevent it. The pre-PR code computed v_real_missing from unnest(v_gt_docs) WHERE d NOT IN (SELECT doc_id FROM tapir_results), with a tie-boundary tolerance, and the pass/fail predicate was WHERE NOT scores_match OR NOT docs_match. For the partial-result case (e.g., 5 of 10 returned, with a missing GT doc whose score is well outside any tie boundary), the old check would have caught it. The new code drops docs_match from pass/fail and rank-keys the comparison, so partial results silently pass.
Step-by-step proof (partial case). Suppose for query Q, ground_truth has 10 rows with ranks 1..10 and a future BMW regression causes tapir_results to be inserted with only 5 rows. The inner subquery in the INSERT returns 5 rows, row_number() OVER () assigns ranks 1..5, so tapir_results has rank IN (1,2,3,4,5). Suppose those 5 scores happen to match ground truth within 0.001 tolerance (e.g., the dropped ranks were 6–10 due to a K-propagation bug). The FOR loop iterates exactly 5 times; r.abs_diff <= p_tolerance for every iteration; v_all_match is never set false. scores_match returns true. docs_match is false (different array_aggs), but the DO block at line 217 ignores it. Result: VALIDATION PASSED for a query whose top-10 is half missing.
Step-by-step proof (empty case). If tapir_results ends up empty (e.g., index scan returns no rows for that query), the FOR loop body executes 0 times, v_all_match stays true, scores_match = true, query is counted as passing. (Note: the old code also silently passed the empty case via NULL propagation on v_tapir_min_score, so the strict regression is the partial-result regime — but flagging the empty case is still reasonable since catching it is exactly the watchdog's job.)
Impact and fix. This is CI/validation infrastructure rather than production code, and the triggering precondition (BM25 index returning fewer than the requested K rows on the 8.8M-doc msmarco corpus) is not currently observed — so this is a nit, not a blocker. But it weakens the watchdog's defensive coverage exactly in the partial-result regression class. A minimal fix: add a count guard at the top or bottom of the FOR loop, e.g.:
IF (SELECT count(*) FROM tapir_results) <> (SELECT count(*) FROM ground_truth WHERE query_id = p_query_id) THEN
v_all_match := false;
v_details := v_details || 'row count mismatch; ';
END IF;Alternatively, include docs_match in the pass/fail predicate at line 217 (WHERE NOT scores_match OR NOT docs_match) — though that would re-introduce tied-cluster false positives the PR intentionally removes, so the row-count guard is the cleaner option.
Three coupled changes that together fix the Wikipedia benchmark validation, which has been silently broken since it was added: 1. Pin the Simple Wikipedia dump date. download.sh now pulls a fixed dump date (default SIMPLE_WIKI_DUMP_DATE=20260501) instead of 'latest'. Because doc_ids are assigned by extraction order from the XML dump, every download of 'latest' produced a different (article_id -> content) mapping. This made committed ground_truth.tsv go stale within days of every new Wikimedia dump, and is what produced the 0-of-80 doc-set match rate observed on actions run 25745376667. Bumping the date in the future is a deliberate operation that should be paired with regenerating ground_truth.tsv in the same PR. 2. Replace precompute_ground_truth.sql with a fast, materialized implementation. The original iterated query terms one at a time, computing document frequency via a fresh to_tsvector() scan of wikipedia_articles per term. On 100K Simple Wikipedia it didn't finish within 10+ minutes. The new version materializes (article_id, term, tf) once via a single tsvector unnest pass, indexes it on term, and computes corpus_stats / df / per-query top-10 via cheap index joins. End-to-end runtime is ~30s, including the load of validation.sql. Output is bit-identical (same BM25 formula, same fieldnorm_quantize, same tie-break ordering by article_id ASC). The new script uses pure SQL — no reference to wikipedia_bm25_idx anywhere — so it remains a valid independent reference for the validator to compare the index against. 3. Regenerate ground_truth.tsv against the pinned 20260501 dump. With the new script and pinned dataset, validate_queries.sql now reports 80/80 scores_match and 80/80 docs_match with worst_abs_diff = 0.000002 against a freshly-built wikipedia_bm25_idx. Together with the per-rank-score validator change in this PR (adapted from #366), this gets Wikipedia validation to a working, deterministic state for the first time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…372) Port the per-rank score validation approach from msmarco (#366) to the Wikipedia validator. ## Motivation Benchmark run [25745376667](https://github.com/timescale/pg_textsearch/actions/runs/25745376667) on `release-1.2.0` failed Wikipedia validation. Of 80 queries: - **19** had `docs_match=f, scores_match=t, max_abs_diff=0.000000` — pure tie-cluster doc-set differences (BMW vs ground-truth tie-break ordering). All matched on scores. - **1** had a real per-rank score divergence beyond 4 decimal places. The old script used doc-set comparison plus 4-decimal rounding and treated *any* divergence as fatal. This produces the same tie-cluster false-positive class that #366 fixed for MS MARCO. ## Change Wikipedia validator now mirrors `msmarco/validate_queries.sql` structurally: - Per-rank score comparison (rank 1..10) within **0.001** absolute tolerance — same threshold and shape as msmarco. - `LIMIT 10` is held inside the inner subquery so it propagates to the BM25 index scan (BMW K=10). See #365 for the trap of letting the planner park the LIMIT above a WindowAgg. - Doc-set match is still reported as supplementary diagnostic info, but no longer part of the pass/fail decision. - Allowlist mechanism with tracking-issue references, starting empty for Wikipedia. ## Behavior Re-running run 25745376667 with this change would have: - ✅ Eliminated all 19 tie-cluster false positives. - ✅ Still flagged the 1 real divergence (or passed it if its abs diff is ≤ 0.001 — the same tolerance applied to msmarco, which is the established standard). ## Notes `benchmarks/datasets/msmarco-v2/validate_queries.sql` still uses the legacy doc-set logic and should likely receive the same treatment. Out of scope for this PR to keep the diff focused on what failed on run 25745376667. Follow-up to #366.
Follow-up to #360, #364. Closes #363 (was a tied-cluster + K=1000 false-positive, not a real concurrent-insert bug).
Root cause
The validation watchdog kept producing non-deterministic concurrent-INSERT-only failures on MS MARCO (queries 1267, 130, 1517, 222, 9351, 9340 across runs), and no amount of investigation could pin them on the index. Turned out the validator itself had this shape:
Postgres can't push the outer
LIMIT 10past the WindowAgg, so the BM25 index scan is asked forpg_textsearch.default_limit(1000) rows. BMW with K=1000 reports subtly wrong scores for some docs on real corpora — see #365 for the full reproducer and code-level hypotheses. Validation then compares the standalone-recomputed per-rank score against ground truth and sees a mismatch — for ranks whose order BMW got wrong because of the under-reported scores.Repro evidence
Local pg17 + concurrent-pgbench-built 8.8M MS MARCO corpus, doc 3906880 / query 1267:
SELECT ... ORDER BY <@> LIMIT 10SELECT row_number() OVER (...), ... FROM ... ORDER BY <@> LIMIT 10SET default_limit=10Same query, same data, same backend session. Only K differs.
The fix
Wrap
ORDER BY ... LIMIT 10in a subquery so the LIMIT actually reaches the index scan.row_number()runs on the resulting 10-row materialized output. Output identical to the buggy shape on healthy data, but K=10 is now used for the index scan, so BMW returns correct scores.Inline comment in the file explains why this shape matters.
Verification
Local pg17 + concurrent-pgbench 8.8M corpus, run the 80 validation queries:
The 8 queries that disappear were all false positives from BMW K=1000 underscoring. The single remaining failure (158) is the stale-pg17-ground-truth / Snowball stemmer drift case tracked in #361 — regenerating
ground_truth_pg17.tsvagainst current pg17 will close it; that regen is running locally and will be a follow-up commit / PR.Allowlist update
Allowlist entry for query 158 has its note updated to reflect the now-confirmed Snowball drift root cause (was previously "root cause not yet isolated").
Refs