Skip to content

fix(ci): validate_queries.sql — propagate LIMIT 10 to BMW index scan; document #365 trap - #366

Merged
tjgreen42 merged 2 commits into
mainfrom
greentodd/validate-queries-limit-pushdown
May 12, 2026
Merged

tjgreen42 merged 2 commits into
mainfrom
greentodd/validate-queries-limit-pushdown

Conversation

@tjgreen42

Copy link
Copy Markdown
Collaborator

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:

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, so the BM25 index scan is asked for pg_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:

BMW K BMW score Standalone score
Query A: SELECT ... ORDER BY <@> LIMIT 10 10 -23.0376 ✅ 23.0376
Query B: SELECT row_number() OVER (...), ... FROM ... ORDER BY <@> LIMIT 10 1000 -20.6486 ❌ 23.0376
Query B with SET default_limit=10 10 -23.0376 ✅ 23.0376

Same query, same data, same backend session. Only K differs.

The fix

Wrap ORDER BY ... LIMIT 10 in 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.

SELECT row_number() OVER ()::int as rank, t.passage_id, t.score FROM (
    SELECT passage_id, -(content <@> q)::float8 as score
    FROM msmarco_passages
    ORDER BY content <@> q
    LIMIT 10
) t;

Inline comment in the file explains why this shape matters.

Verification

Local pg17 + concurrent-pgbench 8.8M corpus, run the 80 validation queries:

Failing queries Worst diff
Before this fix 9 (1267, 1977, 158, 1522, 222, 807, 9351, 215, 9340) 1.46
After this fix 1 (158, allowlisted in #361) 0.62 (the genuine stemmer-drift)

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.tsv against 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

tjgreen42 and others added 2 commits May 12, 2026 01:28
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>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 106 to 118
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@tjgreen42
tjgreen42 merged commit 79a7c12 into main May 12, 2026
9 of 10 checks passed
@tjgreen42
tjgreen42 deleted the greentodd/validate-queries-limit-pushdown branch May 12, 2026 09:49
tjgreen42 added a commit that referenced this pull request May 12, 2026
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>
tjgreen42 added a commit that referenced this pull request May 13, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validate_queries.sql: MS MARCO query 1267 fails only on concurrent-build path (rank-5 doc missing from top-10)

1 participant