matrixorigin / matrixorigin/matrixone
[Bug]: IVFFlat `mode=pre` recall lower than `mode=post` on filtered ANN (`file_id` WHERE) — pre expected ≥ post
- Dominant language
- Go
- Stars
- 1.9k
- Forks
- 311
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 768
Description
### Describe the bug
On filtered IVFFlat ANN (`WHERE file_id = ?` + `ORDER BY l2_distance ... LIMIT k` + `BY RANK WITH OPTION 'mode=pre|post'`), **pre-filter recall is consistently lower than post-filter**, which contradicts the expected semantics:
| Expectation | Reality (nightly wiki_all 10M) |
|---|---|
| `mode=pre` should search within the filtered PK set → **recall ≥ post** | pre **~12–13%** vs post **~19–20%** |
Unfiltered `l2_only` pre/post recall is identical (75.52%), so the gap is specific to **filtered** queries.
### Evidence (nightly)
- Workflow: [Binary Search On TKE New](https://github.com/matrixorigin/mo-nightly-regression/actions/runs/30062290197)
- Job: [IVF PRE POST FILTER VECTOR RECALL TEST](https://github.com/matrixorigin/mo-nightly-regression/actions/runs/30062290197/job/89435125267) (success)
- Commit under test: `f992e248` (`fix/issue-25114-parallel-vector-load`)
- Dataset: wiki_all **10M × 768d**, IVFFlat `lists=3162`, `probe_limit=5`, k=10, concurrency=100, GT=`mode=force` (exact)
- Filter: `file_id` with 50 distinct ids (`filter-file-id-base=20000000`)
| SQL mode | Filter | Recall@10 | QPS |
|---|---|---:|---:|
| l2_only | pre | **75.52%** | 64.61 |
| l2_only | post | **75.52%** | 417.47 |
| l2_filter | pre | **12.48%** | 163.99 |
| l2_filter | post | **19.39%** | 414.30 |
| l2_filter_threshold | pre | **13.16%** | 163.70 |
| l2_filter_threshold | post | **20.02%** | 322.14 |
Artifact: `wiki-all-10m-recall-commit-f992e2482`
### SQL shape
```sql
-- index path (pre / post)
SELECT `file_id`, `id`, l2_distance(`embedding`, ?) AS dist
FROM `jst_app_wiki`.`historical_file_blocks_wiki_ivfflat10m`
WHERE `file_id` = ?
ORDER BY dist ASC
LIMIT 10
BY RANK WITH OPTION 'mode=pre'; -- or mode=post
-- GT path (exact)
... BY RANK WITH OPTION 'mode=force';
```
### Suspected root cause
Two planner asymmetries between pre and post look highly relevant:
1. **Post over-fetches; pre does not**
In `apply_indices_ivfflat.go`, over-fetch is applied only when filters exist **and** pushdown is disabled (post):
```go
if len(scanNode.FilterList) > 0 && !ivfCtx.pushdownEnabled {
overFetchFactor := calculateFilteredPostModeOverFetchFactor(originalLimit)
// k=10 → factor 5.0 → IVF Limit ≈ 50
}
```
So post effectively does ANN top-~50 then filter; pre keeps IVF `Limit = k` with BloomFilter / membership pushdown.
If pre pushdown is incomplete or applied **after** distance top-k inside probed lists, pre degenerates to “post without over-fetch” → **strictly worse recall than post**, matching the numbers (~12% vs ~19%).
2. **Known “LIMIT before filter turns pre into post” hazard**
`pkg/vectorindex/ivfflat/search.go` already documents that pushing `ORDER BY vec_dist LIMIT k` onto the entries scan **before** the PK filter converts intended pre-filter into post-filter (ExactPkFilter path deliberately omits ORDER BY/LIMIT for this reason). The centroid + membership-filter path still uses `ORDER BY vec_dist LIMIT %d` — worth verifying whether runtime membership / BloomFilter is applied **before** that limit on entries.
Within the same `nprobe` lists, a correct pre-filter (rank among **all** rows matching `file_id` in those lists) should dominate post (rank globally then keep matching rows). Observed pre < post strongly suggests pre is not ranking the full filtered candidate set inside the probed lists.
### Expected behavior
For the same `probe_limit` / lists / k:
- Filtered `mode=pre` recall **≥** filtered `mode=post` recall (ideally closer to `mode=force` as selectivity allows).
- If pre cannot fill k after filter, prefer expanding nprobe / candidate budget rather than silently under-recalling vs post.
### Suggested fix directions
1. **Correct filter vs LIMIT order on entries**
Ensure membership / ExactPk / BloomFilter is applied **before** distance Top-N on IVF entries (same invariant as ExactPkFilter path). Add regression covering filtered pre with selective `WHERE` + small k.
2. **Give filtered pre an adequate candidate budget**
- Either over-fetch for pre as well when needed, or
- Adaptively raise `nprobe` for filtered pre (similar to auto-mode `calculateAdaptiveNprobe` on selectivity), so probed lists contain enough rows for the filter predicate.
3. **Assert invariant in BVT / nightly**
On wiki / mini filter cases: `recall(pre) >= recall(post)` (tolerance ε), fail the job when violated.
4. **Short-term workaround for callers**
Prefer `mode=post` for filtered IVF until fixed, or raise `probe_limit` / use `mode=auto` and validate recall; do not assume pre is safer for accuracy today.
### How to reproduce
1. Build MO from (or near) `f992e248` / current main with IVFFlat enabled.
2. Load wiki_all 10M (or a smaller filtered IVF fixture with selective equality predicate).
3. Create IVFFlat index (`lists≈√N`, `probe_limit=5`).
4. Run vector_benchmark recall:
```bash
python run_wiki.py recall --config cfg/ivfflat_10M.json \
--sql-mode l2_filter --filter-mode pre \
--gt-source ann -n 10000 -k 10 --concurrency 100
python run_wiki.py recall --config cfg/ivfflat_10M.json \
--sql-mode l2_filter --filter-mode post \
--gt-source ann -n 10000 -k 10 --concurrency 100
```
5. Observe `avg recall@10` for pre < post.
### Environment
- MO binary-search commit: `f992e24826107b99bd96c8496161c2d667d9e8d2`
- Cluster: TKE nightly (`mo-search-commit-f992e2482-20260724`)
- Index: `USING ivfflat ... lists=3162 op_type "vector_l2_ops"`
- Session: `probe_limit=5`, `ivf_preload_entries=0`
Contributor guide
Assessment
This issue has not been assessed yet.