matrixorigin / matrixorigin/matrixone

[Feature Request]: WAND-based retrieval index — BM25 top-K retrieval without full-scan

Open
#25,541 3 comments 0 reactions 1 assignee Claimed by @Ariznawlll View on GitHub
kind/feature phase/testing severity/s0
Dominant language
Go
Stars
1.9k
Forks
311
Avg merge
1d 3h
Merged PRs (30d)
768

Description

### Is there an existing issue for the same feature request?

- [x] I have checked the existing issues.

### Is your feature request related to a problem?

# WAND — the algorithm

**WAND** = **W**eak **AND** (a.k.a. Weighted AND). It's a *document-at-a-time (DAAT) top-K retrieval* algorithm — the standard fast way to answer "give me the K highest-BM25-scoring documents for this multi-term query" without scoring every matching document. Introduced by Broder et al. (2003); the **Block-Max WAND (BMW)** refinement is Ding & Suel (2011). It is what the `retrieval` fulltext index uses.

## The problem it solves

A query like `MATCH(txt) AGAINST('red apple banana')` is an **OR** over terms: a document matches if it contains *any* term, and its rank is `score(d) = Σ_t score(t, d)` (BM25 per term). The naive plan scores **every** document containing **any** query term, then sorts and takes the top-K. On a large corpus with common terms, that scores most of the collection — and you only wanted K rows. WAND skips the documents that provably **cannot** enter the top-K.

## The core idea: a threshold + upper bounds

Two ingredients:

1. **A running threshold `θ`** = the K-th best score seen so far (the smallest score currently in a size-K min-heap). A document can only matter if its score can beat `θ`.
2. **A per-term score upper bound `UB(t)`** = the maximum score term `t` can contribute to *any* document (precomputed from its posting list — for BM25, roughly `idf(t) × max-tf-component`). Then for any document `d`:

```
score(d) ≤ Σ_{t ∈ query, t ∈ d} UB(t)
```

If that **upper bound < θ**, the document cannot reach the top-K → **skip it, never score it.**

## The mechanism: pivoting over sorted cursors

Each query term keeps a **cursor** over its posting list (posting lists are sorted by doc id). One DAAT step:

1. **Sort the cursors by their current doc id** (ascending).
2. **Find the pivot.** Walk the cursors in doc-id order, accumulating `UB(t)`. The first term at which the running sum **exceeds `θ`** is the **pivot term**; its current doc id is the **pivot doc** — the *smallest* doc id whose upper bound could possibly beat `θ` (everything before it has too few high-value terms).
3. **Act on the pivot:**
- If every cursor *before* the pivot is already **at** the pivot doc → all those terms are present in the pivot doc → **fully score it**; if its real score > `θ`, push it into the heap and raise `θ`.
- Otherwise → the lagging cursors are on smaller doc ids that cannot win, so **advance them forward to (at least) the pivot doc** — jumping over a whole run of hopeless documents.

As the heap fills, `θ` **rises monotonically**, which makes the upper-bound test stricter, which makes the pivot jumps larger — the search accelerates as it goes. Only the documents that could plausibly enter the top-K are ever fully scored.

## Block-Max WAND (the refinement in use)

A single global `UB(t)` is loose — one outlier posting inflates it everywhere. **BMW** divides each posting list into **blocks** and stores a **max score per block**. The pivot test then uses the *block-local* max for each cursor's current region, giving a **much tighter** upper bound → more aggressive skipping, and it can **skip whole blocks** at once. (In the index, this is the per-block metadata `finalizeScoring` computes alongside the postings.)

## Why it is both fast and correct

- **Exact, not approximate.** WAND/BMW only skips a document when its *upper bound* is below `θ`. Since the upper bound ≥ the true score, a skipped document truly could not have entered the top-K — the returned top-K is **identical** to the exhaustive-scoring result. (This is why the gold correctness test diff-checks WAND's top-K against a brute-force `Σ tf·idf²` reference.)
- **Fast** because it evaluates a small fraction of the OR-matching documents — the win grows with corpus size and with how selective K is.

## How it maps to the MO `retrieval` index

- **Storage** = the postings (`docID`, `tf`) per term + docmap (pk, doc length) + per-block max scores, serialized into the tag=0 base sub-indexes (and tag=1 tail deltas). BM25 needs corpus stats `N` (doc count) and `avgdl` (average doc length), precomputed at load.
- **Query** = the pushed-down top-K (`LIMIT k` → heap size K), an optional membership bitmap (the `WHERE` prefilter, AND-ed into the walk so filtered-out docs are skipped like non-matches), and the multi-segment liveness bitmap (deletes/updates). The engine runs the BMW walk over the live, filtered postings and returns the exact BM25 top-K **directly — no SQL SORT node.**

## In one line

> **WAND is a safe top-K pruning strategy** — keep a threshold of the current K-th best score, bound each candidate by the sum of its terms' max possible contributions, and skip every document whose bound cannot beat the threshold — so you score only the handful of documents that could actually win.

## References

- A. Z. Broder, D. Carmel, M. Herscovici, A. Soffer, J. Zien. *Efficient Query Evaluation using a Two-Level Retrieval Process.* CIKM 2003. (original WAND)
- S. Ding, T. Suel. *Faster Top-k Document Retrieval Using Block-Max Indexes.* SIGIR 2011. (Block-Max WAND)

### Describe the feature you'd like

### Describe implementation you've considered

_No response_

### Documentation, Adoption, Use Case, Migration Strategy

```Markdown

```

### Additional information

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.