anda_db_tfs is the embedded full-text search component of AndaDB. It implements the classic Okapi BM25 ranking algorithm and is designed for long-term textual memory in AI agents. It is written in pure Rust, thread-safe, and has very few dependencies. It can be used on its own or reused directly by the upper-layer database as the anda_db::BM25 index.
| Goal | Implementation |
|---|---|
| Embedded, zero external services | Pure Rust library; no Elasticsearch or Tantivy process required |
| Friendly to mixed Chinese and English text | Pluggable Tokenizer pipeline; built-in Porter stemmer and jieba tokenization |
| High-concurrency reads and writes | DashMap plus atomic counters; insert / remove / search can run concurrently across threads |
| Incremental persistence | The inverted index is sharded into buckets; only dirty buckets are flushed |
| Small memory footprint | Vec, FxHashMap, and compact CBOR encoding |
| Boolean queries | AND / OR / NOT syntax with parentheses for agent retrieval |
For a multi-term query q, the score of document d is:
-
$tf_{t,d}$ : the number of times term$t$ appears in document$d$ . -
$|d|$ : the total number of tokens in document$d$ ;$\text{avgdl}$ is the average document length across the corpus. -
$\text{idf}(t) = \ln!\left(1 + \dfrac{N - df_t + 0.5}{df_t + 0.5}\right)$ , the classic Okapi IDF smoothing formula. - The hyperparameters
$k_1$ and$b$ are configured throughBM25Params, with defaultsk1=1.2andb=0.75.
Before scoring, the library defensively clamps user-provided parameters: k1 is clamped to [0, 1000], and b to [0, 1]; non-finite inputs fall back to the defaults to avoid generating NaN or inf.
BM25Index
├── doc_tokens DashMap<doc_id, token_count> // document length table
├── postings DashMap<token, (bucket_id, Vec<(doc_id, tf)>)> // inverted index
├── buckets DashMap<bucket_id, Bucket> // shard metadata
├── metadata RwLock<BM25Metadata> // name / config / stats
└── atomic counters max_bucket_id, max_document_id, total_tokens, ...
-
Posting
(bucket_id, Vec<(doc_id, tf)>):bucket_ididentifies the bucket currently owning the token; the list holds one entry per indexed document, so its length is the token's document frequency.removekeeps it that way: when the supplied text does not account for all of a document's entries, it sweeps the rest by id. Entries of absent documents, possible only in data written by earlier releases, are pruned on load. -
Bucket: a serializable unit containing a group of tokens and the
doc_idsthey cover. A bucket tracks dirty state with dual version counters:Field Meaning dirty_versionIncremented on every modification saved_versionVersion last persisted successfully sizeApproximate byte size under current CBOR encoding tokensThe list of terms owned by this bucket doc_idsThe set of document IDs seen in this bucket
is_dirty() is true if and only if dirty_version > saved_version. A flush records the captured dirty versions as saved only after the manifest commit succeeds; failures leave the buckets retryable. The caller must exclude mutations, compaction, and other flushes for the entire flush. Version tracking does not make concurrent mutation during flush safe.
BM25Config.bucket_overload_size is the soft upper limit for the serialized size of a single bucket object, defaulting to 512 KiB. On insert:
- A token that already has a posting stays in the bucket that owns it; that bucket is only charged for the appended entry (and, the first time the document lands in it, for the document's token-count entry).
- A brand-new token is placed in the current tail if the bucket is empty or its estimated payload stays
< limit, including the new document-length entry. - Otherwise insertion advances the tail and retries that token. Its posting and final bucket registration are published together under the bucket lock; existing postings never have a temporary owner. Each token is placed once instead of repeatedly moving a pending token list.
Between flushes size is an estimate accumulated from those charges. Every flush and every reload replaces it with the exact length of the serialized bucket object — which includes the per-document token counts the object carries — so the estimate cannot drift for long. The same flush makes the bucket's doc_ids hint exact.
Note: a token that is already registered in a bucket is never moved by a later insert. This avoids the degenerate case where every insertion creates a new bucket (regression test test_no_excessive_small_buckets), but it also means the limit only applies at placement time: a bucket keeps growing by one entry for every later document that contains one of its tokens. Very frequent terms — stop words, with a tokenizer that keeps them — make their buckets grow with the corpus, and every flush that touches such a bucket rewrites it whole. Stop-word filtering in the tokenizer chain is the effective remedy; compact_buckets repacks whole tokens and never splits one.
For fragmented indexes left over from earlier versions, or buckets hollowed out by long-running remove operations, compact_buckets() can repack everything in one pass:
- Scan all
postingsand estimate each token's CBOR size. - Use the Best-Fit-Decreasing algorithm, with
BTreeMap<remaining_capacity, bin_index>forO(n log n)packing. - Rebuild
buckets: bucket IDs are reassigned from0upward, all buckets are marked dirty, and the nextflushrewrites the full on-disk layout.
The return value (old_count, new_count) is useful for monitoring. Concurrent insert / remove calls are safe: compact_buckets takes the index's internal mutation gate exclusively, so no mutation can observe — or add to — the half-rebuilt bucket map. It must still not overlap a flush (see §5).
Packing uses a fixed conservative CBOR width for the bucket owner, so the old bucket IDs cannot influence the new layout. Repeating compaction without data changes, including after reload, leaves the version and dirty state unchanged. Maintenance also shrinks posting lists whose capacity exceeds four times their length (and 64 entries), retaining space for twice the live length. This applies to single-bucket indexes too and does not itself require a disk write.
- Insert, remove, purge and search support concurrent calls. Mutations of the same document id are serialized with internal striped locks, spanning document membership, postings and bucket accounting. Batch purge deduplicates stripe indices and acquires them in ascending order.
- The lock order is mutation gate → document stripes → maps. Posting creation and bucket registration share a bucket lock. Removal rechecks posting ownership while holding that same bucket lock before unlisting a token.
- Compaction holds the mutation gate exclusively. The caller must exclude flush from mutations, compaction and other flushes for the entire async call; Collection's exclusive operation lease provides this. One writer per durable index remains the deployment contract.
- Searches are best-effort concurrent reads, not transactional snapshots. A query samples corpus statistics once; counters converge after completed mutations. No average-length cache is maintained.
- Metadata-only shells expose persisted statistics through
metadata()andstats(), whilelen()counts loaded documents. Partially loaded or failed-load indexes refuse inserts and flushes; remove/purge and compaction do nothing.is_fully_loaded()indicates writability. - Flush captures dirty versions and metadata, then serializes and uploads one bucket at a time. Extra payload memory is bounded by the largest dirty bucket. No internal map guard spans an await, and no bucket is marked clean before the manifest commit succeeds.
- Top-k selection uses
select_nth_unstable_by, followed by sorting the selected results:O(matches + k log k).
Physically, one index consists of one metadata blob plus multiple bucket blobs. The upper layer is free to store them in files, object storage, a KV store, or any other backend.
<root>/
metadata # CBOR-encoded BM25Metadata (carries the bucket manifest)
buckets/
b_0_7 # CBOR-encoded BucketOwned, addressed by (bucket_id, generation)
b_1_9
b_2 # legacy (pre-manifest) object at generation 0, read-only
...
Bucket objects are immutable once referenced: every flush writes replaced
buckets to a fresh (bucket_id, generation) object and the metadata's
manifest is the loader's single source of truth.
pub struct BM25Metadata {
pub name: String,
pub config: BM25Config,
pub stats: BM25Stats, // version / counts / timestamps / watermarks
/// Bucket manifest: bucket_id -> generation of the current durable object
/// (0 = legacy un-suffixed object). Empty when loading pre-manifest data.
pub buckets: BTreeMap<u32, u64>,
}flush uses last_saved_version as an idempotency guard: when neither stats.version has increased nor any bucket is dirty, it returns immediately with saved == false.
struct BucketOwned {
#[serde(rename = "p")] postings: FxHashMap<String, PostingValue>,
#[serde(rename = "d")] doc_tokens: FxHashMap<u64, usize>,
}The short field names ("p", "d") are chosen to reduce CBOR size. Note that PostingValue embeds bucket_id, so loading does not require any extra bookkeeping.
let outcome = index.flush(metadata_writer, now_ms, |object, bytes| {
// write bytes to the storage object for (object.bucket_id, object.generation)
std::future::ready(Ok(()))
}).await?;
for object in &outcome.obsolete {
// best-effort: delete the objects the new manifest no longer references
}flushcaptures dirty versions and the next manifest, then serializes/uploads dirty buckets one at a time. Only postings owned by that bucket are written; document lengths are derived from those postings. Buckets without tokens, other than the tail (max_bucket_id), are left out of the manifest instead of being written as empty objects, and their previous objects — including empty ones written by earlier releases — are reported obsolete. New tokens only go to the tail, so such buckets would otherwise stay empty and be fetched on every open. The tail is kept so the manifest never becomes empty.- Each dirty bucket is written to a new object keyed by
(bucket_id, generation); the generation is this flush's metadata version, so committed objects are never mutated in place. - The metadata — whose manifest maps every live bucket id to its current generation — is written last. That single write is the atomic commit point: a crash or error before it leaves the previous snapshot fully intact (the new objects are unreferenced garbage); after it, the replaced objects are garbage and are returned in
FlushOutcome::obsoletefor best-effort deletion. compact_bucketsneeds no special ordering: the repacked layout becomes visible atomically with the next manifest commit, and every pre-compaction object is reported obsolete.
let idx = BM25Index::load_all(tokenizer, metadata_reader, async |object| {
Ok(read_bucket(object).await?) // Ok(None) means this bucket is not loaded
}).await?;load_metadatarestores metadata only, which is useful for lightweight scenarios that need just statistics.- With a manifest present,
load_bucketsreads exactly the referenced(bucket_id, generation)objects. Metadata persisted by pre-manifest releases has no manifest; the loader falls back to scanning bucket ids0..=max_bucket_idat generation0(the legacy un-suffixed objects), and the first flush upgrades the durable layout to the manifest format. load_all/load_bucketspermit explicit read-only partial loads throughOk(None). The missing bucket set is retained across incremental loads; completing it enables mutation. Failed or cancelled loads remain read-only and retain already loaded document lengths for retry.- Production startup uses
load_all_strict;load_buckets_strictis also available. Missing manifest-referenced objects fail the load. Legacy layouts still tolerate holes in their bucket-id scan. - If the same token appears in more than one loaded bucket (possible only in legacy data written by the old multi-phase flush), the later bucket id wins. The loader removes that token from the older bucket, rebuilds bucket document-id sets from the winning postings, and marks repaired buckets dirty so the next flush removes stale on-disk ownership.
search_advanced(query, top_k, params) accepts boolean expressions, and QueryType::parse turns the input string into an AST:
expr := or_expr
or_expr := and_expr ( " OR " and_expr )*
and_expr := not_expr ( " AND " not_expr )*
not_expr := "NOT " not_expr | term
term := chunk ( whitespace chunk )*
chunk := "(" or_expr ")" | word
Precedence is OR < AND < NOT. The operators are case-sensitive and must stand between spaces (NOT followed by a space): a and b is three search words, and NOT NOT a nests two negations (evaluated as a). Key properties:
- Multi-term queries default to OR:
"quick fox"and"quick OR fox"return the same results insearchandsearch_advanced. A group next to words is one more OR operand:rust (async AND tokio)isrust OR (async AND tokio). - Score merging:
ANDsums the BM25 scores of its subqueries;ORdoes the same;NOTproduces a zero-scored placeholder set used only for filtering, and in anANDcontext it removes matching items from the result set. - Robust parsing: unbalanced parentheses do not panic. They are treated as ordinary characters, which makes direct forwarding of user input safe. An empty group is an empty
ORthat matches nothing, soa AND ()returns nothing. - Tokenization belongs to the tokenizer: the parser preserves operand case. Every boolean operand is tokenized independently with one tokenizer clone per query. An OR of terms merges and deduplicates the resulting tokens, never the raw text. Custom context-sensitive tokenizers may therefore distinguish plain text search from boolean word expressions.
- Candidate execution: AND evaluates selective operands first and restricts later work to the surviving candidates. Each posting is scored in one pass; DF is the posting length, so candidate-restricted scoring keeps global DF/IDF. NOT filters use set membership without computing discarded BM25 scores. IDFs are cached within the query and computed using
ln_1pfor numerical stability. NOTcomplement guard: a complement of the whole index (NOT a,a OR NOT b,NOT a AND NOT b) is limited to 10 000 documents. Inside anANDwith a positive operand, or undertry_search_in_ids, a NOT is taken relative to the documents already matched, which are in memory anyway, soa AND NOT (b AND NOT c)has no limit. Leading NOT pairs cancel at planning time:hello AND NOT (NOT world)ishello AND world, scored.try_search_advancedexposes resource errors;search_advancedreturns an empty result on error. Strict parsing rejects combined parentheses/NOT budget exhaustion instead of silently changing query meaning.- Multi-byte safe: the delimiters
" AND "and" OR "are ASCII, so byte-wise scanning remains safe under UTF-8. Mixed CJK text does not require extra handling.
Example:
let hits = index.search_advanced(
"(brown AND fox) AND NOT (rare OR sleeps)",
10,
None,
);OR normalization flattens nested ORs and merges their direct token operands even when AND/NOT branches are present. Duplicate normalized tokens contribute once within that OR; redundant OR parentheses do not change scores. Complex branches still contribute their own scores. Empty candidate sets stop before reading postings; other candidate searches continue to use global DF/IDF.
All tokenizers implement tantivy_tokenizer_api::Tokenizer and are composed through TokenizerChain. Important APIs:
| Function | Role |
|---|---|
TokenizerChain::builder(base).filter(f1).filter(f2).build() |
Compose a tokenization pipeline |
default_tokenizer() |
SimpleTokenizer -> RemoveLongFilter(32) -> LowerCaser -> Stemmer (requires the tantivy feature) |
jieba_tokenizer() |
Prepends JiebaMergeFilter to the pipeline above (requires the tantivy-jieba feature) |
collect_tokens(tok, text, inclusive) |
Standalone tokenization helper for upper-layer preprocessing or non-BM25 matching statistics |
flat_full_text_search(tok, query, text) |
Naive matching without building an index, useful for short passages |
detect_script(text) |
Detects the dominant script by character frequency (Latin / Cyrillic / Arabic / Cjk / Other) |
In mixed-script text, SimpleTokenizer treats consecutive Chinese characters as one token. The public generic JiebaMergeFilter re-segments CJK spans and globally orders (offset_from, offset_to, position, text). The built-in jieba_tokenizer() uses the same segmentation and filters but buffers and sorts only one SimpleTokenizer span at a time. Regression tests compare the complete token stream, including offsets and positions, against the generic chain. Both preserve:
- Chinese text is segmented correctly (
"北京市东城区长安街"->北京,东城区,长安街); - English, Russian, Arabic, and other scripts retain the stemmed and lowercased output of the primary pipeline;
- The resulting
TokenStreamis still monotonic, so downstream BM25 consumption remains correct.
RemoveLongFilter::limit(32) retains tokens with UTF-8 byte length less than 32, not 32 Unicode characters. collect_tokens copies a term only on its first occurrence in the frequency map.
Note:
collect_tokensfilters out single-byte tokens wheretoken.text.len() <= 1(length measured in bytes), which removes punctuation and isolated ASCII letters. Single Chinese characters are unaffected because their UTF-8 length is at least 3 bytes.
pub enum BM25Error {
Generic { name: String, source: BoxError },
Serialization { name: String, source: BoxError },
NotFound { name: String, id: u64 },
AlreadyExists { name: String, id: u64 },
TokenizeFailed{ name: String, id: u64, text: String },
}Generic is used for errors returned by I/O closures and by the query guards of try_search_advanced; Serialization wraps cbor2 failures; AlreadyExists and TokenizeFailed occur during insert (TokenizeFailed carries at most the first 256 bytes of the document text); NotFound is never raised by the crate and is left to upper-layer APIs for idempotent validation.
let cfg = BM25Config {
bm25: BM25Params { k1: 1.5, b: 0.6 },
bucket_overload_size: 1024 * 1024, // 1 MiB
};
let index = BM25Index::new("mem".into(), jieba_tokenizer(), Some(cfg));Tuning guidance:
k1in [1.2, 2.0]: for shorter documents with denser keywords, increasing it slightly can amplify the effect of high-frequency terms.bin [0.5, 0.9]: choose0.75to0.9when document lengths vary significantly; drop closer to0.5when most documents are similar in length.bucket_overload_size:- Small (for example
64KiB): lower I/O amplification for incremental flushes, suitable for frequent checkpoints. - Large (for example
2MiB): fewer total buckets and faster full loads, suitable for read-heavy AI memory stores.
- Small (for example
- Periodic compaction: call
compact_buckets()periodically in a background task to keep bucket counts stable. It is safe against concurrentinsert/remove(the crate gates it internally); schedule it so it does not overlap aflush.
Test coverage includes:
cargo test -p anda_db_tfs --all-featurescargo test -p anda_db_tfs --no-default-featurescargo test -p anda_db_tfscargo test -p anda_db_tfs --features full --example tfs_demo- Correctness of insert / remove / search, bucket serialization and partial loading, result invariance after compaction, the regression test
test_no_excessive_small_buckets, UTF-8 query parsing, and more.
Benchmarks: cargo bench -p anda_db_tfs --features full --bench tfs_tokenizer and cargo bench -p anda_db_tfs --features full --bench tfs_index. The latter covers scoring, mutations, load/flush, compaction and mixed Chinese text. See measurements and the review checklist for reproducible comparisons and remaining storage tradeoffs.
use anda_db_tfs::{BM25Index, default_tokenizer};
let idx = BM25Index::new("notes".into(), default_tokenizer(), None);
idx.insert(1, "The quick brown fox jumps over the lazy dog", 0).unwrap();
idx.insert(2, "A fast brown fox runs past the lazy dog", 0).unwrap();
idx.insert(3, "The lazy dog sleeps all day", 0).unwrap();
for (id, score) in idx.search("fox", 10, None) {
println!("doc {id}: {score:.3}");
}
for (id, score) in idx.search_advanced("(brown AND fox) AND NOT sleeps", 10, None) {
println!("doc {id}: {score:.3}");
}Persisting to the local filesystem uses the tested write_atomic helper. It creates a temporary file beside the target, writes and syncs it, renames it atomically, and syncs the directory on Unix. If a crashed process left the first temporary name behind, the helper advances its sequence until it creates a fresh file; it never deletes an existing temporary file that could belong to another writer. The complete runnable adapter is in tfs_demo.
use std::{fs, path::{Path, PathBuf}};
use anda_db_tfs::{BoxError, BucketObject};
// Import write_atomic from the linked helper into your application.
fn bucket_path(object: BucketObject) -> PathBuf {
if object.generation == 0 {
format!("./idx/b_{}.cbor", object.bucket_id).into()
} else {
format!("./idx/b_{}_{}.cbor", object.bucket_id, object.generation).into()
}
}
fs::create_dir_all("./idx")?;
let outcome = idx.flush_with(
now_ms,
|bytes| std::future::ready(
write_atomic(Path::new("./idx/metadata.cbor"), &bytes)
.map_err(BoxError::from)
),
|object, bytes| std::future::ready(
write_atomic(&bucket_path(object), &bytes).map_err(BoxError::from)
),
).await?;
for object in outcome.obsolete {
let _ = fs::remove_file(bucket_path(object));
}
let idx = BM25Index::load_all_strict(
default_tokenizer(), fs::File::open("./idx/metadata.cbor")?,
async |object| match fs::read(bucket_path(object)) {
Ok(bytes) => Ok(Some(bytes)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
},
).await?;Never open the committed metadata with File::create before flush: a clean flush invokes no callback, so that would leave an empty file. flush<W: Write> writes and flushes its writer but cannot supply atomic replacement or filesystem durability on its own. A metadata callback with an uncertain outcome requires reopening from durable state.
- Removal wants the original text:
remove(id, text, now_ms)re-tokenizes the text to find the document's postings. If those entries do not add up to the document's token count (wrong text, or a changed tokenizer),removesweeps every posting list for the id: still correct, butO(index size). When the text is genuinely unrecoverable — a repair path whose document bodies are gone — usepurge_ids(&BTreeSet<u64>, now_ms): it sweeps every posting list once for the whole set, drops the ids fromdoc_tokensandtotal_tokens, and marks the affected buckets dirty. Pass all the dead ids in one call rather than looping. top_k = 0: kept for API compatibility. It returns an empty set and does not trigger sorting.- Flush coordination: the crate does not serialize flushes internally. The caller must ensure a flush never overlaps mutations, compaction, or another flush (
anda_db'sCollectionalready guarantees this); a single writer per durable index is a deployment contract. - Search semantics under partial loading: if
load_bucketsskips a posting bucket, terms owned by that bucket are unavailable. Loaded buckets also carry the document lengths needed to score their postings, solen()may include every document touched by those loaded terms even when other buckets are skipped. Search results remain the natural subset of the loaded postings. - Embedded-only: this library is intended for in-process embedding inside AndaDB and does not provide HTTP or gRPC services. For remote access, use
anda_db_serveroranda_db_shard_proxy.
- Robertson & Zaragoza. The Probabilistic Relevance Framework: BM25 and Beyond, 2009.
tantivy_tokenizer_api- tokenizer trait.- For regression cases and design discussion, see
rs/anda_db_tfs/src/bm25/tests.rs,src/bm25/regression_tests.rsandtests/regressions.rs, and anda_db_btree.md for the related bucket strategy.