lucivy GitHub npm PyPI crates.io blog

Search code the way you grep it.

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.

lucivy — demo

What you just saw and why it is hard

Substrings across tokens

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.

Fuzzy, verified on the text

Two typos in a nine-word phrase still find it. At distance d, enough trigrams of the query must appear exactly; the candidates come from the FST, then Levenshtein (or Jaro-Winkler above a similarity) decides on the real text. No full scan, and the score carries the verified edit distance.

Regex driven by its literals

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.

Every answer checked

The ground-truth harness compares each query's documents and byte spans to a byte-by-byte scan of the files: 93 983 kernel files, nine query modes, zero mismatches, and the build fails on one. The same scan judges Elasticsearch and tantivy on the same corpus (below): where they answer zero in silence, the row says so.

In your transaction

An index is immutable files plus one metadata object written last, so it maps onto a database table or an object store: the commit of your rows and the commit of the index are the same commit, and a rollback takes the index with it. Five store methods to implement (bring your own storage, one transaction); rag3db does it over Postgres.

Sharded and federated, same scores

BM25 statistics are aggregated before scoring, so N shards give the scores of one index; two independent nodes exchange their statistics and score as one corpus (distributed search) — as a library, in-process, in the browser too. Asserted by a test: union equal, scores equal.

How it works

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.

Using it nine ways, each with its own machinery

Query Filter Shard Distribute Bring your own storage One transaction Snapshot & sync Browser Sparse vectors
Show me in C++ has the same API through a cxx bridge.

Query 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);
How it works Every text query becomes a substring query on the suffix FST. 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.

Filter it

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"}]
}))?;
How it works 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.

Shard it

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()?;
How it works A shard is a complete, autonomous index — its own 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.

Distribute it

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)?;
How it works BM25 asks how rare a term is in the whole corpus, and no machine knows that alone — which is why naively merging two nodes' results ranks them wrong. 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.

Two round trips of JSON, no data movement. Nothing is copied, nothing is mounted, and a node never sees another's documents — sharing statistics discloses term frequencies, so it belongs inside one trust boundary and not between two. Since 3.0.6 this call takes the same DAG as a local search: shards in parallel, top-k bounded per shard, and batching when the index does not fit in memory.

The filter and the statistics answer two different questions, and lucivy keeps them apart on purpose:
• 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).

Bring your own storage

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)?;
How it works Segments are immutable files, so they map onto a blob store without a translation layer: writes are whole objects, reads are whole objects or byte ranges. A commit writes the new segments, then the metadata last — the object that makes them part of the index — so a crash leaves the previous state and never half of the new one. With 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.

One transaction for your data and its index

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
How it works A lucivy index is a set of immutable files plus one metadata object, so it maps onto rows or objects without a translation layer. A commit writes the new segment files first and the metadata last — the object that makes them part of the index — so a half-written commit is never a visible state, whatever the storage. Put those writes in your transaction and the property you already trust for your rows now covers their index.

What lucivy guarantees, and what is yours. Ours: metadata last, no partial visibility, and 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).

Snapshot and sync

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)?;
How it works LUCE is a container of every shard's files; 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.

In the browser JavaScript only

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 }
How it works A tab addresses 4 GB, and that is the only hard bound. Under the residency limit the index is held whole and a search is milliseconds; above it, the shards stream through a memory budget batch by batch — the same hits, read from storage, and seconds instead of milliseconds. 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.

Sparse vectors Rust only

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
How it works The score is a plain dot product, so it is local to a shard: merging results is a sort, and two indexes built on different corpora are directly comparable — no global statistics, nothing to recompute. WAND skips the documents that cannot reach the current top-k, using a ceiling stored with each posting. An index is a list of immutable segments: a commit writes one holding what is new, so it costs the delta and not the index (35 ms instead of 320 at 200 000 vectors), deletions are tombstones, and a merge walks the segments' dimension tables together. Those tables are keyed by the global token id and sorted, which is what makes merging two segments — and therefore two indexes — a walk with nothing remapped.

Numbers 93 983 Linux kernel files, version 4.0 — and every answer checked against the files on disk

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.

querymodedocumentsspanslucivynaive scan
mutex_locksubstring5 14520 79718 ms3 597 ms
mutex_lockseparators relaxed5 82522 81711 ms3 829 ms
spin_locksubstring6 56934 66711 ms3 796 ms
schedwhole word5 28427 88120 ms4 413 ms
schedsubstring9 28953 21111 ms3 766 ms
printkstart of token4 46024 71913 ms4 062 ms
schdulefuzzy, 1 edit5 19618 82544 ms11 777 ms
regsiterfuzzy, 2 edits34 451265 797778 ms12 933 ms
spin_lock_[a-z]+regex5 51024 368233 ms435 ms
index93 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.

One corpus, one truth lucivy, Elasticsearch 8.19 and tantivy 0.25 on the same 93 983 files, judged by the same scan

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:

askedtruth (scan)lucivyElasticsearchtantivy
spin_lock, separators relaxed — also spin lock, spin-lock, spinlock9 5529 552, 23 ms6 577 — not with this analyzer: its trigrams carry the underscore6 601 — relaxed is its only mode, the separator never enters its index
spinlokc, two edits, across the token boundary10 03410 034, 148 ms3 549 — fuzziness compares whole terms6 557 — same
spin_lock_[a-z]+, a regex5 5105 510, 219 ms5 440 (wildcard field, 70 short), 480 ms0 — the terms are already cut
de, two characters93 00993 009, 7.7 M spans, 561 ms0, silently0, silently
retur -ENOMEM, a fuzzy phrase14 44914 449, 30 ms14 446 (span_near), 24 ms — it does this well—
where it matched: mutex_lock, 5 145 documents20 797 spansall 20 797, 15 mshighlight on the top 200: 179 ms5 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_ram3 082 MB (×3.6)680 MB (×0.8)
your index in your transaction—yes: pluggable store, one commit, rollback includedno: a server next to your databaseno: its own directory, its own commit
shards and nodes scoring as one index, as a library—yes, asserted by a testyes, as a clusterno: 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

Browser against native the whole Linux 2.6.0 kernel, 4 shards, shared dictionary, the same engine and queries on both sides — the native run verified against a scan of its files, the browser column the tab's own timings

native (Rust, mmap)browser (WASM, index linux)
files13 806 (the harness skips a few directories)14 032, 126 MB of text
index905 MB on disk1 089 MB, held in memory
indexing23 s41 s (a commit every 8 MB of text)
mutex_lock, separators relaxed2 ms10-18 ms
spin_lock / spin_lock_init, strict3 ms11-48 ms
fuzzy schdule (d = 1)10 ms29-33 ms
fuzzy regsiter (d = 2)128 ms—
regex spin_lock_[a-z]+52 ms113-127 ms
10 000 files of a modern kernel, index on disk3.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.

Install

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.

Honest limits

  • Prebuilt binaries cover Linux x86_64/aarch64, macOS x86_64/arm64 and Windows x86_64 (the Python wheel, the npm package); Alpine (musl), FreeBSD and 32-bit build from source, with a Rust toolchain.
  • In the browser a tab addresses 4 GB, and indexing itself takes 1.5 GB of it: about 200 MB of text is the ceiling — the whole Linux 2.6.0 kernel (14 032 files, 126 MB) indexes in 28 s and holds 1.1 GB; 15 000 files of a modern kernel (214 MB) take a minute and 1.6 GB; the terminal refuses a repository beyond that rather than let the tab die.
  • Fuzzy at distance 2 costs 5-10× distance 1; a regex with no usable literal is a scan — query_warnings tells you before you run it.
  • One maintainer. The storage layer (segments, postings, doc store, fast fields) derives from tantivy 0.22; everything above it — the SFX engine, the query system, sharding, snapshots, the actor runtime, the bindings, the browser build — is new.
GitHub · PyPI · npm · crates.io · MIT
← lucivy

lucivy playground

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.

Import your own files

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.

Loading WASM + dataset...
Logs

  
GitHub · PyPI · npm · crates.io