Skip to content

feat: fulltext2 — a position-free BM25 full-text retrieval index#25904

Draft
cpegeric wants to merge 1065 commits into
matrixorigin:mainfrom
cpegeric:fulltext2_only
Draft

feat: fulltext2 — a position-free BM25 full-text retrieval index#25904
cpegeric wants to merge 1065 commits into
matrixorigin:mainfrom
cpegeric:fulltext2_only

Conversation

@cpegeric

@cpegeric cpegeric commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue ##25541

What this PR does / why we need it:

Summary

Adds fulltext2, a new first-class full-text index type built around a WAND / block-max ranked-retrieval engine, and removes the standalone bm25 index (its ranked-retrieval capability is now subsumed by fulltext2's POSITION_FREE mode). fulltext2 is a distinct algo from classic fulltext — not a variant — so it carries its own contract, on-disk format, plugin, CDC path, and query surface.

Classic fulltext keeps positional term data for exact phrase/boolean/NL semantics. fulltext2 adds a position-free build option that drops positions (keeping the FST term dictionary), yielding a much smaller, faster, ranked bag-of-words index — while still supporting a positional build for phrase/boolean parity when you want it.

Gated behind the experimental_fulltext2_index session variable.

Motivation

The earlier "WAND retrieval" experiment proved that ranked BM25 retrieval does not fit as a fulltext parser-mode variant (boolean/phrase/NL need term positions the position-free binary index cannot serve). Rather than graft ranked retrieval onto classic fulltext or maintain a separate bm25 index type, we make it its own index type with a clean contract, and fold the old bm25 engine into it.

SQL surface

DDL — a distinct index type via the USING/index-type seam, with a configurable tokenizer and a position-free option:

SET experimental_fulltext2_index = 1;

-- positional (phrase / boolean / NL capable); parser defaults to ngram
CREATE FULLTEXT2 INDEX idx ON t(body);
CREATE FULLTEXT2 INDEX idx ON t(body) WITH PARSER gojieba;

-- position-free, ranked bag-of-words — requires WITH PARSER gojieba
CREATE FULLTEXT2 INDEX idx ON t(body) WITH PARSER gojieba POSITION_FREE=TRUE;

Full CREATE option grammarCREATE FULLTEXT2 INDEX name ON t(cols) <options>, where <options> may combine, in any order:

Option Values Meaning
WITH PARSER ngram (default) · gojieba · json · json_value tokenizer
POSITION_FREE TRUE · FALSE (default) drop positions → ranked bag-of-words only (requires gojieba)
AUTO_UPDATE TRUE · FALSE (default) enable the idxcron scheduled auto-maintenance for this index
DAY / HOUR / SECOND integer the auto-maintenance cadence (combined; e.g. DAY=1 = compact daily)
MAX_INDEX_CAPACITY integer per-segment doc capacity (drives tiered-merge fullness)
ASYNC / FORCE_SYNC flag build the initial index asynchronously (CDC) vs synchronously
-- gojieba, position-free, with daily auto-maintenance:
CREATE FULLTEXT2 INDEX idx ON t(body)
  WITH PARSER gojieba POSITION_FREE=TRUE AUTO_UPDATE=TRUE DAY=1;

-- capacity-bounded segments + hourly auto-maintenance:
CREATE FULLTEXT2 INDEX idx ON t(body)
  MAX_INDEX_CAPACITY=100000 AUTO_UPDATE=TRUE HOUR=6;

AUTO_UPDATE=TRUE + DAY/HOUR/SECOND are stored in the index's algo-params and read by the idxcron executor, which schedules a periodic MERGE/REBUILD (see Maintenance below). POSITION_FREE=TRUE is only valid with WITH PARSER gojieba; a bare POSITION_FREE=TRUE (defaults to ngram) and WITH PARSER ngram POSITION_FREE=TRUE are both rejected at DDL — ngram/json emit overlapping tokens meaningless without positions, so they can only build a positional index.

Query — ranked retrieval mode alongside the classic modes:

-- ranked bag-of-words top-K (works on a POSITION_FREE index)
SELECT * FROM t WHERE MATCH(body) AGAINST('query terms' IN BM25 MODE) LIMIT 10;

-- NL / BOOLEAN / phrase still available on a *positional* fulltext2 index
SELECT * FROM t WHERE MATCH(body) AGAINST('+cat -dog' IN BOOLEAN MODE);

Maintenance — ALTER … REINDEX (MERGE / REBUILD / retune)

fulltext2 maintains a tiered index: a tag=0 base (built from source) plus a tag=1 CDC tail of incremental inserts/deletes. ALTER TABLE … ALTER REINDEX <name> FULLTEXT2 <option> drives compaction and retuning:

-- MERGE: fold the tag=1 CDC tail into the tag=0 base (in place, keeps the current
-- position-free/positional mode). Cheap incremental compaction.
ALTER TABLE t ALTER REINDEX ft FULLTEXT2 MERGE;

-- REBUILD (no option): drop the tail AND rebuild the base entirely from source.
-- Use when the dead-doc ratio is high or to recover a partially-materialized index.
ALTER TABLE t ALTER REINDEX ft FULLTEXT2;

-- Retune POSITION_FREE both ways (a REBUILD from source under the new mode).
-- Tri-state parse distinguishes =FALSE from "unset", so it toggles deterministically:
ALTER TABLE t ALTER REINDEX ft FULLTEXT2 POSITION_FREE=FALSE;  -- → positional (phrase/boolean work)
ALTER TABLE t ALTER REINDEX ft FULLTEXT2 POSITION_FREE=TRUE;   -- → position-free (IN BM25 MODE only)

-- FORCE_SYNC: run the reindex synchronously instead of via async CDC.
ALTER TABLE t ALTER REINDEX ft FULLTEXT2 MERGE FORCE_SYNC;

Scheduled (idxcron) maintenance. When the index was created AUTO_UPDATE=TRUE with a DAY/HOUR/SECOND cadence, the idxcron executor runs the same reindex action automatically on that interval, choosing MERGE vs REBUILD by the dead-doc ratio (a tail past the dead-doc threshold rebuilds; otherwise it merges). No manual ALTER … REINDEX is needed in that mode.

Architecture

  • Engine (pkg/fulltext2/): segment build sink, delta+varint block-postings format, FST term dictionary, block-max WAND top-k, dense boolean evaluator, block-by-block phrase intersection, multi-segment index with global corpus stats + per-segment liveness/deletes, mmap'd base segments, CDC tail builder, and MERGE compaction.
  • fulltext2 AlgoPlugin (pkg/fulltext2/plugin/{plan,compile,runtime,idxcron,iscp}): five hook sets, all with var _ Hooks compile-time assertions; routed through the indexplugin.Get(algo) registry (no new per-algo switch in plan/compile). Blank-imported in indexplugin/all and indexplugin/iscp.
  • SQL-plan integration: apply_indices_fulltext2.go / apply_indices_match.go route MATCH … AGAINST to the fulltext2_search TVF with ORDER BY score DESC + LIMIT pushdown; fulltext2_compact.go drives compaction.
  • TVFs: fulltext2_{create,search,compact}.
  • CDC + idxcron: async incremental maintenance (CREATE→sync→query→REINDEX→DROP), scheduled MERGE/REBUILD.

Tokenizers

ngram (default), gojieba (CJK word segmentation), json (JSON values flattened to text, then tokenized as ngram), and json_value (each leaf value indexed as one whole atomic token — classic-fulltext json_value parity), with tokenization parity between build and query. POSITION_FREE is supported only with gojieba — ngram and json produce overlapping/redundant tokens that can't be reconstructed without positions, so they always build a positional index (and are rejected if POSITION_FREE=TRUE is requested).

Performance & footprint

Position-free build is materially smaller and faster than both classic fulltext and the old bm25 index. Residency wins: block-compressed mmap postings + lazy directory (decode a term's block-max entry on lookup), lazy positions section, dense O(N) boolean evaluator, insertion-sort WAND pivot loop (no per-iteration alloc), and a cached cursor curDoc with inline in-block skip.

Safety / guards

  • Reject a FULLTEXT + FULLTEXT2 index on the same column set at DDL.
  • Reject NL/BOOLEAN/phrase modes on a POSITION_FREE index with a clear message.
  • OOM defenses: bounded top-k heap (uncapped LIMIT no longer OOMs), fail-fast memory guard on CDC tail load, off-heap posting decode, corrupt-frame / uvarint-overflow guards on read.
  • Injective per-row keys for temporal/decimal PKs (no data loss); CDC hands uuid/decimal/datetime as typed strings (no types.EncodeValue panics).
  • ALTER ADD FULLTEXT no longer locks its not-yet-created hidden table.
  • Experimental-flag gated; restore/clone supported.

Testing

New BVT suites under test/distributed/cases/fulltext2/ and pessimistic_transaction/fulltext2/: functional sync, POSITION_FREE + IN BM25 MODE, parser coverage (ngram/gojieba/json_value), membership prefilter, ORDER-BY/LIMIT pushdown, FULLTEXT+FULLTEXT2 conflict rejection, async CDC convergence, MERGE/REBUILD, restore/clone, and bugfix regressions. Extensive engine unit tests in pkg/fulltext2/ (boolean parity, phrase-exact, CJK, multi-segment, mmap, serialize round-trip).

Compatibility

New opt-in index type behind an experimental flag; no change to classic fulltext behavior or on-disk format. Fulltext2 reports as FULLTEXT in information_schema / SHOW INDEX (correct MySQL taxonomy).

cpegeric and others added 30 commits June 16, 2026 14:32
The UnionBatch full-append varlena fast path (from the null/grouping extension)
propagated w's null/grouping bitmaps with w.nsp.Foreach / w.gsp.Foreach, which
walk EVERY set bit in the underlying bitmap with no upper bound. The per-row
path it replaces only consults [0,cnt) via Contains, so the two diverge whenever
w carries stale bits at index >= w.length — a normal reused state, since
SetLength shrinks v.length without clearing nsp/gsp and vectors are pooled.

For a stale nsp bit i >= cnt, `vCol[oldLen+int(i)] = types.Varlena{}` indexes the
oldLen+cnt-length slice out of range -> panic. For a stale gsp bit it sets a
phantom grouping bit at oldLen+i beyond the appended range. (The predecessor
commit was safe: its fast path was gated on nsp/gsp EmptyByFlag, so any stray
bit fell through to the per-row path.)

Fix: skip indices >= cnt in both Foreach callbacks, matching the per-row path.
Keeps the two-memmove fast path; only bitmap propagation changes.

Found by an adversarial self-review-to-break pass; the shipped
TestUnionBatchNullFastPath only sets bits within length so it missed this.
Adds TestUnionBatchFastPathStaleBitmapBits (stale nsp/gsp bits past SetLength,
cross-checked vs per-row UnionOne + phantom-bit assertions) — it panics on the
unbounded code and passes with the bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the perf optimizations (fileservice ReadAt coalescing, UnionBatch varlena
batching) and the UnionBatch fast-path bitmap fix onto vecf16.

Conflicts (pkg/container/vector, 2 files):
- vector.go: both hunks were the UnionBatch fast-path gsp/nsp propagation —
  vecf16 had the unbounded Foreach (the bug), refactor_vector_fs had the
  [0,cnt) bound. Took the bounded fix (stale bits >= w.length no longer panic /
  pollute v.gsp). vecf16's surrounding narrow-vec changes auto-merged and are
  preserved.
- unionbatch_fastpath_test.go: kept the new TestUnionBatchFastPathStaleBitmapBits
  regression test from refactor_vector_fs.

Verified: pkg/container/vector + pkg/fileservice build; vector suite green with
-race (incl. the new regression test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt8/uint8)

vector_dims only had float32/float64 overloads, so it errored on the narrow
vector types: "invalid argument function vector_dims, bad value [VECINT8]".
Widen VectorDimsArray from RealNumbers to ArrayElement (the body is just
BytesToArray[T] + len, which is correct for any element type — dimension =
content_bytes / sizeof(T)) and register the bf16/f16/int8/uint8 overloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st rejection

Covers the two narrow-vector behaviors added on this branch:
- array_vecnarrow_dims: vector_dims on vecbf16/vecf16/vecint8/vecuint8 returns the
  element count (and NULL for a NULL vector), matching vecf32 — guards the fix that
  added the narrow vector_dims overloads.
- vector_ivf_quant_upcast: ivfflat CREATE INDEX with a QUANTIZATION wider than a
  narrow base column is rejected at plan time (downcast-only) — guards the schema.go
  upcast check. Plan-time errors, no GPU/index-build needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-run with fixed methodology (persistent per-thread conn; 4-pass warm
median): build/recall/QPS/p50 across gpu/nogpu x simd/nosimd. GPU -> recall;
SIMD -> build + search. int8/uint8 base fastest search; narrow (non-upcast)
entries are the win. Adds cold-scan profile: read path ~51% (pread ~27% +
de-interleave ~21% + CRC ~3%), lz4 ~21%, materialize ~4%, distance ~3%;
storage 1.4/3.2/13.5 GB/s; S3-FIFO double-miss as the cold-start lever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pregrowVarlenaArea's sizing loops (unionT sels-gather and the UnionBatch general
path) inspected wCol[...] headers without checking whether the source row is
null. A null varlen append does not overwrite the slot, so a reused vector can
retain a stale non-inline header in a null position. The union copy loops below
skip null rows (w.nsp), but the pre-grow counted them — so a null-heavy append
reserved area for dead old payload, triggering a large needless mp.Grow (or an
allocation failure) on inputs the union would mostly skip.

Skip null rows in both pre-grow loops (gated on !w.nsp.EmptyByFlag()), matching
exactly the rows the copy loops actually append. Output is unchanged; only the
reservation size is corrected.

Adds TestUnionPregrowSkipsNullRows: a reused vector with stale 1 MiB headers in
null slots; pre-fix the pre-grow reserves ~16 MiB of area (assertion catches
cap), post-fix v.area stays tiny. Cross-checks values + nulls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ALTER TABLE ... ALTER REINDEX shares index_option_list with CREATE INDEX,
so build options parse but were silently dropped (only lists/force_sync
took effect). Each algorithm now honors the build options it supports on
a rebuild and rejects the rest with a clear "not supported" error.

Mechanism (no proto change): the options are read off the parse tree
(c.stmt) at compile via reindexSpecifiedParams and passed through
ReindexParamUpdate.Params to each plugin's Compile.ValidateReindexParams,
which merges the supported keys into the persisted IndexAlgoParams (shared
MergeReindexParams helper) and rejects unsupported ones. Persistence stays
at the existing UPDATE mo_catalog.mo_indexes site.

Per-index supported options:
  ivfflat: lists, kmeans_train_percent, kmeans_max_iteration
  ivfpq:   + max_index_capacity, m, bits_per_code
  cagra:   max_index_capacity, intermediate_graph_degree, graph_degree, itopk_size
  hnsw:    m, ef_construction, ef_search, max_index_capacity

Hardening / cleanup:
- escape the algo_params JSON + index name in the reindex UPDATE
  (sqlquote.EscapeString) so a user option value cannot break out of the
  string literal (SQL injection); defense-in-depth for future string opts.
- AlterOptionAlterReIndex.Format now reproduces all carried build options
  (lowercase, string values quoted) for a lossless, re-parseable round-trip.
- quantization is deliberately left out (deferred to the in-progress vecf16
  quantization work) to avoid conflicts; it is rejected, not silently kept.
- value validation is handled by the shared index_option_list grammar (> 0),
  matching CREATE; no per-plugin value checks needed.
- removed the now-useless BuildAlterReIndex plan hook (interface + 5 impls):
  ForceSync is set generically in build_ddl.go; the plan node's
  IndexAlgoParamList field is no longer written.

Tests: reindex_params_test.go (extraction), per-plugin merge/reject unit
tests, parser round-trip cases, and a CPU BVT (vector_reindex_options,
ivfflat + hnsw) verified end-to-end with mo-tester (23/23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-adds quantization to the reindex per-index options (it was held back on
gpu_plugin_all to avoid conflicting with the in-progress quantization work,
which has now landed on vecf16). Validation mirrors CREATE INDEX, per backend:

- reindexSpecifiedParams extracts quantization normalized to lowercase
  (catalog.ToLower), matching CREATE so case-sensitive consumers agree.
- ivfflat: validated via quantizer.ToVectorType (float32/float16/bf16/int8/
  uint8) and added to its supported set.
- cagra / ivfpq: validated via metric.ValidQuantization (cuvs map:
  float32/float16/int8/uint8 — bf16/float64 rejected) and added to their sets.
- hnsw: not supported yet (will be added soon); quantization stays rejected
  there, with no test locking that.

Tests: extraction normalization (Float16 -> float16); per-plugin accept/reject
(incl. bf16 accepted by ivfflat but rejected by cagra/ivfpq). BVT
vector_reindex_options extended with ivfflat quantization merge + reject,
verified end-to-end with mo-tester (26/26).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
golangci-lint gofmt flagged comment alignment in these test files (from the
bf16/narrow-vector work). gofmt -w; whitespace-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Narrow-vector l2_distance is computed in float64 now, so its low-order bits
differ across CPU SIMD kernels (CI amd64 AVX-512/AVX2 vs the result-gen host).
The hardcoded f32-precision expectations were failing.

- func_vecnarrow_test.go (UT): TestL2DistanceNarrowArray expected the stale f32
  value 7.071067810058594; updated to the f64 reference 7.0710678118654755
  (diff was 1.8e-9, just over the framework's 1e-9 InEpsilonF64 tolerance which
  otherwise absorbs real cross-platform f64 variance).
- array_vecuint8 / load_data_narrow_vec (BVT): wrap the sqrt/division distances
  (l2_distance, cosine_*) in round(..., 4); l2_distance_sq / inner_product are
  integer-exact for uint8 and left as-is. .result regenerated.
- vector_ivf_quant_ddl (BVT): its .result expected "internal error: version not
  found" for cloned quantized-ivf search, but cloning works now (the table_clone
  (IndexName, IndexAlgoTableType) keying fix merged in from gpu_plugin_all), so
  the clone search returns correct nearest neighbors. .result regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…agra

Add a half-source scalar quantizer path so a vecf16 base column can be stored
natively as half (direct) or quantized to int8/uint8 with NO f32 detour:
- index_base: half_quantizer_ (scalar_quantizer_t<half>) + uses_half_quantizer_
  flag; add_chunk_quantize_half / flush_pending_half_chunks_if_needed (train on
  the buffered vecf16 sample, transform half->T, store via native add_chunk(T));
  train_quantizer_half; serialize/deserialize the half quantizer.
- ivf_pq/cagra build(): flush the half buffer + skip the float train for a half
  base; fix the count==0 early-return to also consider pending_half_total_count_.
- quantize_half_query: quantize a half query -> T via half_quantizer_ for the
  search path (reuses the native search).
- C wrappers: gpu_{ivf_pq,cagra}_add_chunk_quantize_half / _quantize_half.
- tests: ivf_pq/cagra native half build+search (BasicLoadAndSearchHalf) and
  half->int8 quantize build (HalfQuantizeToInt8Build); brute_force_test note
  that cuVS brute force is f32/f16-only (int8/uint8 unsupported).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the new C half-source entry points and the native-query search:
- GpuIvfPq/GpuCagra.AddChunkQuantizeHalf([]Float16) — build-time half->T quantize.
- GpuIvfPq/GpuCagra.QuantizeHalf([]Float16) []T — quantize a half query to the
  1-byte storage type for the search path.
- MultiGpuIvfPq/MultiGpuCagra.SearchQuantizeHalf — quantize the half query via the
  index's half quantizer, then run the existing native Search([]T) (reuses
  sharding/overflow/merge; async via the worker pool, no extra goroutine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TION guard

SupportedVectorTypes -> {float32, float16} for both indexes. schema.go accepts an
f16 base and rejects an upcast QUANTIZATION (storage wider than base, e.g.
vecf16 + 'float32'); f16 + int8/uint8 is allowed (downcast). int8/uint8 base
columns stay unsupported (cuVS brute force can't back their CDC overflow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntizeHalf)

model_gpu: AddChunk([]T) (native, vecf16->half direct) and AddChunkQuantizeHalf
([]Float16) (vecf16->int8/uint8). build_gpu: Add(id, []T) and AddQuantizeHalf(id,
[]Float16). These route to the cuvs native AddChunk / the half-source quantize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
IvfpqSearch/CagraSearch.Search accept a native []cuvs.Float16 query: f16-direct
(storage==half) runs native MultiIndex.Search([]Float16) (filtered uses an exact
half->f32 widen); f16->int8/uint8 quantizes the half query to T via
MultiIndex.SearchQuantizeHalf, then native search. f32 path unchanged.

Note: filtered f16->int8/uint8 and the small-data/CDC base-typed overflow remain
(the [B,Q] overflow refactor, tracked separately).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_gpu: derive the storage qtype from an f16 base (F16 when no QUANTIZATION),
decode the base column natively (f16ToCuvs bridge), route to Add (direct) or
AddQuantizeHalf (quantized). search_gpu: derive Quantization=F16 from KeyPartType,
decode a vecf16 query natively to []cuvs.Float16 (runXxxSearchHalf) and dispatch
in Search (direct vs quantized).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CDC/overflow brute force is typed by the BASE type B (f16/f32), independent
of the main index storage type Q (int8/uint8 when quantized) — so the overflow
is cuVS-supported and lossless, and small-data / overflow-only quantized indexes
can be searched. Also fixes the previously-broken f32-base + QUANTIZATION=int8
overflow (was GpuBruteForce[int8], which cuVS rejects).

- multi_index.go: searchWaiter interface + multiGpuSearchBQ[Q,B] (submit indices
  with []Q and the overflow with []B async to the worker pool; type-agnostic
  collect/merge — no extra goroutine). MultiGpuIvfPq/MultiGpuCagra become [B,Q].
- IvfpqSearch/CagraSearch[B,Q]: Indexes []*Model[Q], Overflow *GpuBruteForce[B],
  MultiIndex *MultiGpu*[B,Q]; buildOverflow creates GpuBruteForce[B];
  SearchQuantizeHalf searches indices (quantized query) + overflow (native half),
  and works overflow-only (no main index).
- dispatch newIvfpqAlgo/newCagraAlgo on (KeyPartType base, Quantization storage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ivfpq/cagra apply_indices paths never serialized "parttype" into the
search-side IndexTableConfig JSON, so KeyPartType defaulted to 0. The search
factory dispatches the base type B on KeyPartType == T_array_float16; with it
unset, an f16 index always fell through to B=float32. A float32 overflow brute
force was then fed a []Float16 query, the any(query).([]float32) assertion
failed, and an empty SearchAsync returned job id 0 — SearchWait(0) blocked
forever (large f16 tables masked this: no overflow, so the brute force is never
searched).

Add partType to the ivfpq/cagra index contexts (from the base column's type)
and emit "parttype" in both tblcfg templates, mirroring ivfflat. f16-direct and
f16->int8/uint8 overflow searches now dispatch B=Float16 and run natively.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildOverflow now routes overflow vectors through addOverflowVecs, which adds
them in the brute force's native element type — half via AddChunk for a
Float16 overflow, f32 via AddChunkFloat otherwise — so the stored element type
matches the native-B overflow search path.

multiGpuSearchBQ gains a guard: if the overflow brute force is loaded but
neither a base-typed nor an f32 query was supplied (a [B,Q]/query dispatch
mismatch), it returns an error instead of submitting an empty job that would
hang in SearchWait. Includes the HalfEmptyAddChunkSearch cuVS test covering
the empty-ctor + native half add + half search path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	pkg/sql/compile/ddl.go
#	pkg/sql/compile/plugin_context.go
#	pkg/sql/compile/reindex_params_test.go
#	pkg/sql/parsers/dialect/mysql/mysql_sql.go
#	pkg/vectorindex/cagra/plugin/compile/compile.go
#	pkg/vectorindex/cagra/plugin/compile/compile_test.go
#	pkg/vectorindex/ivfflat/plugin/compile/compile.go
#	pkg/vectorindex/ivfflat/plugin/compile/compile_smoke_test.go
#	pkg/vectorindex/ivfflat/plugin/plan/schema.go
#	pkg/vectorindex/ivfflat/plugin/runtime/runtime.go
#	pkg/vectorindex/ivfflat/plugin/runtime/runtime_test.go
#	pkg/vectorindex/ivfpq/plugin/compile/compile.go
#	pkg/vectorindex/ivfpq/plugin/compile/compile_test.go
#	pkg/vectorindex/types.go
#	test/distributed/cases/vector/vector_reindex_options.result
#	test/distributed/cases/vector/vector_reindex_options.sql
#	test/distributed/gpu_cases/pessimistic_transaction/vector/vector_ivfpq_async.result
Step 5: the CDC tail / overflow tier now stores and replays vectors in the
index's native base type (vecf16 stays 2 bytes/elem on the wire — no f32
widening), feeding the base-typed brute force directly.

- cuvs CDC codec (cdc.go, small_tail.go): EncodeEventRecord/DecodeEventRecord/
  ReplayEventLog/SaveSmallTailAsCdc and CdcEventRecord/OverflowEntry/
  ReplayState/PendingRecord become element-type-agnostic — the vector body is
  raw little-endian bytes (Vec []byte) and the per-row length is vecBytesPerRow
  (dim * element size), replacing the f32-only `dim`/`4*dim` assumption. For
  f32 the on-wire bytes are byte-identical, so existing f32 CDC streams are
  unchanged. The codec stays free of any element-type constraint.
- IvfpqModel/CagraModel become [B, Q] (base, storage); OverflowVecs is []B and
  replayEventChunks[B] reinterprets the codec's bytes to []B (dim*sizeof(B)).
  The build keeps a single storage param (phantom B=float32) since build-time
  models never carry overflow.
- create table-fns buffer the tail as native base-type bytes (half for vecf16);
  search buildOverflow feeds bf.AddChunk(m.OverflowVecs []B) directly, dropping
  the f32-detour addOverflowVecs helper.
- sync.go / iscp cuvs_writer stay f32 (ongoing f16 ingestion is gated at the
  iscp writer) and pass raw f32 bytes (4*dim) through the byte codec.

Verified: f16-direct, f16->int8, and f32 overflow searches all return correct
neighbours end-to-end; cuvs codec unit tests pass; GPU build + gpu-tagged vet
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 7. Coverage for the vecf16 base-column feature:

- CPU planner unit tests (pkg/vectorindex/{ivfpq,cagra}/plugin/plan/plan_test.go):
  vecf16 base accepted; vecf64/vecbf16/vecint8/vecuint8 base rejected;
  vecf16 + QUANTIZATION 'float32' upcast rejected. (The accepted downcast
  path needs a richer compiler context than the stub provides, so it's
  covered by the GPU functional cases below.)
- GPU BVT (test/distributed/gpu_cases/vector/): new vector_{ivfpq,cagra}_f16.sql
  exercise a vecf16 BASE column — direct (native half storage) and
  QUANTIZATION int8/uint8 (native half->int8/uint8) — build + exact-match
  top-1 search (query cast to vecf16(8)); 1..20 tight data keeps int8/uint8
  deterministic. vector_gpu_negative.sql gains the vecbf16-base and
  vecf16->float32-upcast rejection guards (and its .result picks up the
  VECF32->"VECF32 / VECF16" message change from the f16 schema guard).

.result files generated with mo-tester (genrs) against a GPU-enabled MO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… quantizers

The cuVS index was templated on a single storage type T, and the base/query
(quantizer-source) axis was expressed as a duplicated half-source subsystem
plus a uses_half_quantizer_ runtime flag. Make the base type a real type
parameter end-to-end (B = base/query/quantizer-source, Q = storage), mirroring
the Go [B,Q] design, and collapse the two quantizer subsystems into one.

C++ / C ABI (cgo/cuvs):
- gpu_index_base_t<T,...> -> gpu_index_base_t<B, T,...> with one
  scalar_quantizer_t<B>; deleted half_quantizer_, uses_half_quantizer_,
  pending_half_*, train_quantizer_half, add_chunk_quantize_half,
  flush_pending_half_chunks_if_needed. Quantize stays gated on sizeof(T)==1;
  serialization collapses to one quantizer.bin. Added base_type/storage_type
  typedefs.
- gpu_ivf_pq_t<B,Q>, gpu_cagra_t<B,Q>; gpu_brute_force_t<T> -> base<T,T>
  (no quantizer); ivf_flat/kmeans pinned to <float,T>.
- C ABI: new_* gain a quantization_t btype (reusing the existing enum, no
  Base_* enum) before qtype; any_t stores both; one (btype,qtype) dispatch
  helper per index recovers B/Q via base_type/storage_type and throws on
  unwired combos. Half-specific wrappers collapse to gpu_*_add_chunk_quantize
  / gpu_*_quantize_query (B-typed, raw bytes).

Go (pkg/cuvs, pkg/vectorindex, table-fns):
- GpuIvfPq[B,Q] / GpuCagra[B,Q]; constructors pass btype+qtype;
  AddChunkQuantizeHalf->AddChunkQuantize([]B), QuantizeHalf->QuantizeQuery.
- IvfpqBuild[B,Q]/CagraBuild[B,Q] carry the real base (phantom float32 gone);
  the Add/AddFloat/AddQuantizeHalf trio collapses into one AddRow(fa,hf) that
  routes by (B,Q): f32 base -> AddChunkFloat; f16 base -> native AddChunk when
  Q==f16 else AddChunkQuantize. The create table-fns hold one
  ivfpqBuilder/cagraBuilder interface (single 7-combo (base,storage) switch).

Verified: libmo links + 164/164 cgo C++ tests pass (incl. f16->int8 quantize);
MO_CL_CUDA=1 make build; go vet -tags gpu clean; and against live mo —
f16-direct/int8/uint8, f32->f16/int8/uint8 (no regression), and the
half/int8 overflow brute force all return correct top-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CompactSegments held ~3-4 full copies of the live corpus on the Go heap:
ReconstructLiveDocs returned the whole []TokenizedDoc, those were fed into a
single Builder (accumulating them all again in b.docs), then FinishSegments built
every fresh segment at once. At MERGE scale that OOMs.

Make ReconstructLiveDocs an iter.Seq2[TokenizedDoc, error] (range-over-func) that
yields one live doc at a time and frees each doc's postings bucket as it goes.
CompactSegments now streams the iterator into a capacity-bounded builder, sealing +
persisting + freeing each fresh base segment as it fills (capacity floored to
DefaultBuildCapacity), so peak extra memory is one open segment. Old bases + the
tail are deleted first (safe: reconstruction reads the already-loaded in-memory
idx, all in one statement txn; DeleteAllBases is indiscriminate over tag=0, so the
incrementally-written fresh bases must not exist yet).

Note: buckets is still built up front (a doc is only complete once every
term/segment is scanned, and output is pk-sorted) -- a full bound (bm25-style
incremental tail-fold or a spilled reconstruction) remains a later optimization.

Verified live: MERGE folds tail into base correctly (inserts kept, delete dropped,
update applied); 8 live docs at max_index_capacity=3 rebuild into 3 fresh base
segments (nrow 3,3,2); position_free_merge/bugfix/async BVTs and the reconstruct
engine tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@fengttt fengttt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WHY does this PR has to have all those cuvs code? Can we have a CLEAN PR that a human can read? Please break the PR to reasonable, self contained works.

…ad guard (B)

C — TailBuilder held every CDC delete in one in-memory []DeleteRecord until
Finish(), so a long catch-up (which appends all deletes before Finish) could
accumulate them unbounded on the heap. Spill the delete batch into capacity-capped
frames the same way insert segments already seal. Ordering is preserved: Finish
returns ALL delete frames first (lowest chunk_ids) then the insert segments, so a
same-batch UPDATE's insert still supersedes — spilling into several delete frames is
order-equivalent to one (every tombstone still sorts below every insert).
TestTailBuilderDeleteSpill proves multi-frame liveness matches the single-frame case.

B — loading a large index puts O(total-docs) of per-doc metadata (pks/docLen +
NewIndex liveLoc/liveOrd) on the Go heap regardless of LIMIT, with no guard (only the
tail had one). Add checkBaseLoadBudget (base analogue of checkTailLoadBudget), gating
on SUM(nrow)*~256B/doc vs MemoryTotal*0.8 - Go heap — nrow, not filesize, since
posting blocks are mmap. CAST AS SIGNED for a type-safe read. It turns a node-killing
OOM into a clear error. Crucially the guard is called from the QUERY path
(Fulltext2Search.Load), NOT LoadAllBases, so CompactSegments (MERGE) — the remedy the
error suggests — stays exempt and can still load the base to reclaim dead docs; the
message is honest that MERGE only helps when the bloat is deleted rows.

B is a fail-fast guard, not a heap-floor reduction; the real shrink (on-demand doc
metadata) and bounded-DAAT phrase/boolean queries (A) remain scoped follow-ups.

Verified: pkg/fulltext2 unit tests (incl. new delete-spill) pass; live create/query,
async-CDC delete, and MERGE BVTs green; build/vet/gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SearchPhrase materialized every live match into a `matched` map plus a full
`results` slice (O(matches)) before taking top-k, so a low-selectivity phrase that
hits most of the corpus could OOM. Bound it to O(k): a lone phrase has ONE idf²
(idfSquared over its live df) and scoreTerm multiplies the per-doc factor by that
constant, so idf² does not affect the top-k ORDER — rank on the idf²-free partial
score through a new bounded top-k (a k-sized min-heap), count the
(filter-independent) live df alongside (isLive picks exactly one (si,ord) per pk, so
counting live hits already yields the distinct-live df), and scale the k winners by
idf² at the end. Result scores are identical to before; only the memory drops from
O(matches) to O(k).

Boolean (searchBooleanFull's dense O(N) score array + the cross-segment matched map)
still needs the heavier DAAT cursor-merge rewrite — tracked as A part 2. The global
per-doc liveness maps (B deep, on-demand liveness) are the coupled follow-up.

Verified: pkg/fulltext2 engine tests pass; isolated phrase/prefilter/bag-of-words
BVTs (fulltext2 37/37, membership 33/33, position_free 23/23) green; build/vet/gofmt
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cpegeric and others added 10 commits July 20, 2026 21:51
…liveness (B deep)

NewIndex kept a resident liveLoc map[any]docLoc (one entry per live doc, ~50 B each
with the any key + map overhead) for the whole cache lifetime — the dominant O(total
live docs) Go-heap floor a large index pays just to be queryable. But the per-segment
liveOrd bitmap already encodes the same liveness (and the boolean/stream walks already
use it); liveLoc was only still used by isLive (phrase) and ReconstructLiveDocs
(compact).

Route both through liveOrd: isLive becomes an O(1) bitmap index (no pk key, no map
lookup — also drops SearchPhrase's per-hit normalizeKey), and ReconstructLiveDocs
enumerates live (si,ord) via a new forEachLive helper. liveLoc is now LOCAL to
resolve() — it derives liveOrd + global stats, then is discarded. A fully-live
(append-only) index therefore holds ZERO resident liveness heap (all liveOrd[si]==nil);
a delete-heavy one holds the liveOrd bitmap (~1 B/doc) instead of the ~50 B/doc map.

Scope: this removes the dominant RESIDENT liveness floor. The remaining resident
O(docs) heap (pks []any + docLen) needs an mmap docmap (storage-format change, tier 2),
and resolve()'s transient top/liveLoc peak during load is unchanged (covered by
checkBaseLoadBudget).

Verified: pkg/fulltext2 tests pass; live async-CDC (delete/update liveness) 18/18,
MERGE (compact via liveOrd) 25/25, restore/clone 26/26, phrase/boolean/prefilter/
prepare all 100%; build/vet/gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eDocs)

gofmt arrayOrderedCompare in container/types/compare.go, and pre-size the
collectLiveDocs test slice with idx.globalN (prealloc). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p (B deep, tier 2)

After the liveLoc removal, a loaded segment's biggest resident Go-heap cost was
pks []any (~24 B/doc: interface header + boxed value), materialized for every doc at
decodeDocmap and kept for the whole cache lifetime. Replace it with a pkOffsets
[]int32 (4 B/doc — the byte offset of each pk in the docmap bytes) plus pkRaw, a VIEW
into those bytes (mmap-backed for a base segment, GC-alive for a tail). A new
seg.pk(ord) accessor decodes on demand; pkType is validated once at load. So a
loaded segment holds ~6x less per-doc heap for pks, and the pk bytes ride the
reclaimable mmap instead of the Go heap. No storage-format change — offsets are built
by scanning the existing wire format; build-side segments keep pks []any unchanged.

decodeDocmap now sets s.N itself (loaded pks==nil, so the old s.N=len(pks) in
decodeSegment would have zeroed it). All ~15 seg.pks[ord]/len(seg.pks) sites route
through seg.pk(ord)/seg.numDocs() (resolve, phrase, boolean, WAND, stream, membership,
compact). The WHERE-prefilter membership hot path takes a small box on loaded segments
(the docmap pk encoding differs from the docfilter probe encoding, so the value must be
re-encoded); no-prefilter queries are unaffected.

Remaining resident docmap heap: docLen []int32 (4 B/doc) + pkOffsets (4 B/doc);
fully off-heap docLen needs an aligned format change (deferred).

Verified: pkg/fulltext2 tests pass (roundtrip int64/varchar/datetime/narrow-int,
boolean/phrase on loaded segments, reconstruct, frames); live fulltext2 37/37,
membership 33/33, async-CDC 18/18, MERGE 25/25; build/vet/gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	pkg/sql/parsers/dialect/mysql/mysql_sql.go
# Conflicts:
#	pkg/sql/parsers/dialect/mysql/mysql_sql.go
… of 7)

Self-review (fulltext2_only vs cuvs_quantize, OOM focus) surfaced 7 defects; this
fixes 6:

#1 (BLOCKER, index.go) newBoundedTopK did make([]topKItem,0,k) — a pushed LIMIT
(clamped to MaxInt32) sized the phrase top-k to ~50GB and OOM-killed the CN on a
tiny table. Now: no pre-alloc (grow lazily to min(k,#matches)) + cap k to globalN in
SearchPhrase. Verified: MATCH ... LIMIT 500000000 on a 3-row table returns instantly.

#2 (BLOCKER, plugin/plan/tablefunc.go) fulltext2SearchColDefs declared
doc_id=T_int64/score=T_float64 but the executor AppendAny/AppendFixed[float32] — a
direct FROM fulltext2_search(...) wrote 4-byte floats into an 8-byte score column
(garbage ORDER BY) and mistyped non-int pks. Match the executor (T_any / T_float32),
same as the MATCH-path ftIndexColdefs.

#3 (index.go) ReconstructLiveDocs bucketed the WHOLE live corpus before yielding, so
MERGE (guard-exempt) peaked at O(corpus) Go heap — the reclaim op itself OOMs. A live
doc's postings are entirely within its own segment, so reconstruct ONE SEGMENT AT A
TIME: buckets is now bounded to O(one segment) (≤ capacity), freed before the next.

#4 (storage.go) checkTailLoadBudget gated on 1× SUM(LENGTH(data)) but load holds raw
chunks + reassembled frames + decoded segments at once; scale the estimate (×3) so the
guard fails before the real peak OOMs.

#6 (segment.go, termdict.go) loaded-side forEachPosting materialized the whole
vocabulary via prefixTerms("") ([]string); stream the FST via a new forEachTerm — no
O(vocabulary) spike compounding the MERGE peak.

#7 (serialize.go) decodeDocmap stored pk offsets in []int32; a >2GB docmap wrapped the
offset negative → pk(ord) panic. Fail fast at decode instead.

Deferred/decision-logged: #5 (stream.go:143 no-LIMIT non-disjunctive materializes all
matches) is partially mitigated by #1 (phrase pre-alloc now bounded to #matches); the
full fix is DAAT streaming for phrase/boolean (A2), bounded by the result set the user
asked for.

Verified: pkg/fulltext2 tests pass; live MERGE 25/25, fulltext2 37/37, membership
33/33, async 18/18; build/vet/gofmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-segment build memory tracks the number of postings (term occurrences
held in Builder.docs), not the doc count -- a doc can hold one token or
tens of thousands, so the doc-only max_index_capacity seal let a
long-document corpus (e.g. wiki articles ~1k tokens each) blow past any
memory bound (300K docs ~= 300M postings ~= 11GB heap -> OOM at scale).

Add max_postings_capacity: a new user-settable WITH option (default 8M
~= ~512MB build peak) alongside the unchanged max_index_capacity (docs).
A segment seals on whichever cap fires first via ReachedSegmentCap, in
all three streaming build paths -- create TVF, CDC TailBuilder, and
MERGE/REBUILD CompactSegments -- so build memory is bounded regardless of
doc shape. max_index_capacity keeps its doc-count meaning, so the idxcron
tiered-merge fullness path is untouched (the posting cap is purely
additive) and the doc cap still bounds the docmap for many-tiny-docs
indexes.

Plumbing: MAX_POSTINGS_CAPACITY grammar token (0 conflicts) ->
catalog.IndexAlgoParamMaxPostingsCapacity -> schema.go records it ->
compile.go resolves (default 8M) -> TableConfig.PostingCapacity +
fulltext2_compact 6th arg + iscp CDC writer.

Tests: engine unit tests (posting counter, both-dimension seal, streaming
posting-split), parser round-trips (CREATE + ALTER REINDEX), and BVT
cases/fulltext2/fulltext2_posting_capacity. Live-validated on a 40K-60K
synthetic long-doc corpus: CREATE build-delta +9.94GB uncapped -> +0.97GB
capped (10x), REBUILD +0.97GB, MERGE +0.51GB -- all flat as corpus grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working size/XXL Denotes a PR that changes 2000+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants