| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| zeusdb-vector-database-0.8.0.cdx.json | 2026-08-26 | 127.7 kB | |
| README.md | 2026-08-26 | 18.5 kB | |
| Version 0.8.0 source code.tar.gz | 2026-08-26 | 684.3 kB | |
| Version 0.8.0 source code.zip | 2026-08-26 | 742.0 kB | |
| Totals: 4 Items | 1.6 MB | 0 | |
The filter language gains boolean composition, presence tests and columns for the fields you declare, and a filtered search returns the nearest of the records the filter matches rather than whatever survived an unfiltered page. The index gains the verbs a store expects, len(), in, count(), delete(), clear(), update_metadata() and rebuild() among them, and dot as a fourth metric. A save is atomic and verified, a quantized index is scored on the metric it declared, and an unquantized index holds about half the memory it did. dim is now required, add() raises on a malformed batch and l1 can no longer be quantized, so read the breaking list before upgrading. A directory saved by 0.7.0 opens unchanged unless it pairs l1 with quantization or was created outside the new bounds.
Breaking
dimis required oncreate(). It defaulted to 1,536, which built an index sized for one vendor's model family and reported the mistake at the firstadd(), as a dimension mismatch on every vector of any other width.create("hnsw")without it raisesTypeError. Pass the width your embedding model produces, for exampledim=1536for OpenAI text-embedding-3-small ordim=768for most sentence-transformers models.add()raisesValueErrorwhen the parallel arrays of a batch dict disagree in length, naming both lengths and the short field. It used to insert a prefix in one direction and store the surplus records under generated ids in the other, so one id against two vectors reported2 inserted, 0 errors. Makeids,metadatasand the vector array the same length. Omittingidsentirely is not a disagreement and still generates one per record.- A parallel array that is not a
listraisesTypeError. A tuple or an ndarray ofidsormetadataswas counted as one error per record on the list path and discarded whole on the NumPy path, where every record then took a generated id. Pass a list, which for an array isids.tolist(). - A metadata filter decides which records are ranked, not which results survive. The traversal admits only matching records, so a filtered search returns the
top_knearest of the records the filter matches. It used to find thetop_knearest of every record and then discard the ones the filter rejected, so a selective filter returned a short or empty page. A filter matching 5 of 300 records attop_k=5returns all 5 where it returned none. Code that raisedtop_kto compensate can stop, and a test that pinned a short page under a filter needs its expectation refreshed. Where the filter matches fewer than 5,000 records those records are scored exactly rather than traversed, and that page is ordered by distance and then by id. l1cannot be quantized.create(space="l1", quantization_config=...)raisesValueError. A quantized graph scores every candidate from tables of squared L2 distances to the codebook, and Manhattan distance does not order the same way, so such an index returned the wrong records and reported a score on a quantity it never declared. L1 tables over the shipped codebook and over a k-medians codebook fitted to absolute error were measured by brute force on three corpora at 100,000 records, and ranked 0.03 to 0.08 below what quantizedl2reaches at its own game on the same records, and at the defaultsubvectorsbelow the squared L2 ordering they would replace, so the pair stays refused. Usespace="l2"with the same configuration, orl1without quantization.- A directory pairing
l1with quantization no longer opens, whatever release saved it.load()raises the sameValueError, naming the path. Open it under the release that saved it, read the records back withget_records(), and add them to an index created underl2or without quantization. Every other directory saved by 0.7.0 inside the bounds below opens without any action. - A quantized
cosineindex ranks and scores by cosine distance to the reconstruction. It ranked and scored by the squared L2 distance to it and reported that sum as the score, which read 1.86 times the cosine distance at 25,000 records. Result order changes, in each measured case for the better. Recall at 10 rose by 0.0015 to 0.0518 across three corpora at two subvector counts each, with every 95 percent half width at or under 0.001. The exact scan path normalises the reconstruction before scoring, asadd()does, where it scored against a reconstruction of length 0.90. A search costs 1 to 3 percent more and a build 3 to 8 percent more. A test that pinned scores or exact result sets from a quantized cosine index needs its expectations refreshed. A quantized cosine directory saved by an earlier release opens and is scored by cosine distance from then on. Its graph keeps the neighbour lists it was saved with. Loading it once withZEUSDB_LOAD_REBUILD_GRAPH=1set and saving it again wires it on the new ordering. - A quantized
l2index reports the rooted distance on every path. The traversal returned the squared sum and the exact scan the rooted distance, so one index reported 159.416 and 12.626 for the same record depending on how selective the filter was. The page is rooted once per candidate, which does not move the order. A threshold on the score of a quantizedl2index that was derived from the traversal path needs re-deriving on the rooted scale. An unquantized index is unchanged. dim,ef_construction,top_kandef_searchhave ceilings.dimabove 65,536 andef_constructionabove 4,096 raise atcreate(),rebuild()andload(), andtop_kabove 65,536 andef_searchabove 131,072 raise atsearch(). None had one, andcreate(dim=2**40)ended the process with an allocation failure. A directory saved with a value outside these bounds does not open under this release. Open it under the release that saved it, read the records back withget_records(), and add them to an index created inside the bounds.save()andload()print nothing. Both wrote progress lines to stdout, ending in a line reporting that the save or load had completed. A script that read stdout for those lines has nothing to read.save()returnsNoneand raises on failure,load()returns the index, and the log records are unchanged.
Fixed
- Records queued in the log file's writer at process exit were lost, because the appender's worker guard was never dropped and its
flush()is a no-op. The guard is dropped at exit through anatexithook registered on import, which waits for the writer to drain.os._exit()and an abort run no hook. - A trained
quantized_with_rawindex saved while holding no records reopened without its raw vector store, so every record added after the load lost its raw vector permanently andget_records()returned a reconstruction. Reached byclear()or by removing every record before a save. The store is opened whenever the storage mode expects one. - A
quantized_with_rawdirectory whosevectors.binwas lost opened as a complete index built from reconstructions with the top page moved, one whosequantization.jsonwas lost opened as unquantized, and one whosepq_codes.binwas lost reported itself quantized while holding no codes. A raw index saved over a quantized directory reopened carrying the previous save's codebook and codes.load()checksfiles_includedinmanifest.jsonagainst the directory in both directions before it parses anything, reads each quantization artefact only when the manifest names it, and refuses a directory with a file missing or with a file the manifest does not name, naming the file. total_size_mbinmanifest.jsoncounted the previous save's graph dump or none at all,training_progressinget_stats()read0/1000 (100.0%)on a trained index,created_atrecorded the load rather than the creation when a loaded index was saved again, andtraining_completed_atwas stamped at save rather than when the codebook was fitted. All four are corrected, and the two timestamps survive a save and load.ZEUSDB_LOG_LEVEL=warningwas accepted by the Python layer and refused by the Rust layer, which printedignoring zeusdb_vector_database=warningto stderr and then filtered nothing, andwarnfailed the other way round. Both layers acceptwarn,warning,err,error,fatalandcritical.- A filter nested about four thousand levels deep overflowed the stack and ended the process. Conversion is capped at 128 levels and group nesting at 10, and either raises
ValueErrornaming the depth. load()validates what it reads.config.jsonandquantization.jsonare held to the rulescreate()applies todim,space,m,ef_construction,expected_size,bitsandsubvectors, where an unrecognisedspaceused to fall back tocosinein silence,bitsof 40 ended the process,bitsof 64 masked every code to zero andsubvectorsof 0 divided by zero. Every container length inmappings.bin,vectors.bin,pq_codes.binandpq_centroids.binis decoded under a budget of 64 bytes per byte the file holds, where ten of them went to the allocator unexamined and a declared length of 2^40 ended the process. A graph dump naming an origin id above whatconfig.jsoncounts is refused, where it sized the graph's id table from that id and ended the process. A damaged or hand edited directory raises where it used to end the process or open wrongly.- The
quantized_with_rawwarning, thecreate()docstring and the README said the mode held less memory than an unquantized index above a break even. It holds more at every record count, 1.08 times at 50,000 records of dimension 1,536 and 1.14 times at dimension 128, because it keeps every raw vector and adds the codes and the trained tables to it. The warning describes the mode as the accuracy mode and quotes only figures exact from the configuration. - Eight error messages carried runs of spaces where a line continuation had been lost.
Added
space="dot", the inner product, as a fourth metric.search()reports1 - dotso that lower stays better, vectors are stored as given rather than normalised, andindex.spacereads the metric back. It cannot be combined withquantization_config, which raisesValueErroratcreate(). The codebook is fitted by squared L2 and cannot rank by the inner product, with recall at 10 never above 0.37 by brute force over its own reconstructions across three corpora and stored length spreads up to three orders of magnitude, against an unquantizeddotindex at least 0.35 higher on the same data. Usecosineon normalised vectors where only direction should count, ordotwithout quantization where length must count.- Boolean composition in the filter language.
$and,$orand$notcompose whole filters, where a mapping was only ever a conjunction of its fields. The three names are reserved as keys, so a field named$and,$oror$notraisesValueErrornaming the key, and any other name beginning with$still filters. Groups nest to 10 levels. - Filter operators
nin,any,all,exists,is_missingandis_null.nin,anyandallexclude a record lacking the field, as the existing operators do, andexists,is_missingandis_nulltaketrueorfalseand refuse anything else. create(indexed_fields=[...])builds a column for each metadata field named, so a filter naming only declared fields is answered from the columns rather than by reading every record's metadata. Up to 32 names. At 100,000 records a filter matching one record costs 0.09 to 0.15 milliseconds where it cost 28 to 74, and eight declared columns cost 6.69 MB. A filter naming an undeclared field returns the same records, reads every record to find them and logs one warning naming the field, and one naming a declared field beside an undeclared one is bounded by the declared branch where that removes at least two thirds of the records. The declaration is carried inconfig.jsonand the columns are rebuilt frommetadata.jsonon load, so no file is added and a directory saved before this opens unchanged.index.indexed_fieldsreads the declaration back.len(index),id in index, andcount(filter=None), which walks every record with the interpreter lock released.remove_points(ids), returning the ids that were not present,remove_where(filter), returning how many it removed, anddelete(ids=..., where=...), which dispatches to them, returns a count, and raises when given both arguments or neither.remove_where({})is refused, because an empty filter matches every record everywhere else in the language and here that would empty the index.clear(), which replaces the graph rather than removing record by record, keeps the configuration including a fitted codebook, and returns the count removed. It does not reset the generated id counter, so ids generated after it continue the sequence.update_metadata(id, metadata), which replaces one record's metadata wholesale, asadd(overwrite=True)does, leaves the vector, the codes and the graph node alone, and returns whether the id was present.rebuild(m=..., expected_size=..., ef_construction=...), which builds the graph again at a new configuration, in place. Every record keeps its vector, its metadata, its external id and its internal id, and a quantized index is rebuilt from its stored codes rather than re-encoded. The three arguments are held to the rulescreate()applies.mandef_constructionwere fixed at construction before this.shrink_to_fit(), which returns the graph's spare buffer capacity to the allocator and reports the bytes released.compact()calls it.list(after=...), a cursor naming the last id of the previous page, besideoffset. A deletion ahead of an offset shifts the next page by one and a cursor does not. The two cannot be combined, and a cursor naming a removed record raisesKeyErrorrather than returning a page from somewhere else.get_records(strict=True), which raisesKeyErrornaming every id the index does not hold. The default still skips them.AddResult.ids, every id the call put in the index, in insertion order.index.space,index.m,index.ef_constructionandindex.expected_sizeas read-only properties besideindex.dim.shutdown_logging(), exported at package level, which drains the log file and closes it. It is registered withatexiton import, so a normally exiting process needs no call. Records emitted after an explicit call are discarded.centroid_norm_memory_mbinget_stats()on a quantized index, pricing the table of squared centroid norms the cosine scorer reads. It is folded intototal_memory_mb.- A CycloneDX SBOM of the crates the wheel links, attached to each GitHub Release, and dependency scanning with cargo-deny on pull requests touching the manifests and daily, reporting advisories to code scanning.
- A fuzzer over the graph dump reader, a randomised operation sequence checked after every step against a model of what the index holds, subprocess tests for every allocation bound, a comparison of every search page against a brute force ranking, and a lock rank registry that asserts the declared lock order on every acquisition in a debug build.
Changed
- An unquantized index holds about half the memory it did. Every raw vector was held twice, once in a map keyed by external id and once inside the graph, byte for byte. It is held once, in a store addressed by node index that the graph reads rather than owns. At 50,000 records of dimension 1,536 the index commits 325.3 MiB where it committed 632.4. Search latency did not move and the on-disk format is unchanged.
graph_memory_mbreports everything the graph holds apart from the raw vectors, andtotal_memory_mbno longer counts a raw vector twice, so both read lower on an index that has not changed. - A save writes a sibling directory and renames it into place, so a reader sees the previous index or the new one and never a mixture, and no artefact of an earlier save survives.
manifest.jsonis written last and records a length and a SHA-256 digest for every other artefact, whichload()checks before parsing them. A directory saved by an earlier release carries no digests and opens as before.format_versionis unchanged at 1.1.0. list()returns records in arrival order, ascending by internal id, where it returned hash map iteration order, which differed from one process to the next. The order survivessave()andload(), andoffsetpages over it.- A 2-D NumPy array of
float32orfloat64given toadd()or to batchsearch(), and a 1-Dfloat64array given to singlesearch(), is read directly rather than through the sequence protocol, which every such array fell through to before. The list path's empty batch check and dimension mismatch message apply on the array paths as well. - Neighbour vectors are prefetched during a traversal on x86-64.
- The rebuild
load()falls back to when a graph dump is absent or damaged runs inside the extension rather than through Pythonadd()calls, and wires the same graph byte for byte. - The
quantized_with_rawwarning is suppressed where the configuration never trains, the low dimension warning is removed, thequantized_onlybreak even counts both codes a record carries, which moves the figure from 4,476 to 4,626 records at dimension 64, and every quantization warning attributes to the caller'screate()line rather than to library internals. - Every GitHub Actions step is pinned to a commit SHA, each wheel's SHA-256 is recorded at build and verified before publishing, a check-version job holds the tag,
pyproject.toml,vdb-core/Cargo.tomland__version__to one canonical version, every job carries a timeout, the lock order suite runs on a pull request touching a module that takes a lock, and the compiler is pinned to 1.97.1 inrust-toolchain.toml. The Miri job is removed, and the two tests comparing the AVX kernels against the scalar path bit for bit run on every push. - The README documents the new verbs, the filter composition and columns, what a filtered search costs, what each storage mode holds, and
is_training_ready,training_vectors_needed,rebuild_with_quantization,get_performance_infoandbenchmark_concurrent_reads, and corrects what it said about batch atomicity, cosine normalisation andquantized_onlyreconstruction.