feat: fulltext2 — a position-free BM25 full-text retrieval index#25904
Draft
cpegeric wants to merge 1065 commits into
Draft
feat: fulltext2 — a position-free BM25 full-text retrieval index#25904cpegeric wants to merge 1065 commits into
fulltext2 — a position-free BM25 full-text retrieval index#25904cpegeric wants to merge 1065 commits into
Conversation
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
requested changes
Jul 20, 2026
fengttt
left a comment
Contributor
There was a problem hiding this comment.
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What type of PR is this?
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 standalonebm25index (its ranked-retrieval capability is now subsumed by fulltext2'sPOSITION_FREEmode). 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_indexsession 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
bm25index type, we make it its own index type with a clean contract, and fold the oldbm25engine into it.SQL surface
DDL — a distinct index type via the
USING/index-type seam, with a configurable tokenizer and a position-free option:Full CREATE option grammar —
CREATE FULLTEXT2 INDEX name ON t(cols) <options>, where<options>may combine, in any order:WITH PARSERngram(default) ·gojieba·json·json_valuePOSITION_FREETRUE·FALSE(default)gojieba)AUTO_UPDATETRUE·FALSE(default)DAY/HOUR/SECONDDAY=1= compact daily)MAX_INDEX_CAPACITYASYNC/FORCE_SYNCAUTO_UPDATE=TRUE+DAY/HOUR/SECONDare stored in the index's algo-params and read by the idxcron executor, which schedules a periodic MERGE/REBUILD (see Maintenance below).POSITION_FREE=TRUEis only valid withWITH PARSER gojieba; a barePOSITION_FREE=TRUE(defaults to ngram) andWITH PARSER ngram POSITION_FREE=TRUEare 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:
Maintenance —
ALTER … REINDEX(MERGE / REBUILD / retune)fulltext2 maintains a tiered index: a
tag=0base (built from source) plus atag=1CDC tail of incremental inserts/deletes.ALTER TABLE … ALTER REINDEX <name> FULLTEXT2 <option>drives compaction and retuning:Scheduled (idxcron) maintenance. When the index was created
AUTO_UPDATE=TRUEwith aDAY/HOUR/SECONDcadence, 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 manualALTER … REINDEXis needed in that mode.Architecture
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.fulltext2AlgoPlugin (pkg/fulltext2/plugin/{plan,compile,runtime,idxcron,iscp}): five hook sets, all withvar _ Hookscompile-time assertions; routed through theindexplugin.Get(algo)registry (no new per-algo switch in plan/compile). Blank-imported inindexplugin/allandindexplugin/iscp.apply_indices_fulltext2.go/apply_indices_match.gorouteMATCH … AGAINSTto thefulltext2_searchTVF with ORDER BY score DESC + LIMIT pushdown;fulltext2_compact.godrives compaction.fulltext2_{create,search,compact}.Tokenizers
ngram(default),gojieba(CJK word segmentation),json(JSON values flattened to text, then tokenized as ngram), andjson_value(each leaf value indexed as one whole atomic token — classic-fulltextjson_valueparity), with tokenization parity between build and query.POSITION_FREEis supported only withgojieba— ngram and json produce overlapping/redundant tokens that can't be reconstructed without positions, so they always build a positional index (and are rejected ifPOSITION_FREE=TRUEis 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
curDocwith inline in-block skip.Safety / guards
FULLTEXT+FULLTEXT2index on the same column set at DDL.POSITION_FREEindex with a clear message.LIMITno longer OOMs), fail-fast memory guard on CDC tail load, off-heap posting decode, corrupt-frame / uvarint-overflow guards on read.types.EncodeValuepanics).ALTER ADD FULLTEXTno longer locks its not-yet-created hidden table.Testing
New BVT suites under
test/distributed/cases/fulltext2/andpessimistic_transaction/fulltext2/: functional sync,POSITION_FREE+IN BM25 MODE, parser coverage (ngram/gojieba/json_value), membership prefilter, ORDER-BY/LIMIT pushdown,FULLTEXT+FULLTEXT2conflict rejection, async CDC convergence, MERGE/REBUILD, restore/clone, and bugfix regressions. Extensive engine unit tests inpkg/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
FULLTEXTininformation_schema/SHOW INDEX(correct MySQL taxonomy).