All notable changes to turbovec are recorded here. The format is based on Keep a Changelog and the project follows Semantic Versioning.
The Rust crate (turbovec on crates.io) and the Python distribution
(turbovec on PyPI) version independently. Each release section below
is split by surface — a single feature can affect both, and its bullet
appears under each surface it touches.
Security-audit release. Two adversarial audit passes over the core crate,
the Python binding, and the framework integrations, hardening the
untrusted-file load path and the Python API surface and fixing several
data-integrity bugs in the integration wrappers. Resolves #104, #105, and
#106. No on-disk format change (still .tv / .tvim v3).
A few fixes change observable behavior — see Changed under each surface. They turn previously-undefined or silently-wrong situations into clean, typed errors, so the bump is minor rather than patch.
- Untrusted index files are validated before allocation on load. A
crafted
.tv/.tvimcould previously trigger an integer-overflow in the packed-size computation, drive a multi-gigabyte allocation from a tiny file, divide-by-zero in the repack step, or load a structurally invalid index that returned silently-wrong scores (abit_widthof 5–8 passed the old length check). The loader now range-checksbit_widthanddim, computes every size with checked arithmetic, and reads each payload through a length-capped reader. (#105) - x86 scalar fallback returned wrong results. On pre-AVX2 x86 (or VMs
without AVX2),
score_query_into_heapread the perm0-interleaved code layout as if sequential, producing an incorrect top-k. It now de-interleaves correctly; verified end-to-end against the SIMD kernels on AVX-512 hardware. (#106)
AddErrorandConstructErrorare now#[non_exhaustive]. Downstreammatchon these enums must carry a wildcard arm; in exchange, future error variants are no longer breaking changes. (The newDimTooLargevariant is why this release is the moment to make the switch.)dimis capped atMAX_DIM(65536) at construction, first add, and load.searchlazily builds adim×dimrotation matrix whose size is not bounded by any file, so an unboundeddimwas a load-time resource-exhaustion vector. Larger dims now return a typed error.- A zero-
dimlazy add is rejected withAddErrorinstead of panicking with a divide-by-zero and wedging the index.
- Dead, untested
pack::repack_3bit(no callers; 3-bit goes throughrepack).
- The crate now fails to compile on non-64-bit targets (a
compile_error!gated ontarget_pointer_width). The size/offset arithmetic inencode/pack/searchassumes 64-bitusize; refusing to build on 32-bit/wasm avoids shipping a silently-unsafe (potential out-of-bounds) build there.
search()no longer panics on NaN / Inf / oversized query coordinates. These previously raised an uncatchablePanicException(aBaseException); they now raiseValueError, matchingadd. (#105)- Loading a malformed
.tv/.tvimraises a clean error instead of panicking or driving a huge allocation (the Rust load-path hardening above, surfaced through the binding). - agno: duplicate derived
doc_idno longer orphans vectors. Two documents that derive the same id (a repeateddoc.id, or identical content) are both kept and both deletable, matching agno's reference store (LanceDb appends). Previously the earlier vector was counted and searchable but unreachable by id, and leaked on every upsert. (#104) - agno:
delete_by_name/delete_by_content_id/delete_by_metadatano longer over-delete. When distinct documents collided on a content-deriveddoc_id, deleting by one attribute also deleted the id-twin; deletion now targets only the documents matching the predicate. - LangChain / Haystack / LlamaIndex: a persisted JSON side-car that is out
of sync with its
.tvimindex now raises aValueErrorat load instead of an opaqueKeyErrordeep inside a later query. - Internal binding result-shape errors map to
RuntimeErrorrather than an uncatchable panic.
search()and the index constructors now raiseValueErrorfor non-finite query values and fordimoutside the supported range, where some of these inputs previously panicked or were silently accepted.
- Corrected stale benchmark figures in the README (recall deltas, ARM/x86
speed ranges) to match the current
benchmarks/results/; several had drifted from before the TQ+ calibration step landed.
Bug-fix release. Two data-safety fixes in the Python integration wrappers'
add/upsert paths, plus a source-build fix for the Python extension on macOS.
The Rust crate is functionally unchanged — only non-behavioral cleanups —
but is re-released to keep crates.io in sync with the source tree. No
on-disk format change (still .tv / .tvim v3).
- Internal cleanup only, no behavior change — the published crate
behaves identically to 0.8.0. Cleared three build warnings (two unused
bindings in the NEON scoring kernels; the scalar
score_query_into_heapfallback is nowcfg-gated out ofaarch64builds, where the NEON kernel is always used and it was dead code) and corrected stale SIMD module/kernel doc comments.
- Intra-batch duplicate ids no longer orphan vectors in the LangChain
and Haystack integrations. A repeated id within a single
add_texts/add_documents/write_documentscall previously added one vector per row while the id→handle map kept only the last, leaving the earlier vectors live in search but mapped to the wrong document and unreachable for delete. Both now resolve duplicates the way their reference stores do — LangChain (InMemoryVectorStore) keeps the last occurrence; Haystack (InMemoryDocumentStore) applies theDuplicatePolicyagainst the batch-so-far. Fixes #90. - Upsert no longer destroys existing data when the new batch fails
validation, across all four integrations (LangChain, LlamaIndex,
Haystack, Agno). The old vectors for matching ids were deleted before
the incoming batch was validated/encoded, so a dimension change or a
non-finite embedding left the store with the originals already gone. The
delete is now deferred until after the add succeeds (Agno captures the
previous generation's handles and removes them after
insert). Fixes #89. - Plain
cargo buildof the extension now links on macOS. Buildingturbovec-pythonfrom source failed with "symbol(s) not found for architecture arm64" because nothing emitted the Python extension-module linker args (maturin injects them; a barecargo builddid not). Added abuild.rscallingpyo3_build_config::add_extension_module_link_args(). Prebuilt wheels were unaffected. Fixes #92.
Audit-driven correctness pass on every layer (Rust core, Python bindings,
four integration wrappers). Headline: 14 active bugs found and fixed,
hundreds of regression tests added, doc drift cleaned up across the
public API. No on-disk format change (still .tv / .tvim v3).
AddError::InvalidInputValue { vector_index, coord_index, value }— new error variant returned byTurboQuantIndex::add_2dandIdMapIndex::add_with_ids_2dwhen an input coordinate is non-finite (NaN, +Inf, -Inf) or has magnitude>= 1e16. Without this validation the encode pipeline silently corrupted the index: NaN/Inf propagated throughsimd_normand poisonedvec_scales[slot] = NaN, making the slot exist inlen()but unreachable throughsearch; huge magnitudes overflowed the f32 norm to+Inf, making the slot win every query.- Scalar fallback in the x86_64 search dispatch. Previously,
searchon an x86_64 CPU without AVX-512 BW or AVX2 silently returned empty top-k results for every query (the SIMDunsafe { if/else if }block had noelse). Rare in practice on modern hardware but the failure mode was the worst kind.
- Breaking:
AddErrorno longer derivesEq(the newInvalidInputValuevariant carries anf32, which isn'tEqbecauseNaN != NaN).PartialEqis still derived. Downstream code that pattern-matchesAddErrorexhaustively will need to add the new variant. TurboQuantIndex::add/add_2d/search/search_with_masknow reject non-finite / huge-magnitude inputs at entry.addandsearchpanic with a clear message (matching their existing precondition- panic style);add_2dandadd_with_ids_2dreturnErr(InvalidInputValue)for callers handling untrusted input.TurboQuantIndex::from_partsasserts structural invariants (packed_codes / scales / TQ+ length relationships) at entry, catching any future caller that bypasses the read-layer validation.- Rustdoc on
add,add_2d,search,search_with_mask, andIdMapIndex::add_with_idsnow documents every panic condition introduced by the input validation.
IdMapIndex::add_with_ids_2dpartial-mutation on inner failure. ID tables (id_to_slot/slot_to_id) were mutated before delegating to the inneradd_2d. If the inner call returnedErr(e.g.DimMismatchon a committed-dim index), the ID tables retainednghost entries pointing at slots that didn't exist in the inner index — corrupting latersearch_with_allowlist/remove. Fixed by capturingbase_slotbefore, running inner add first, mutating ID tables only on success.- v2-loaded index +
addsilently mis-encoded new vectors. Loading a pre-TQ+ (v2) file lefttqplus_shiftempty; the nextaddsawexisting = None, fit fresh calibration, encoded the new batch with that calibration — but then silently dropped the fitted shift/scale because then_vectors != 0else branch only extendedpacked_codes/scales. The new vectors then got searched against identity calibration, producing silently wrong scores. Fixed by populating explicit identity TQ+ infrom_partswhen loading a v2-shaped state. - Empty first add froze identity calibration forever.
add(&[])hit then < TQPLUS_MIN_SAMPLESbranch inencode, returned identity, and then_vectors == 0branch wrote it toself.tqplus_shift. Every subsequent add — even a million-vector batch with rich distribution — then sawexisting = Some(identity)and silently skipped fresh fitting. Fixed by short-circuitingaddto a true no-op whenn == 0.
- Breaking (typed-exception hygiene):
TurboQuantIndex.add/searchandIdMapIndex.add_with_ids/searchnow raiseValueErrorfor non-finite or huge-magnitude coordinates, non- contiguous numpy arrays, and wrong-dim queries. Previously these surfaced as Rust panics →PanicExceptionin Python. - Breaking:
TurboQuantIndex.swap_removenow raisesIndexErrorfor out-of-range indices (was a Rust panic). IdMapIndex.searchandTurboQuantIndex.searchnow return consistent shapes for empty queries —(0, min(k, n_vectors, n_allowed))on both. PreviouslyIdMapIndexreturned(0, k)(rawk), diverging fromTurboQuantIndex's(0, min(k, n)). ForIdMapIndex, theeffective_kcomputation also now dedups the allowlist for thenq == 0path, matching the kernel's mask-based dedup fornq > 0.
turbovec.langchain.TurboQuantVectorStore:similarity_search,similarity_search_with_score, andsimilarity_search_by_vectornow populateDocument.idon returned hits (was silentlyNone). TheDocumentpassed to user-supplied filter callables also carries.idso predicates can filter on it. Fixes #81.turbovec.haystack.TurboQuantDocumentStore:Document.blobandDocument.sparse_embeddingnow survive write → retrieval round-trip (were silently dropped). Docstore schema bumpsv1 → v2with backward-compat load. Filter shape validation tightened to matchInMemoryDocumentStore(bare{"field": "x"}shapes are rejected). Docstring scoped back from "matches the public surface ofInMemoryDocumentStore" sincebm25_retrievalis not implemented.turbovec.llama_index.TurboQuantVectorStore: fullBaseNodefidelity throughquery/get_nodes/ persist+load. PREVIOUS / NEXT / PARENT / CHILD relationships,excluded_embed_metadata_keys/excluded_llm_metadata_keys, template fields (text_template,metadata_template,metadata_separator),start_char_idx/end_char_idx, andmimetypewere silently dropped — now preserved vianode_to_metadata_dict/metadata_dict_to_node. Nodes schema bumpsv1 → v2with backward-compat load. Plus:FilterCondition.NOTnow supported (wasNotImplementedError).FilterOperator.TEXT_MATCHis now case-sensitive (matches the reference; previously silently lowercased both sides).FilterOperator.TEXT_MATCH_INSENSITIVE,ALL,ANYadded.query.mode != VectorStoreQueryMode.DEFAULTraisesNotImplementedErrorinstead of silently behaving as DEFAULT.add()rejects intra-batch duplicatenode_ids with a clearValueError(previously, the index ended up with both vectors but only the last node_id mapped back to one, orphaning the first handle and silently returning the second node's payload attached to the first node's vector).
turbovec.agno.TurboQuantVectorDb:embedderis now threaded through returnedDocumentobjects sodoc.embed()/doc.async_embed()work on retrieved hits (previously raised "No embedder provided"). Empty query strings short-circuit to[](matching LanceDb).
-
TQ+ per-coordinate calibration. Before the data-oblivious rotation, every coordinate is shifted by its empirical 5th percentile and scaled so that the 5–95% range maps to
[0, 1]. The shift/scale pair is fit incrementally from the cold-pathadddata, so the index stays online — no separate train pass, no rebuilds as the corpus grows. At search time, the same affine is applied to the query before the rotation. Recall@1 lifts across published cells:- GloVe-200 4-bit: 0.8440 → 0.8498 (+0.6pp)
- OpenAI-1536 2-bit: 0.876 → 0.891 (+1.5pp)
- OpenAI-1536 4-bit: 0.966 → 0.974 (+0.8pp)
- OpenAI-3072 2-bit: 0.911 → 0.929 (+1.8pp)
- OpenAI-3072 4-bit: 0.971 → 0.974 (+0.3pp)
No public API change — TQ+ is always-on. The cost is one extra pass per
addbatch to update the running quantile estimates, paid once on the cold path; search latency is essentially unchanged. -
Cross-arch top-K parity. The AVX2 and AVX-512 BW kernels now produce byte-identical top-K result sets to the NEON kernel for any deterministic input. Per-vector f32 scores still differ by ~1e-5 relative across arches (different SIMD reduction orders), but those rank swaps are confined to within-tie vectors and never change set membership. Verified via the new
examples/kernel_xtest.rssmoke test (sha256 of sorted-per-query top-K indices matches across all three SIMD paths).
-
On-disk format version bumped to 3 for both
.tvand.tvim. v3 appends a TQ+ trailer (per-coord shift + scale arrays) after the existing scales section. The v3 reader is backward-compatible: v2 files load with empty TQ+ vectors (identity calibration). Files written by 0.7.x cannot be loaded by 0.6.x or older; there's no forward-compat shim. Reindexing from source vectors picks up the TQ+ recall lift; loading an old v2 file gives you the pre-TQ+ numbers. -
x86 LUT-build is no longer data-dependent. The AVX2 and AVX-512 BW kernels previously capped
max_lutatmin(127, 65535 / n_byte_groups)to keep their no-flush u8→i16 accumulators in range — which at d=1536/4-bit clamped to 42, and at d=3072/4-bit to 21, opening a visible recall gap vs ARM (−1.6pp and −5.5pp respectively). Both kernels now batch the inner loop byFLUSH_EVERY=256byte-groups and run a mini-epilogue (SUB-trick + i16→f32 + fmadd into per-query f32 accumulators) at the end of each batch — the same structure NEON has used since 0.5.x.max_lutis now unconditionally 127 on every arch. x86 speed is essentially flat vs the previous release (the per-batch flush eliminates the same work from the single final epilogue).
- TQ+ per-coordinate calibration. Same kernel-level change as the
Rust crate; Python users see no API change.
TurboQuantIndex.add()carries a small extra pass per batch to update the running quantile estimates (one-shot cold-path cost; search latency unchanged), and.search()returns higher recall on the cells listed above. The README's "How it works" section documents the calibration step.
- On-disk format version bumped to 3 for both
.tvand.tvim. Same forward-compat policy as the Rust crate: old v2 files load fine into 0.6.0+ (with identity calibration), but indexes written by 0.6.0+ cannot be loaded by ≤ 0.5.3. Reindex from source vectors to pick up the recall lift.
- x86/ARM recall parity at d=1536 and d=3072, 4-bit. Previous releases silently produced lower recall on x86 than ARM at high dim — most visibly at d=3072/4-bit where x86 measured 0.919 @1 vs ARM's 0.974 (−5.5pp). Same fix as the Rust crate (porting the ARM-style periodic accumulator flush to AVX2 and AVX-512 BW). x86 search latency is essentially unchanged.
-
BREAKING:
TurboQuantIndex::new,TurboQuantIndex::new_lazy,IdMapIndex::new, andIdMapIndex::new_lazynow returnResult<Self, ConstructError>instead of panicking on invalid input. The newturbovec::ConstructErrorenum coversbit_widthout of{2, 3, 4}anddimnot a positive multiple of 8 (which also closes a latent hole wheredim = 0was silently accepted — the previousdim % 8 == 0assertion vacuously passed for zero, then divided-by-zero on the firstadd).Migration: append
?(or.unwrap()in tests/binaries) to existing constructor calls. Mirrors theAddErrorpattern from the previous release. -
Encode is 2–3× faster on aarch64. SIMD-ifies the quantize + scale + bit-pack inner loop via NEON (compare against boundaries in 8 lanes at a time, weighted horizontal-add for the bit-pack) and fuses the three passes so there's no intermediate
codes: Vec<u8>allocation. Rayon parallelises across rows on both aarch64 and x86_64; x86_64 keeps the existing scalar inner loop. Recall is bit-identical to the previous release at every published cell (verified againstbenchmarks/suite/recall_*.pyon M3 Max). Measured throughput on M2 Pro, single-threaded:- d=768, 4-bit: 22.5K → 66.3K vec/sec (2.9×)
- d=1536, 4-bit: 9.5K → 21.9K vec/sec (2.3×)
- d=1536, 2-bit: 16.6K → 25.7K vec/sec (1.5×)
-
Codebook is now cached across incremental
addcalls. The Lloyd-Max boundaries and centroids are a deterministic function of(bit_width, dim), so recomputing them on everyaddwas wasted work. They're now stored inOnceLockcells (the same pattern already used for the rotation matrix) and reused across calls. No behaviour change; faster incremental indexing.
- Linux wheels now actually import. Every Linux wheel since
Linux build support was added had a missing
DT_NEEDEDentry forlibopenblas, soimport turbovecfailed at the dynamic linker step withundefined symbol: cblas_sgemm— even on systems that had OpenBLAS installed. The wheel now declares the dependency explicitly, andauditwheelbundles a self-contained copy oflibopenblas(plus itslibgfortran/libquadmathruntime deps) intoturbovec.libs/. Linux wheel size grows from ~1.8 MB to ~11 MB (aarch64) / ~42 MB (x86_64) as a consequence — the bundled OpenBLAS contains kernel variants for many micro-archs and dispatches at runtime. The Linux release CI now also runspytestagainst the freshly-built wheel on native runners so this class of bug can't ship silently again.
-
TurboQuantIndexandIdMapIndexconstructors raiseValueErroron bad input (bit_widthoutside{2, 3, 4},dimnot a positive multiple of 8, including the previously silently-accepteddim = 0case). Previously these surfaced aspyo3_runtime.PanicException, which subclassesBaseExceptionand so wasn't caught byexcept Exception:— user code can now recover from a configuration error as a normal usage error. -
Encode (build-time, not query-time) is faster on aarch64. Same kernel-level change as the Rust crate; Python users see no API change and bit-identical recall at every published cell. Building an index with
TurboQuantIndex.add()is ~2–3× faster on M-series macOS and Linux aarch64. x86_64 sees the Rayon parallelism but not the SIMD kernel.
-
BREAKING:
TurboQuantIndex::add_2d,IdMapIndex::add_with_ids_2d, andIdMapIndex::add_with_idsnow returnResult<(), AddError>instead of panicking on invalid input. The newturbovec::AddErrorenum covers dim mismatch,dim % 8 != 0on lazy-commit, vector buffer length not a multiple ofdim, ids/vectors count mismatch, and duplicate ids. The low-levelTurboQuantIndex::add(&[f32])and constructor asserts are unchanged — they still panic, since those signal contract violations rather than user-input errors.Migration: append
?(or.unwrap()in tests/binaries) to existing calls. Match onAddErrorif you need to recover from specific failure modes.
- Dim mismatch on
add/add_with_idsnow raisesValueErrorinstead of surfacing apyo3_runtime.PanicExceptionwith a Rust backtrace. The previousPanicExceptionsubclassedBaseExceptionand so was not caught byexcept Exception:— user code can now recover from a wrong-shape batch as a normal usage error. The same applies to duplicate ids and length mismatches onIdMapIndex.add_with_ids.
-
Block-level early exit for selective mask searches (closes #30). When a search is issued with
Some(mask)the SIMD kernels now check whether each 32-vector block contains any allowed slots before doing the LUT lookup + popcount + score-decode work for that block. If not, the entire block is short-circuited at one integer-load + branch per block. The AVX-512BW path additionally short-circuits 64-vector pairs at once where possible.Measured speedup at 1% selectivity, 100K vectors, d=1536 (mask allowing the last 1K slots): 6.4× on ARM (M3 Max), 12.7× on x86 (Sapphire Rapids c3-standard-8). Unmasked search latency is unchanged (the guard only fires when a mask is passed).
Public API: no change to existing surfaces.
-
turbovec::search::BLOCKS_SKIPPED_BY_MASK— atomic counter incremented each time a block is short-circuited. Accessorsblocks_skipped_by_mask()andreset_blocks_skipped_by_mask()are exposed for hybrid-retrieval telemetry. AVX-512BW pair-level skips count as 2.
- Block-level early exit for selective
search_with_maskcalls. Same kernel-level change as the Rust crate; Python users see identical API and unchanged unmasked latency. Selective masks now run substantially faster (≈6–13× at 1% selectivity, scaling with index size — larger indices amortize fixed per-query cost more and see larger speedups). Closes #30.
BREAKING — on-disk file format version bumped from 1 to 2. Existing
.tvand.tvimfiles written by turbovec ≤ 0.4.3 cannot be loaded by 0.5.0+. Reindex from source vectors to migrate; no in-place migration is provided.
If you have indexes built with 0.4.3 or earlier, re-encode them:
import numpy as np
from turbovec import TurboQuantIndex
# Source vectors (the f32 inputs your old index was built from).
vectors = np.load("my_vectors.npy") # shape (n, dim)
# Build a fresh 0.5.0 index. Same API, same recall guarantees, but with
# the new length-renormalization correction applied.
index = TurboQuantIndex(dim=vectors.shape[1], bit_width=4)
index.add(vectors)
index.write("my_index_v2.tv")If you load an old file under 0.5.0+, you will see:
this .tv file was written by turbovec ≤ 0.4.3 (format version 1).
It is incompatible with turbovec 0.4.4+ because the per-vector scalar's
meaning changed. Rebuild this index from the source vectors using
turbovec 0.4.4 or later.
- Length-renormalized scoring. The per-vector scalar stored in
TurboQuantIndexis now||v|| / <u_rot, x̂>instead of||v||, giving an unbiased estimator of the inner product. The SIMD kernel multiplies by this value at the same site it previously used the norm — no change to kernel speed, storage layout, or public API.
- On-disk format version bumped to 2 for both
.tvand.tvim..tvnow starts with a 4-byte magic"TVPI"+ 1-byte version prefix;.tvimkeeps its existing magic with version bumped from 1 to 2. Loading a v1 file returnsio::Errorof kindInvalidDatawith an upgrade-hint message; no in-place migration is provided. TurboQuantIndex::normsfield renamed toscales. Internal rename to match the value's new meaning. The SIMD kernel parameter isvec_scales(to disambiguate from the per-query LUT calibrationscalesparameter inside the same functions).
-
Length-renormalized scoring. Replaces the per-vector
||v||scalar with a RaBitQ-style correction||v|| / <u_rot, x̂>that removes the systematic bias of the inner-product estimator. The SIMD kernel is byte-for-byte unchanged — it multiplies by the new scalar at the same site it previously used the norm. Recall@1 gains across published benchmarks:- GloVe-200 2-bit: 0.5053 → 0.5524 (+4.7pp)
- GloVe-200 4-bit: 0.8115 → 0.8440 (+3.3pp)
- OpenAI-1536 2-bit: 0.8700 → 0.9060 (+3.6pp)
- OpenAI-1536 4-bit: 0.9550 → 0.9700 (+1.5pp)
- OpenAI-3072 2-bit: 0.9120 → 0.9240 (+1.2pp)
- OpenAI-3072 4-bit: 0.9670 → 0.9800 (+1.3pp)
Same-session ARM and x86 speed benchmarks confirm no measurable search-latency change (deltas within FAISS noise floor on every cell). The correction adds one extra dot product per vector at encode time — a one-shot cost on the cold path, not visible to search.
- On-disk format version bumped to 2 for both
.tvand.tvim..tvfiles now start with a 4-byte magic"TVPI"+ 1-byte version..tvimfiles use the existing magic with version byte bumped from 1 to 2. - Loading a turbovec ≤ 0.4.3 index raises with a clear error.
The per-vector scalar's meaning changed (
||v||→||v|| / <u_rot, x̂>), so silently re-interpreting v1 files would produce wrong scores. The new loader detects v1 files by their format signature and raisesOSErrorpointing the caller at rebuilding from source vectors.
turbovec.haystack.TurboQuantDocumentStoreclamps cosine scores to[-1, 1]beforescale_scorerescaling. Cauchy–Schwarz bounds the true cosine in that range, but the LUT scoring kernel's float-precision noise can produce values slightly outside it — most visibly on a self-query, which is algebraically 1.0 but the kernel produces ~1.00016 after its per-sub-table calibration. Without the clamp, downstream consumers ofscale_score=Truesaw scores> 1.0and the[0, 1]contract was violated. Dot-product path uses a sigmoid that is already bounded; no clamp needed there.
-
Windows x64 wheel (closes #31). Prior releases shipped only Linux x86_64/aarch64, macOS aarch64, and an sdist — Windows users running
pip install turbovecfell through to the sdist and hit alink.exebuild failure unless they had Rust + MSVC installed locally. The release workflow now also builds acp39-abi3-win_amd64wheel and validates it by installing and running the core pytest suite (test_index.py,test_id_map.py,test_filtering.py) on the build runner before upload. Implementation in #33.Intel Mac (macOS x86_64) was considered alongside Windows but blocked by GitHub's December 2025 deprecation of free-tier
macos-13runners; tracked separately in #34.No library changes in this release — same Python API, same on-disk format, same recall and throughput as 0.4.2. Pure platform-coverage patch.
numpyis now a declared runtime dependency. The Python package and every integration module importsnumpyunconditionally, and the Rust extension's Python surface expects NumPy arrays as input. Prior releases relied onnumpybeing pulled in transitively via the framework extras (langchain-core,llama-index-core,haystack-ai). This brokepip install turbovec[agno]in clean environments becauseagnodoesn't depend onnumpy.numpy>=1.20is now declared in[project].dependencies, so it's installed regardless of which extra (or none) is selected.
- Agno integration (
turbovec.agno). NewTurboQuantVectorDbclass implementing Agno'sVectorDbinterface, structurally aligned withagno.vectordb.lancedb.LanceDb(the closest in-tree single-machine backend). Drop-in for callers that useLanceDbas their Agno knowledge backend.- Dim is sourced from
embedder.dimensions(matchesLanceDb); no baked-in default. - Filtered search uses the kernel-level
allowlist=path: filters resolve to a handle allowlist before scoring, so selective filters return up tolimitresults from the filtered set instead of fewer-than-limitfrom a post-filter. - JSON side-car persistence (no pickle, no
allow_dangerous_deserializationflag). - Constructor restricts
search_type=vectoranddistance=cosine— turbovec doesn't ship a BM25/lexical index and stores unit-normalized vectors only. Non-vector / non-cosine constructions raiseValueErrorrather than silently misbehaving. - Honours
similarity_threshold(cosine → relevance clamped to[0, 1]via(s + 1) / 2),reranker(optional rerank pass after vector retrieval),content_id/content_hashpayload fields. - Full async surface:
async_*variants for create/insert/upsert/ search/drop/exists/name_exists, using the embedder's async batch paths when available. - Install:
pip install turbovec[agno].
- Dim is sourced from
-
Search-time filtering. New methods restrict the returned top-k to a caller-supplied subset of vectors. The kernel applies the filter at the heap-update site rather than via post-filtering, so selective filters return up to
kresults from the allowed set instead of fewer-than-kfrom an over-fetch pass. Output shape shrinks tomin(k, n_allowed)— consistent with the existingk > len(idx)contract; no sentinel padding. (#21)TurboQuantIndex::search_with_mask(queries, k, mask: Option<&[bool]>)— slot bitmask, length equal tolen(idx).IdMapIndex::search_with_allowlist(queries, k, allowlist: Option<&[u64]>)— external-id allowlist; translated to a slot bitmask internally via the existingid_to_slotmap. Panics on empty allowlist or unknown ids.- Threaded through every scoring path: NEON (aarch64), AVX2 (x86_64), AVX-512BW (x86_64), and the scalar fallback.
-
Lazy index construction. The dim can now be deferred and inferred from the first batch of vectors, rather than committed at construction time. This is the same ergonomic improvement integration users were already getting through the framework wrappers, pulled down into the core so direct Rust users and any future integration get it for free.
TurboQuantIndex::new_lazy(bit_width)andIdMapIndex::new_lazy(bit_width)— construct an empty index with no committed dim.TurboQuantIndex::add_2d(vectors, dim)andIdMapIndex::add_with_ids_2d(vectors, dim, ids)— add a flat vector batch with an explicit dim; locks the index dim on the first call, validates on subsequent ones. Existingadd(&[f32])/add_with_ids(&[f32], &[u64])still work on a dim-known index and panic with a clear message on a lazy uncommitted one.TurboQuantIndex::dim_opt()/IdMapIndex::dim_opt()returnOption<usize>—Nonefor the lazy uncommitted state. The existingdim() -> usizegetters keep returningusize, with0as a non-breaking sentinel for the lazy state (the eager constructor assertsdim >= 8, so0doesn't collide).- File format:
.tvand.tvimheaders encode the lazy state via adim = 0sentinel. Files written before this change always havedim >= 8and load cleanly into the eager state.
search,search_with_mask, andprepareonTurboQuantIndexreturn empty results / are no-ops when called on a lazy uncommitted index, rather than panicking.
-
Search-time filtering. Same feature surfaced as keyword-only arguments on
search:TurboQuantIndex.search(queries, k, *, mask=None)—maskis a NumPyboolarray of shape(len(idx),).IdMapIndex.search(queries, k, *, allowlist=None)—allowlistis a NumPyuint64array of external ids.- Pre-validates shape, dtype, emptiness and unknown ids and raises
ValueError/KeyErrorrather than letting the Rust panic surface aspyo3.PanicException. (#21)
-
Lazy construction.
TurboQuantIndex(dim=None, bit_width=4)andIdMapIndex(dim=None, bit_width=4)now accept an optionaldim. When omitted, the dim is inferred from the first.add(...)/.add_with_ids(...)call using the input array's shape. The framework integrations all rely on this internally now. -
.dimproperty on both index types now returnsint | None(wasint);Nonemeans the index hasn't seen its first add yet.
-
Haystack integration (
turbovec.haystack):TurboQuantDocumentStoreis now a structural drop-in forhaystack.document_stores.in_memory.InMemoryDocumentStore. Audited againsthaystack-ai 2.28.0and brought up to parity. In addition to the earlier filter-resolution fix:dimis now optional in the constructor; the index is built lazily on the firstwrite_documents.- Constructor accepts
embedding_similarity_function("cosine"default, since turbovec stores unit-normalized vectors),async_executor, andreturn_embeddingfor parity with the reference.scale_score=Truenow uses the right per-similarity-function formula ((s + 1) / 2for cosine,expit(s / 100)for dot product), fixing a pre-existing bug. - 12
*_asyncvariants added (count_documents_async,filter_documents_async,write_documents_async,delete_documents_async,delete_all_documents_async,update_by_filter_async,count_documents_by_filter_async,count_unique_metadata_by_filter_async,get_metadata_fields_info_async,get_metadata_field_min_max_async,get_metadata_field_unique_values_async,embedding_retrieval_async). - 8 utility methods added (
delete_all_documents,delete_by_filter,update_by_filter,count_documents_by_filter,count_unique_metadata_by_filter,get_metadata_fields_info,get_metadata_field_min_max,get_metadata_field_unique_values), plus astorageproperty andshutdown(). write_documentsnow validates its input and raisesValueError("Please provide a list of Documents.")on bad input instead of an opaqueAttributeError.- Persistence methods renamed to match the reference:
save → save_to_disk,load → load_from_disk. (No deprecation shims — pre-this-change persisted stores load fine, but the method names change.)
-
LangChain integration (
turbovec.langchain):TurboQuantVectorStoreis now a structural drop-in forlangchain_core.vectorstores.in_memory.InMemoryVectorStore. Audited againstlangchain_core 0.3.63. In addition to the earlier filter fixes:__init__no longer requires a pre-builtIdMapIndex. Lazy construction letsTurboQuantVectorStore(embedding)work directly — same no-arg ergonomics as the reference._select_relevance_score_fnoverride added — maps the raw cosine similarity into[0, 1]sosimilarity_search_with_relevance_scoresandas_retriever(search_type="similarity_score_threshold")work. Result is clamped to[0, 1]to absorb the small overshoot caused by quantization noise.get_by_ids/aget_by_idsimplemented from the side-car docstore.add_documentsoverrides the base-class default so partialDocument.idis honoured per-document (some ids explicit, others UUID-generated) instead of being dropped wholesale.- True async overrides:
aadd_documents,aadd_textsandasimilarity_search_with_scoreuseaembed_documents/aembed_queryfor genuine async embedding generation;asimilarity_search,asimilarity_search_by_vector,amax_marginal_relevance_search,afrom_texts,adeleteare explicit overrides too. deletenow returnsNone(wasbool) and is a no-op when called withids=None— matches the reference's contract.max_marginal_relevance_search/_by_vector/amax_marginal_relevance_searchraiseNotImplementedErrorwith a clear message rather than the base class's bareNotImplementedError. MMR isn't faithfully implementable on a quantized index because the algorithm requires full-precision candidate vectors that turbovec discards after encoding.- Persistence methods renamed:
save_local → dump,load_local → load, matching the reference.
-
LlamaIndex integration (
turbovec.llama_index):TurboQuantVectorStoreis now a structural drop-in forllama_index.core.vector_stores.simple.SimpleVectorStore. Audited againstllama_index.core 0.12.39. In addition to the earlier filter fixes:__init__no longer requires a pre-builtIdMapIndex;TurboQuantVectorStore()works directly.from_params(dim=None, bit_width=4)is also lazy.get_nodes(node_ids, filters)implemented (the reference raises NotImplementedError because it doesn't store nodes; we do).clear()resets state while preservingbit_width.to_dict/from_dictfor config round-trip.get(text_id)raisesNotImplementedErrorwith an explanation — we can't return the original embedding (quantized away).delete_nodes(node_ids, filters)now honoursfilters(previously raised). Both constraints intersect when supplied.- Async overrides for
async_add,adelete,adelete_nodes,aclear,aquery,aget_nodes. - StorageContext compatibility: new
from_persist_dir(persist_dir, namespace, fs)matching the reference's namespaced-filename convention, soStorageContext.from_defaults(persist_dir=...)works. Thepersist/from_persist_pathon-disk layout is now stem-based:persist_pathis a path stem and we write{stem}.tvim+{stem}.nodes.jsonnext to each other. This fits StorageContext's file-shaped paths and lets multiple namespaced stores share a directory.
-
JSON side-cars across all three integrations. Haystack, LangChain and LlamaIndex persistence now writes a plain-JSON side-car next to the binary
IdMapIndexpayload instead of a pickle. Theallow_dangerous_deserializationflag is gone everywhere — loading is safe regardless of file provenance. Document / node metadata must be JSON-serializable, which matches the constraint the reference in-tree stores already impose. The side-car carries aschema_versionfield; loaders reject unknown versions instead of silently misinterpreting bytes.