One index answers every question, and every answer is checked. Exact substrings, matches across separators, typos across token boundaries, regular expressions, two-character needles — with BM25 and the exact bytes of every match, nothing to configure per question, and every answer compared to a scan of the files. A Rust library with Python, Node.js and C++ bindings, that runs in your process, in your transaction, and in your browser.
Everything in the terminal is real: it clones lucivy's own source from GitHub and indexes it in this tab — every count and every time below is measured here, right now.
Then type index mdn, index linux, index go… at the prompt: a whole repository — all of MDN Web Docs, the entire Linux 2.6.0 kernel, Go, Godot, TypeScript, PostgreSQL, CPython — indexed in this tab in 15 to 35 seconds, kept in this browser, reopened in seconds next time.
lock_init is found inside spin_lock_init, suffixfst
finds suffix_fst and suffix FST, an emoji is bytes like any other.
A tokenizer sees an identifier as one opaque token, or three; lucivy indexes every suffix
of every token in a suffix FST and chains tokens through a sibling table, so
a fragment or a whole phrase is found wherever it sits — separators relaxed or strict
— with the exact bytes highlighted.
Sharded[A-Z]\w+ costs the price of Sharded: the required literal
drives the walk, regex::Regex decides on the rebuilt windows. A pattern with no
usable literal falls back to a scan — and query_warnings says so before
you run it.
Document ─ tokenizer ─┬─ inverted index (postings, term frequencies)
├─ SFX v3: suffix FST + 7 sidecars per field
├─ fast fields
└─ doc store
Query ─ FST walk (substring / trigrams / literals) ─ sibling chains across tokens
─ validation on the source text (Levenshtein, Jaro-Winkler, regex)
─ BM25 with global statistics ─ byte spans
Five crates and four bindings: ld-lucivy (engine), lucivy-core
(ShardedHandle, queries, snapshots, storage), luciole (actor runtime and DAGs,
WASM-safe), lucistore (blob storage, snapshots, deltas) and
sparse-vector (a sparse index with WAND pruning on the same storage). The whole
design is in ARCHITECTURE.md.
That is the whole engine in two pictures. What follows is one entry per way of using it — the call first, and how it works underneath after it.
One JSON shape for every kind of search. A query is a dict (or a plain string, which becomes a substring search over every text field), and the answer carries the score and the exact byte spans that matched.
index.search({"type": "contains", "field": "body", "value": "mutex"}, highlights=True)
index.search({"type": "contains", "field": "body", "value": "mutx", "distance": 1}) # fuzzy
index.search({"type": "fuzzy", "field": "body", "value": "mutx",
"fuzzy_metric": "jaro_winkler", "min_similarity": 0.9})
index.search({"type": "contains", "field": "body", "value": "lock.*mutex", "regex": True})
index.search({"type": "phrase", "field": "body", "value": "return -ENOMEM"})
index.search({"type": "startsWith", "field": "body", "value": "pthread"})
index.search({"type": "term", "field": "body", "value": "lock"}) # whole words only
index.search({"type": "parse", "fields": ["body", "path"],
"value": 'mutex AND NOT guard OR "exact phrase"'})
# What the engine will really do, before it does it
index.query_warnings({"type": "contains", "field": "body", "value": "__init"})
# ['separators are ignored (strict_separators=false): "__init" is searched as "init"']
index.search({ type: 'contains', field: 'body', value: 'mutex' }, { highlights: true });
index.search({ type: 'contains', field: 'body', value: 'mutx', distance: 1 }); // fuzzy
index.search({ type: 'fuzzy', field: 'body', value: 'mutx',
fuzzyMetric: 'jaro_winkler', minSimilarity: 0.9 });
index.search({ type: 'contains', field: 'body', value: 'lock.*mutex', regex: true });
index.search({ type: 'phrase', field: 'body', value: 'return -ENOMEM' });
index.search({ type: 'startsWith', field: 'body', value: 'pthread' });
index.search({ type: 'term', field: 'body', value: 'lock' }); // whole words only
index.search({ type: 'parse', fields: ['body', 'path'],
value: 'mutex AND NOT guard OR "exact phrase"' });
// What the engine will really do, before it does it
index.queryWarnings({ type: 'contains', field: 'body', value: '__init' });
use lucivy_core::query::QueryConfig;
use serde_json::json;
let q: QueryConfig = serde_json::from_value(json!({
"type": "contains", "field": "body", "value": "mutex"
}))?;
let sink = std::sync::Arc::new(ld_lucivy::query::HighlightSink::new());
let hits = index.search(&q, 10, Some(sink.clone()))?; // spans land in the sink
// Every other kind is the same call with a different config
json!({"type": "contains", "field": "body", "value": "mutx", "distance": 1});
json!({"type": "fuzzy", "field": "body", "value": "mutx",
"fuzzy_metric": "jaro_winkler", "min_similarity": 0.9});
json!({"type": "contains", "field": "body", "value": "lock.*mutex", "regex": true});
json!({"type": "parse", "fields": ["body", "path"], "value": "mutex AND NOT guard"});
// What the engine will really do, before it does it
index.query_warnings(&q);
term adds
"anchored at a token start" and "covers whole tokens", startsWith adds only the
first, phrase asks for adjacency, fuzzy adds an edit distance,
regex a pattern — so all of them cross token boundaries, and all of them return
byte spans. parse lowers boolean syntax (AND, OR,
NOT, quotes, +/-, parentheses) onto the same
substring queries. Separators are relaxed by default: _, -,
., / and spaces are ignored on both sides, so
rag3weaver finds rag3_weaver; pass
strict_separators: true when spin_lock must not match
spin-lock.
Two kinds: restrict to a set of your own ids, or compose non-text conditions with the text query.
# A pre-filter by your ids — the engine only visits those documents index.search({"type": "contains", "field": "body", "value": "kmalloc"}, allowed_ids=[3, 7, 11, 12]) # Non-text conditions, composed with must / should / must_not index.search({"type": "boolean", "must": [{"type": "contains", "field": "body", "value": "error"}, {"type": "filter", "op": "gte", "field": "priority", "value": 3}], "must_not": [{"type": "term", "field": "state", "value": "closed"}]})
// A pre-filter by your ids — the engine only visits those documents index.search({ type: 'contains', field: 'body', value: 'kmalloc' }, { allowedIds: [3, 7, 11, 12] }); // Non-text conditions, composed with must / should / must_not index.search({ type: 'boolean', must: [{ type: 'contains', field: 'body', value: 'error' }, { type: 'filter', op: 'gte', field: 'priority', value: 3 }], mustNot: [{ type: 'term', field: 'state', value: 'closed' }] });
// A pre-filter by your ids — the engine only visits those documents let allowed: HashSet<u64> = [3, 7, 11, 12].into_iter().collect(); index.search_filtered(&q, 10, None, allowed)?; // Non-text conditions, composed with must / should / must_not let q: QueryConfig = serde_json::from_value(json!({ "type": "boolean", "must": [{"type": "contains", "field": "body", "value": "error"}, {"type": "filter", "op": "gte", "field": "priority", "value": 3}], "must_not": [{"type": "term", "field": "state", "value": "closed"}] }))?;
allowed_ids is a real pre-filter, not a post-filter: the id set is routed
to the shards that hold those ids (the others stay idle), and it travels all the way into
the v3 resolvers — the FST walk, the postings, the verification and the spans are done for
those documents only. A regex over ten allowed documents went from 126 ms to 4 ms when this
was wired. The scoring follows: document frequencies are counted on the subset and
N is the subset's size, so a filtered search scores as if the index were the
subset.
One number at creation. Indexing and searching then run across shards in parallel, and the scores do not move.
index = lucivy.Index.create("/tmp/idx", fields=[...], shards=4)
index.add(1, body="...") # the router decides where it lands
index.commit()
index.search({"type": "contains", "field": "body", "value": "mutex"})
index.compact(10_000) # merge down to segments of at most N documents
index.wait_merges_quiet() # let the background merges finish
const index = Index.create('/tmp/idx', [{ name: 'body', type: 'text' }], 4);
index.add(1, { body: '...' }); // the router decides where it lands
index.commit();
index.search({ type: 'contains', field: 'body', value: 'mutex' });
index.compact(10000); // merge down to segments of at most N documents
index.waitMergesQuiet(); // let the background merges finish
use lucivy_core::sharded_handle::ShardedHandle;
use lucivy_core::query::SchemaConfig;
let config: SchemaConfig = serde_json::from_value(json!({
"fields": [{ "name": "body", "type": "text", "stored": true }],
"shards": 4,
}))?;
let index = ShardedHandle::create("/tmp/idx", &config)?;
index.add_document_json(1, &json!({ "body": "..." }))?; // the router places it
index.commit()?;
index.search(&q, 10, None)?;
index.compact(10_000)?;
index.wait_merges_quiet()?;
meta.json, its own
segments — behind a router. The router places a document by balance (round robin) or by
tokens, so similar documents can share a shard. A search is a DAG: the prescan of every
segment of every shard runs as one task each (that is where the parallelism lives), then one
weight is compiled from statistics aggregated over all shards and dispatched. That is
why 1 shard and 4 shards give identical scores — the measured difference is 0.0000 — and why
adding shards costs no accuracy.
Several machines, each with its own index, searched as one — without copying or mounting anything. Two round trips of small JSON, no data movement: what crosses the network is statistics, never documents.
# ── on every node (a process, a machine) ────────────────────────── # Each node serves its own index and answers two calls over your transport. def handle_export_stats(query: dict) -> str: return index.export_stats(query) # a JSON string, a few kB def handle_search(query: dict, merged_stats: str, allowed=None) -> list: return index.search_with_global_stats(query, merged_stats, limit=10, allowed_ids=allowed) # ── on the coordinator ──────────────────────────────────────────── # Round 1: ask every node what it knows about this query. stats = [post(node, "/export_stats", query) for node in NODES] # parallel in practice merged = lucivy.merge_stats(stats) # one JSON string, sent back to all # Round 2: everyone searches under the same statistics, and returns its top-k. pages = [post(node, "/search", {"query": query, "stats": merged}) for node in NODES] # Round 3 is local: merge the top-k lists by score. Nothing else to do — # the scores are already comparable, which is the whole point. hits = sorted(sum(pages, []), key=lambda h: -h["score"])[:10] # ── restricted to what this caller may see ──────────────────────── # The ids come from your permissions, your tenant, your agent's domain — # and they are spread over the nodes. Send them along; each node visits # only those documents, and still scores under the federation's statistics. allowed = acl.visible_ids(user) # e.g. [17, 44, 802, ...] pages = [post(node, "/search", {"query": query, "stats": merged, "allowed": allowed}) for node in NODES] # A document keeps the rank it would have had for anyone else: the filter # says what is *visited*, the statistics say how it *scores*.
// ── on every node (a process, a machine) ───────────────────────── app.post('/export_stats', (req, res) => res.send(index.exportStats(JSON.stringify(req.body.query)))); // a JSON string app.post('/search', (req, res) => res.json(index.searchWithGlobalStats(JSON.stringify(req.body.query), req.body.stats, 10, false, req.body.allowedIds))); // ── on the coordinator ─────────────────────────────────────────── // Round 1: ask every node what it knows about this query. const stats = await Promise.all(NODES.map(n => post(n, '/export_stats', { query }))); const merged = mergeStats(stats); // one JSON string, sent back to all // Round 2: everyone searches under the same statistics. const pages = await Promise.all(NODES.map(n => post(n, '/search', { query, stats: merged }))); // Round 3 is local: merge the top-k lists by score, and stop there. const hits = pages.flat().sort((a, b) => b.score - a.score).slice(0, 10); // ── restricted to what this caller may see ─────────────────────── // The ids come from your permissions, your tenant, your agent's domain, // and they are spread over the nodes. Each node visits only those // documents, and still scores under the federation's statistics. const allowedIds = acl.visibleIds(user); // e.g. [17, 44, 802, ...] const pages2 = await Promise.all(NODES.map(n => post(n, '/search', { query, stats: merged, allowedIds }))); // A document keeps the rank it would have had for anyone else: the filter // says what is visited, the statistics say how it scores.
use lucivy_core::bm25_global::ExportableStats; // ── on every node (a process, a machine) ───────────────────────── async fn export_stats(index: &ShardedHandle, q: &QueryConfig) -> String { serde_json::to_string(&index.export_stats(q).unwrap()).unwrap() } async fn search(index: &ShardedHandle, q: &QueryConfig, stats: &str) -> Vec<ShardedSearchResult> { let merged: ExportableStats = serde_json::from_str(stats).unwrap(); index.search_with_global_stats(q, 10, &merged, None).unwrap() } // ── on the coordinator ─────────────────────────────────────────── // Round 1: ask every node, in parallel, what it knows about this query. let per_node: Vec<ExportableStats> = join_all(nodes.iter().map(|n| n.export_stats(&q))).await; let merged = ExportableStats::merge(&per_node); // Round 2: everyone searches under the same statistics. let pages = join_all(nodes.iter().map(|n| n.search(&q, &merged))).await; // Round 3 is local: merge the top-k lists by score. let mut hits: Vec<_> = pages.into_iter().flatten().collect(); hits.sort_by(|a, b| b.score.total_cmp(&a.score)); hits.truncate(10); // ── restricted to what this caller may see ─────────────────────── // On the node: the ids come from your permissions, your tenant, your // agent's domain. Only those documents are visited; the scoring is still // the federation's. let allowed: HashSet<u64> = acl.visible_ids(user); index.search_filtered_with_global_stats(&q, 10, &merged, None, allowed)?;
export_stats serialises what one node knows about this query (document
frequencies, document and token counts): a few kilobytes, not an index.
merge_stats sums them. Each node then compiles its weight from the merged
statistics instead of its own, so a document scores exactly what it would score in one index
holding everything — and the coordinator's merge is a sort, with nothing to renormalise.
search_with_global_stats(..., allowed_ids) — the ids say what is
visited, the federation's statistics say how it scores. A document scores
exactly what it scores for anyone else under the same merged statistics: the filter decides
what is visited, never how a document is scored, so two viewers with different permissions
see the same score on the same document. That is what you want for permissions, tenants, an
agent's domain — scores that do not depend on who is looking. (The statistics
themselves disclose term frequencies, as said above: one trust boundary.)
search_filtered on a single index — scores as if the index were the
subset: document frequencies counted on it, N its size. That is what you
want when the subset is the corpus for that question, and it is a different answer,
not a rounding difference (with a global N and a frequency counted on ten
documents, one hit went from 0.02 to 2.9 — which is why the two modes exist).
The index does not have to live on a filesystem. Give it an object with five methods and your database becomes the truth — the local files become a disposable cache.
class MyStore: # Postgres, S3, anything transactional def load(self, index_name, file_name): ... def save(self, index_name, file_name, data): ... def delete(self, index_name, file_name): ... def exists(self, index_name, file_name): ... def list(self, index_name): ... # optional: blob_len / load_range, which make `lazy=True` worth it index = lucivy.Index.create_with_blob_store(MyStore(), "products", fields=[...], shards=4) index = lucivy.Index.open_with_blob_store(MyStore(), "products", lazy=True)
const { BlobIndex } = require('lucivy');
const store = { // Postgres, S3, anything transactional
load: (indexName, fileName) => buffer,
save: (indexName, fileName, data) => {},
delete: (indexName, fileName) => {},
exists: (indexName, fileName) => true,
list: (indexName) => [names],
// optional: blobLen / loadRange, which make `lazy` worth it
};
// The blob-backed index is asynchronous: its calls cross into your callbacks
const index = await BlobIndex.create(store, 'products',
[{ name: 'title', type: 'text', stored: true }],
{ shards: 4 });
const index = await BlobIndex.open(store, 'products', { lazy: true });
use lucivy_core::blob_store::BlobStore;
use lucivy_core::sharded_handle::{BlobShardStorage, ShardedHandle};
// Your type implements five methods (plus two optional ones for `lazy`)
impl BlobStore for MyStore { /* load, save, delete, exists, list */ }
let storage = BlobShardStorage::new(Arc::new(MyStore), "products", None);
let index = ShardedHandle::create_with_storage(Box::new(storage), &config)?;
// A filesystem index is the same call with the other storage
let storage = lucistore::shard_storage::FsShardStorage::new("/tmp/idx")?;
let index = ShardedHandle::create_with_storage(Box::new(storage), &config)?;
lazy=True and a store that answers blob_len, files are pulled on
first read instead of at open. The store's methods run on the scheduler's threads: they must
be thread-safe, must not call back into the index, and the calling thread must not hold the
GIL or the event loop.
The reason the storage is pluggable: if your save writes inside your
transaction, then committing your rows and committing the index are the same commit — and
rolling back takes the index with it. No dual-write, no reindex queue, no drift between a
row and its searchable form.
class PgStore:
"""Every index file is a row. The connection is the caller's, so the
writes land in whatever transaction is open on it."""
def __init__(self, conn): self.conn = conn
def save(self, index_name, file_name, data):
self.conn.execute(
"INSERT INTO lucivy_files (idx, name, data) VALUES (%s, %s, %s) "
"ON CONFLICT (idx, name) DO UPDATE SET data = EXCLUDED.data",
(index_name, file_name, data))
def load(self, index_name, file_name):
return self.conn.execute("SELECT data FROM lucivy_files WHERE idx=%s AND name=%s",
(index_name, file_name)).fetchone()[0]
def delete(self, index_name, file_name): ...
def exists(self, index_name, file_name): ...
def list(self, index_name): ...
index = lucivy.Index.open_with_blob_store(PgStore(conn), "products")
# ── The pattern ───────────────────────────────────────────────────
with conn.transaction(): # BEGIN
conn.execute("INSERT INTO products (id, title) VALUES (%s, %s)", (id, title))
index.add(id, title=title) # RAM only, nothing written yet
index.commit() # the index's files, through PgStore, INSIDE the transaction
# COMMIT — the row and its searchable form become visible together.
# An exception here rolls both back: no orphan document, no stale index.
index.close() # before the connection goes away — see below
const { BlobIndex } = require('lucivy');
// Every index file is a row; the client is the caller's, so the writes land
// in whatever transaction is open on it.
const store = {
save: (idx, name, data) => client.query(
`INSERT INTO lucivy_files (idx, name, data) VALUES ($1, $2, $3)
ON CONFLICT (idx, name) DO UPDATE SET data = EXCLUDED.data`, [idx, name, data]),
load: (idx, name) => client.query(
'SELECT data FROM lucivy_files WHERE idx=$1 AND name=$2', [idx, name]).rows[0].data,
delete: (idx, name) => { /* ... */ },
exists: (idx, name) => true,
list: (idx) => [],
};
const index = await BlobIndex.open(store, 'products');
// ── The pattern ──────────────────────────────────────────────────
await client.query('BEGIN');
try {
await client.query('INSERT INTO products (id, title) VALUES ($1, $2)', [id, title]);
await index.add(id, { title }); // RAM only, nothing written yet
await index.commit(); // the index's files, through the store, inside the transaction
await client.query('COMMIT'); // row and searchable form become visible together
} catch (e) {
await client.query('ROLLBACK'); // and the index goes back with it
throw e;
}
await index.close(); // before the client goes away — see below
use lucivy_core::blob_store::BlobStore;
use lucivy_core::sharded_handle::{BlobShardStorage, ShardedHandle};
// Every index file is a row. The transaction is the caller's: the store
// holds it, so the writes land wherever it is open.
struct PgStore { tx: Mutex<postgres::Client> }
impl BlobStore for PgStore {
fn save(&self, index_name: &str, file: &str, data: &[u8]) -> io::Result<()> {
self.tx.lock().unwrap().execute(
"INSERT INTO lucivy_files (idx, name, data) VALUES ($1, $2, $3) \
ON CONFLICT (idx, name) DO UPDATE SET data = EXCLUDED.data",
&[&index_name, &file, &data])?;
Ok(())
}
// load, delete, exists, list — and optionally blob_len / load_range for `lazy`
}
let storage = BlobShardStorage::new(Arc::new(PgStore { tx }), "products", None);
let index = ShardedHandle::create_with_storage(Box::new(storage), &config)?;
// ── The pattern ──────────────────────────────────────────────────
let mut tx = client.transaction()?; // BEGIN
tx.execute("INSERT INTO products (id, title) VALUES ($1, $2)", &[&id, &title])?;
index.add_document_json(id, &json!({ "title": title }))?; // RAM only
index.commit()?; // files written through PgStore, inside tx
tx.commit()?; // both become visible together
index.close()?; // before the connection goes away
close() means no further call to your store, ever — pinned by a test,
because a caller freeing a database connection behind an FFI while a background merge still
held it is exactly how rag3weaver's teardown used to segfault. Yours: the isolation level and
the transaction's lifetime — the store's methods run on the scheduler's threads, so they must
be thread-safe, must not call back into the index, and the calling thread must not be holding
the GIL or the event loop.
lazy=True changes only when files are read: on first use rather than at open,
which needs blob_len (and ideally load_range) on your store.
drop_index() removes every file it owns, shard blobs and root objects alike —
also pinned by a test, because "list and delete by prefix" was previously left to callers.
Proved on a real Postgres (acid_postgres.rs) and on every CI run against an
in-memory store (test_acid_blob_v3.rs).
Move an index as one blob, or ship only what changed.
blob = index.export_snapshot() # LUCE: every shard, one blob served = lucivy.Index.open_snapshot(blob) # read-only, nothing extracted # Incremental: the client says what it has, the server sends the difference delta = server.export_sharded_delta(client.shard_versions()) # LUCIDS client.apply_sharded_delta(delta)
const blob = index.exportSnapshot(); // LUCE: every shard, one blob const served = Index.openSnapshot(blob); // read-only, nothing extracted // Incremental: the client says what it has, the server sends the difference const delta = server.exportShardedDelta(client.shardVersions()); // LUCIDS client.applyShardedDelta(delta);
use lucivy_core::snapshot::{export_to_snapshot, import_from_snapshot};
let blob = export_to_snapshot(&index, Path::new("/tmp/idx"))?; // LUCE
let served = ShardedHandle::open_snapshot(OwnedBytes::new(blob))?; // served in place
// Incremental: the client says what it has, the server sends the difference
let delta = server.export_sharded_delta(&client.shard_versions()?)?; // LUCIDS
client.apply_sharded_delta("/tmp/client_idx", &delta)?;
open_snapshot serves it
in place — the blob is mapped and read where it is, nothing is unpacked to disk.
LUCIDS is the incremental form: a client sends the version of each of its shards, and
the server answers with the files of the shards that moved, and only those. Because segments
are immutable and named by content, "what changed" is a set difference over names — no
diffing, no merge conflicts.
The same engine, compiled to WebAssembly, in a Web Worker with real threads — so this one has no Python or Rust form. The demo at the top of this page is this code.
import { Lucivy } from 'lucivy-wasm';
const lucivy = new Lucivy('./lucivy-worker.js'); // pthreads + OPFS
await lucivy.ready;
const index = await lucivy.create('/idx', { fields: [{ name: 'body', type: 'text' }], shards: 4 });
await index.add(1, { body: 'pthread_mutex_lock acquires a mutex' });
await index.commit();
await index.preload(); // hold it in memory, once
await index.search({ type: 'contains', field: 'body', value: 'mutex' }, { highlights: true });
const status = await index.memoryStatus();
// { index_bytes, in_memory, num_docs, warnings, last_search_truncated }
memoryStatus says which of the two is happening rather than
leaving you to guess. Two more bounds protect the tab from a one-letter query over a large
corpus: the highlight sink and the matches resolved per segment are capped, and when a cap
is hit the search says so (last_search_truncated) instead of answering as
if it were complete. Writes are buffered in RAM and flushed at the end — never inside a
handler, because OPFS cannot be written from anywhere.
The other half of a hybrid search: an inverted index for the sparse vectors a model
produces (SPLADE, BGE-M3), with WAND pruning, sharding and the same storage. It indexes
vectors — it does not produce them. Not exposed in the bindings yet: it is a Rust crate,
sparse-vector.
use sparse_vector::{handle::SparseHandle, index::SparseVector};
let index = SparseHandle::create("/tmp/sparse")?;
index.insert(1, &SparseVector { indices: vec![7, 42, 1990], values: vec![0.31, 0.12, 0.08] })?;
index.commit_inner()?;
let hits = index.search(&query_vector, 10); // (id, score), best first
let hits = index.search_filtered(&query_vector, 10, &allowed_ids);
index.compact()?; // merge the segments
93 983 Linux kernel files. sched as a substring matches
9 289 of them: lucivy answers in
11 ms — and all 53 211 highlighted
spans were checked against a byte-by-byte naive scan of
the files on disk, which took 3 766 ms to
answer the same thing. The index is 4.9 GB for 857 MB of text
— 3.0.8 wrote 18 GB for the same files.
A time on its own says nothing: it can be the time to return a wrong answer. So every row below was compared, document by document and byte span by byte span, against a naive scan of the same files — the scan column is how long that reference took. Nine rows, zero mismatches. The panel fails the build if a single count or a single span disagrees.
| query | mode | documents | spans | lucivy | naive scan |
|---|---|---|---|---|---|
mutex_lock | substring | 5 145 | 20 797 | 18 ms | 3 597 ms |
mutex_lock | separators relaxed | 5 825 | 22 817 | 11 ms | 3 829 ms |
spin_lock | substring | 6 569 | 34 667 | 11 ms | 3 796 ms |
sched | whole word | 5 284 | 27 881 | 20 ms | 4 413 ms |
sched | substring | 9 289 | 53 211 | 11 ms | 3 766 ms |
printk | start of token | 4 460 | 24 719 | 13 ms | 4 062 ms |
schdule | fuzzy, 1 edit | 5 196 | 18 825 | 44 ms | 11 777 ms |
regsiter | fuzzy, 2 edits | 34 451 | 265 797 | 778 ms | 12 933 ms |
spin_lock_[a-z]+ | regex | 5 510 | 24 368 | 233 ms | 435 ms |
| index | 93 983 documents, 4 938 MB (shared dictionary; 5.8× the text, 3.0.8: 18 057 MB) | ||||
The two sched rows are the point. 5 284 files contain it as a word;
9 289 contain it at all — the difference is sched_clock,
schedule, sched_domain. A whole-token engine finds the first number
and cannot reach the second. Both are exact.
Run it yourself — it builds the index, runs the panel and the reference scan, and fails on any disagreement:
git clone --depth=1 https://github.com/torvalds/linux /tmp/linux-bench
V3_CORPUS=/tmp/linux-bench V3_SFX_VERSION=4 cargo test --release -p lucivy-core \
--test test_sfx_v3_ground_truth v3_ground_truth_demo -- --ignored --nocapture
The machine, so you can judge the numbers. Intel Core Ultra 7 270K Plus (24 cores), 93 GB RAM, NVMe SSD, Linux 7.2, 5 September 2026, lucivy 4.0.0, four shards, shared dictionary, nothing else running. Times are the search itself; recovering each hit's file for the comparison is the harness's own work and is reported separately. A Jaro-Winkler row runs in the same panel but is timed, not verified — the engine returns one span per candidate window there, so a naive scan is not comparable, and the panel says so rather than implying it was checked. This panel exists because the previous one did not compare anything: it reported "20 hits" on every row, 20 being the result cap, and it hid a bug that lost documents for five releases.
Each engine is configured at its best for substrings, not at its default: Elasticsearch with a
trigram analyzer and a wildcard field, tantivy (upstream, not the fork) with its
NgramTokenizer. On a plain substring all three agree to the document
(mutex_lock 5 145, spin_lock 6 569, sched 9 289).
Where they part — bold equals the truth:
| asked | truth (scan) | lucivy | Elasticsearch | tantivy |
|---|---|---|---|---|
spin_lock, separators relaxed — also spin lock, spin-lock, spinlock | 9 552 | 9 552, 23 ms | 6 577 — not with this analyzer: its trigrams carry the underscore | 6 601 — relaxed is its only mode, the separator never enters its index |
spinlokc, two edits, across the token boundary | 10 034 | 10 034, 148 ms | 3 549 — fuzziness compares whole terms | 6 557 — same |
spin_lock_[a-z]+, a regex | 5 510 | 5 510, 219 ms | 5 440 (wildcard field, 70 short), 480 ms | 0 — the terms are already cut |
de, two characters | 93 009 | 93 009, 7.7 M spans, 561 ms | 0, silently | 0, silently |
retur -ENOMEM, a fuzzy phrase | 14 449 | 14 449, 30 ms | 14 446 (span_near), 24 ms — it does this well | — |
where it matched: mutex_lock, 5 145 documents | 20 797 spans | all 20 797, 15 ms | highlight on the top 200: 179 ms | 5 145 stored texts re-read: 96 ms |
| index, 857 MB of text | — | 4 926 MB (×5.8); 3 335 MB (×3.9) with derived_in_ram | 3 082 MB (×3.6) | 680 MB (×0.8) |
| your index in your transaction | — | yes: pluggable store, one commit, rollback included | no: a server next to your database | no: its own directory, its own commit |
| shards and nodes scoring as one index, as a library | — | yes, asserted by a test | yes, as a cluster | no: one index, one scale of scores |
What the other two do better, in the same report: Elasticsearch answers a plain substring in 3-8 ms to lucivy's 12-15 and is a distributed system; tantivy indexes the corpus in seconds (lucivy: 56 s per-segment, 107 s with the shared dictionary); both run a term-level fuzzy in a fifth of lucivy's time at two edits — on a different question, and every such row says so. tantivy's n-gram tokenizer emits every position as 0, so a trigram phrase there matches nothing: its rows use the honest path, an AND of trigrams then a verification on the stored text, timed as such. Full report, generated by one command: docs/compare-engines-2026-09-05.md.
What this comparison is, and is not. Each engine runs the configuration its own documentation gives for substring search, and every count is judged by the same byte-by-byte scan of the files. Where a cell says “not with this analyzer”, a purpose-built analyzer or plugin may well get closer, at the price of designing it, configuring it and reindexing — such a configuration is welcome in the report. The point of the table is elsewhere: every question in it is answered by lucivy's default index, with nothing to configure — exact, relaxed across separators, fuzzy across token boundaries, regex, two characters, the positions of every match — and each answer is checked against the files.
benches/compare_engines.sh /tmp/linux-bench /tmp/lucivy-compare # Elasticsearch optional, see the header
| native (Rust, mmap) | browser (WASM, index linux) | |
|---|---|---|
| files | 13 806 (the harness skips a few directories) | 14 032, 126 MB of text |
| index | 905 MB on disk | 1 089 MB, held in memory |
| indexing | 23 s | 41 s (a commit every 8 MB of text) |
mutex_lock, separators relaxed | 2 ms | 10-18 ms |
spin_lock / spin_lock_init, strict | 3 ms | 11-48 ms |
fuzzy schdule (d = 1) | 10 ms | 29-33 ms |
fuzzy regsiter (d = 2) | 128 ms | — |
regex spin_lock_[a-z]+ | 52 ms | 113-127 ms |
| 10 000 files of a modern kernel, index on disk | 3.0.8: 2 307 MB → 4.0: 455 MB per segment, 345 MB with the shared dictionary | |
The browser pays two to five times the native engine on the same index; both give the same counts and the same byte spans, and both were checked against a naive scan of the files (0.5 to 1.8 s a query here). On the whole modern kernel, natively (the Numbers above): 11-20 ms for substrings, 44 ms for fuzzy at one edit, 233 ms for the regex. These are substring queries across token boundaries; most full-text engines return nothing for them. How to reproduce: docs/BENCHMARKS.md.
pip install lucivy import lucivy index = lucivy.Index.create("/tmp/idx", fields=[{"name": "body", "type": "text", "stored": True}]) index.add(1, body="pthread_mutex_lock acquires a mutex") index.commit() index.search({"type": "contains", "field": "body", "value": "mutex"}, highlights=True) index.search({"type": "contains", "field": "body", "value": "mutx", "distance": 1}) # fuzzy index.search({"type": "contains", "field": "body", "value": "lock.*mutex", "regex": True})
npm install lucivy
const { Index } = require('lucivy');
const index = Index.create('/tmp/idx', [{ name: 'body', type: 'text', stored: true }]);
index.add(1, { body: 'pthread_mutex_lock acquires a mutex' });
index.commit();
index.search({ type: 'contains', field: 'body', value: 'mutex' }, { highlights: true });
npm install lucivy-wasm import { Lucivy } from 'lucivy-wasm'; const lucivy = new Lucivy('./lucivy-worker.js'); // a Web Worker, pthreads, OPFS await lucivy.ready; const index = await lucivy.create('/idx', { fields: [{ name: 'body', type: 'text' }], shards: 4 }); await index.add(1, { body: 'pthread_mutex_lock acquires a mutex' }); await index.commit(); await index.preload(); // hold the index in memory, once await index.search({ type: 'contains', field: 'body', value: 'mutex' });
cargo add lucivy-core
use lucivy_core::sharded_handle::ShardedHandle;
use lucivy_core::query::{QueryConfig, SchemaConfig};
let config: SchemaConfig = serde_json::from_value(json!({ "fields": [{ "name": "body", "type": "text" }], "shards": 4 }))?;
let index = ShardedHandle::create("/tmp/idx", &config)?;
index.add_document_json(1, &json!({ "body": "pthread_mutex_lock acquires a mutex" }))?;
index.commit()?;
let query: QueryConfig = serde_json::from_value(json!({ "type": "contains", "field": "body", "value": "mutex" }))?;
index.search(&query, 10, None)?;
Prebuilt for Linux x86_64 and aarch64 (glibc ≥ 2.28), macOS x86_64 and arm64, Windows x86_64; everything builds from source elsewhere. C++ through a cxx bridge: bindings/cpp.
query_warnings tells you before you run it.BM25 full-text search — substring, prefix, exact word, phrase, fuzzy (Levenshtein or Jaro-Winkler), regex, boolean query syntax — across word boundaries, with separators relaxed or strict, and exact highlights — running in your browser via WASM
Measured on the whole Linux 2.6.0 kernel (14 032 files) held in memory: substrings answer in 10–20 ms here and 2–4 ms natively, fuzzy in 30 ms, the regex in 120 ms, with identical counts and spans. A browser tab addresses at most 4 GB, which bounds the corpus to about 200 MB of text.
Drop files here or click to select
.txt, .md, .rs, .py, .js, .ts, .go, .java, .c, .cpp, .json, .toml, .yaml, .html, .css, .sh, .sql ...
Also accepts .zip and .tar.gz archives
A good repository to try: postgres/postgres — about 4 400 files, indexed here in about 15 seconds (measured: 13 s, 972 MB in memory). Around 10 000 files of C the index stops fitting in a browser's memory; the page then reloads on the persisted index to serve it, or streams it when even that does not fit. Imported files are indexed in a separate index. Switch between indexes using the tabs above. Everything runs locally in your browser — no data is uploaded. If you hit a rate limit or want to index your private repos, you can use a personal access token. This code runs entirely in your browser — nothing is saved or uploaded.