Compact, immutable string<->id indexes for huge catalogs: ordered FST lookup with prefix/range/fuzzy, plus a minimal-perfect-hash dictionary.
Project description
lexindex
Compact, immutable string↔id indexes for huge catalogs, with a Rust core and Python bindings.
Build once over a set of strings (entity names, document keys, vocabulary terms, cluster labels);
query many times. Pairs naturally with betula-cluster —
map string ids to cluster ids and back — but stands on its own.
Two complementary, build-once / query-many structures:
StringIndex— an ordered index backed by a finite-state transducer (fst). Exactstring → idandid → string, plus prefix, range, fuzzy (bounded Levenshtein edit distance), and subsequence iteration — all driven by automata over the FST, never a full scan — in a compressed, serialisable (and memory-mappable-by-blob) form. Use it for autocomplete, typo-tolerant search, browse, and ordered scans of a large catalog.PerfectHashIndex— a minimal-perfect-hash dictionary backed byptr_hash(themphfeature, on by default). Exactstring → dense idwith verified membership (id) and reverse lookup; no ordering. For a known-closed vocabulary,id_uncheckedskips the membership comparison and is faster thanstd::HashMap(see Benchmarks). Use it as a fixed-vocabulary token↔id map on a hot path.
Both assign dense ids in [0, n), support reverse lookup, and serialise to a flat blob
(save/load) — build once, persist, then load/mmap and query many times. Both are immutable after
building.
Python
pip install lexindex
from lexindex import PerfectHashIndex, StringIndex
idx = StringIndex(["apple", "apricot", "banana", "cherry"])
idx.id("banana") # 2 (sorted rank)
idx.key(0) # "apple"
idx.prefix("ap") # [("apple", 0), ("apricot", 1)]
idx.fuzzy("aple", 1) # [("apple", 0)] — typo-tolerant
idx.save("catalog.bix") # persist; StringIndex.load("catalog.bix") reloads it
d = PerfectHashIndex(["GET", "POST", "PUT", "DELETE"])
d.id("POST") # dense id in [0, n); membership verified, returns None if absent
d.id_unchecked("POST") # fastest lookup for a known-closed vocabulary
No runtime dependencies; a single abi3 wheel covers CPython 3.11+.
Pairs with betula-cluster
lexindex owns the string id ↔ dense id mapping; betula-cluster
clusters the numeric rows. Use the lexindex dense id as the embedding-matrix row index and you can go
both ways — string id → cluster and cluster → string ids:
idx = PerfectHashIndex(doc_ids) # string id <-> dense [0, n) id
matrix[idx.id(doc_id)] = embedding[doc_id] # row index == lexindex id
labels = betula_cluster.fit_predict(matrix, n_clusters=k)
cluster = labels[idx.id("doc-00042")] # string id -> cluster
members = [idx.key(int(r)) for r in (labels == cluster).nonzero()[0]] # cluster -> string ids
Runnable: examples/bridge_clustering.py.
Rust
[dependencies]
lexindex = { git = "https://github.com/ilgrad/lexindex" }
# fst-only (drop the ptr_hash dependency):
# lexindex = { git = "...", default-features = false }
Usage
use lexindex::StringIndex;
let idx = StringIndex::build(["apple", "apricot", "banana", "cherry"])?;
assert_eq!(idx.id("banana"), Some(2)); // string → id (sorted rank)
assert_eq!(idx.key(0).as_deref(), Some("apple")); // id → string
assert!(idx.contains("cherry"));
// prefix / range iteration, lexicographically ordered
let fruit: Vec<_> = idx.prefix("ap").into_iter().map(|(k, _)| k).collect();
assert_eq!(fruit, ["apple", "apricot"]);
// typo-tolerant fuzzy lookup (Levenshtein edit distance ≤ 1) and subsequence match
let near: Vec<_> = idx.fuzzy("aple", 1)?.into_iter().map(|(k, _)| k).collect();
assert_eq!(near, ["apple"]);
let sub: Vec<_> = idx.subsequence("ap").into_iter().map(|(k, _)| k).collect();
assert_eq!(sub, ["apple", "apricot"]);
// serialise to a flat blob, then reload — or `load_mmap` to borrow it zero-copy from the file
idx.save("catalog.bix")?;
let idx = StringIndex::load_mmap("catalog.bix")?; // no read into RAM; pages shared across processes
# Ok::<(), lexindex::IndexError>(())
use lexindex::PerfectHashIndex; // requires the default `mph` feature
let dict = PerfectHashIndex::build(["GET", "POST", "PUT", "DELETE"])?;
let id = dict.id("POST").unwrap(); // fastest exact lookup, dense id in [0, n)
assert_eq!(dict.key(id), Some("POST"));
assert_eq!(dict.id("PATCH"), None); // membership is verified, not just hashed
// persist the MPH and reload it (the dense ids are preserved across save/load)
dict.save("verbs.bmp")?;
let dict = PerfectHashIndex::load("verbs.bmp")?;
assert_eq!(dict.id("POST"), Some(id));
# Ok::<(), lexindex::IndexError>(())
Design notes
StringIndexkeeps the FST (key → id, prefix/range) plus a front-coded string dictionary (id → key). Ids are the sorted rank of each key, so the dictionary stores keys sorted and delta-encodes each against its predecessor in fixed-size buckets — the first key of a bucket is verbatim, the rest are(shared-prefix length, suffix), with one pointer per bucket instead of one 8-byte offset per key. On a structured sorted catalog that collapses the reverse map to well under the raw key bytes; a randomkey(id)decodes up to a bucket of deltas and returns an ownedString. The serialised blob is[magic][fst][front-coded dict];from_bytesvalidates every length and pointer, so loading an untrusted blob can fail but never corrupts.PerfectHashIndexkeys the MPH on a deterministic 64-bit hash of each string (so queries take&strwithout allocating), then verifies the hit against the stored key — an MPH returns a slot for any input, so verification is what turns it into a real membership test. Build fails (rather than silently corrupting) on the astronomically rare 64-bit hash collision between two distinct keys. The hash is version-stable (FNV-1a + a splitmix64 finalizer, notstd'sDefaultHasher), so asaved MPH (theptr_hashstructure serialised viaepserde, alongside the arena) reloads and queries identically on any build — the precondition for persistence.- Zero-copy
load_mmap(the defaultmmapfeature,memmap2) memory-maps a saved blob and borrows the index directly from the mapped pages — no read into RAM, so a multi-gigabyte index is ready instantly and the OS shares its pages across processes.StringIndexmaps the whole thing (FST + front-coded dictionary);PerfectHashIndexmaps the key arena (the bulk) and reads only the tiny MPH into memory. Every read is byte-wise, so there is no alignment gotcha; the one caveat is the usual mmap contract — the file must not be mutated while an index borrows it. mphis opt-in-by-default: with--no-default-featuresthe crate depends only onfst. Enablingmphpullsptr_hashand its dependency tree, which currently carries a few informational RustSec advisories (unmaintained / unsound) on transitive crates —cargo auditreports them as warnings, not vulnerabilities. Thefst-only build is free of them.
Benchmarks
cargo run --release --example bench (1 M keys, 19 bytes each). Absolute numbers are
machine-dependent; the ratios and the trade-off are the point.
| structure | build | lookup | serialised |
|---|---|---|---|
lexindex PerfectHashIndex::id_unchecked |
~310 ms | ~232 ns | 27 B/key |
std::HashMap<String, u32> |
~205 ms | ~290 ns | — (in-RAM, not serialisable) |
lexindex PerfectHashIndex::id (verified) |
~376 ms | ~377 ns | 27 B/key |
lexindex StringIndex (FST) |
~138 ms | ~386 ns | 6 B/key |
std::BTreeMap<String, u32> |
~39 ms | ~833 ns | — (in-RAM) |
Serialised size tells the other half of the story: StringIndex's front-coded reverse map compresses
this structured sorted catalog to ~6 B/key — below the 19 B of raw key bytes, and ~4× smaller than
the PerfectHashIndex blob (whose slot-ordered arena cannot share prefixes). Reach for StringIndex
when the on-disk / mmap footprint matters, PerfectHashIndex when raw lookup speed does.
Honest reading: for a fixed / closed vocabulary, PerfectHashIndex::id_unchecked is the
fastest — ≈1.25× quicker than HashMap (no probing, no membership comparison) and compact +
serialisable. Add membership verification (id) and you pay one extra cache line + a key comparison;
use StringIndex and you trade more latency for ordered / prefix / range / fuzzy queries the hash
maps cannot answer at all. So: id_unchecked for a known-closed token→id map on a hot path;
StringIndex when order or fuzzy/prefix matters; HashMap when you just need a general in-RAM map with
membership and nothing persisted.
License
MIT © Ilia Gradina
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lexindex-0.2.0.tar.gz.
File metadata
- Download URL: lexindex-0.2.0.tar.gz
- Upload date:
- Size: 48.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c989962adafb0091c3802e8434de91ff0117446964860759b4edf592ac2021c0
|
|
| MD5 |
7eda88d839b801f212e274bf1bade549
|
|
| BLAKE2b-256 |
78d9748d3876b4a79f75691f99f82aacabe95ca46d84659794fc307f7d901dfc
|
Provenance
The following attestation bundles were made for lexindex-0.2.0.tar.gz:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0.tar.gz -
Subject digest:
c989962adafb0091c3802e8434de91ff0117446964860759b4edf592ac2021c0 - Sigstore transparency entry: 2078560627
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file lexindex-0.2.0-cp311-abi3-win_amd64.whl.
File metadata
- Download URL: lexindex-0.2.0-cp311-abi3-win_amd64.whl
- Upload date:
- Size: 408.1 kB
- Tags: CPython 3.11+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e881f5ff310e6d48856acb7023c7292f2ae42bc24fb95767fa9fc4e9dee384b1
|
|
| MD5 |
63b2c29db014c4b458216bbfbd72535d
|
|
| BLAKE2b-256 |
b9d11ea57e4ece9e660583a15e3d7a6661886a73a514c957020c492bd541b3eb
|
Provenance
The following attestation bundles were made for lexindex-0.2.0-cp311-abi3-win_amd64.whl:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0-cp311-abi3-win_amd64.whl -
Subject digest:
e881f5ff310e6d48856acb7023c7292f2ae42bc24fb95767fa9fc4e9dee384b1 - Sigstore transparency entry: 2078561432
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 532.5 kB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3fd1eaf71514225bc67a0670cf9100e85eb1e614d2b3afc0def9584fef420e83
|
|
| MD5 |
82f7fde2dcc10b1c2edd7a58b08f99a9
|
|
| BLAKE2b-256 |
ebdaec2b16702e8b2e4965ad094777a5300a56b8207dc1e5f47eeef58079e624
|
Provenance
The following attestation bundles were made for lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
3fd1eaf71514225bc67a0670cf9100e85eb1e614d2b3afc0def9584fef420e83 - Sigstore transparency entry: 2078561115
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 520.4 kB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2b46701b0cad140f88561eed27862e286e0630cc8854a1d6bd4175729e996192
|
|
| MD5 |
09082b693e39c305e508d31d4568e7af
|
|
| BLAKE2b-256 |
0594d3e385c1d2cc2c46b11a9891b9443124dcdd8a03b0f8707bdaeb358290de
|
Provenance
The following attestation bundles were made for lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl -
Subject digest:
2b46701b0cad140f88561eed27862e286e0630cc8854a1d6bd4175729e996192 - Sigstore transparency entry: 2078560762
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 482.4 kB
- Tags: CPython 3.11+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5aab006b41bdba1449649928b86526576853abbb2290782a05dd100b27270469
|
|
| MD5 |
432ab152b078fa1ede1c624839766c56
|
|
| BLAKE2b-256 |
a27fe2c912f9383741c609a6fada77f6fb513a1733a44ed516a7f95e821447f4
|
Provenance
The following attestation bundles were made for lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl -
Subject digest:
5aab006b41bdba1449649928b86526576853abbb2290782a05dd100b27270469 - Sigstore transparency entry: 2078560976
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 497.0 kB
- Tags: CPython 3.11+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9a2ada73632e9aa8c60da78f35244cc720e4c2276082495229c3a86b8cad2a8f
|
|
| MD5 |
d7cf0fc14cd4b0dc76f368657741251e
|
|
| BLAKE2b-256 |
4b873ebd8faf09506f8301b06d59810561320fe159fdb3a0674079ad2510325e
|
Provenance
The following attestation bundles were made for lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl:
Publisher:
release.yml on ilgrad/lexindex
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl -
Subject digest:
9a2ada73632e9aa8c60da78f35244cc720e4c2276082495229c3a86b8cad2a8f - Sigstore transparency entry: 2078561293
- Sigstore integration time:
-
Permalink:
ilgrad/lexindex@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/ilgrad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4f8af661b0991f881e859be6be5f2bae6f60f4cc -
Trigger Event:
push
-
Statement type: