Skip to main content

lexindex

PyPI Python CI Docs License: MIT Rust core · PyO3

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.

Three complementary, build-once / query-many structures — pick by what you need to ask:

  • StringIndex — an ordered index backed by a finite-state transducer (fst). Exact string → id and id → string, plus prefix, range, predecessor / successor (nearest key ≤ / ≥ a query), fuzzy (bounded Levenshtein edit distance), subsequence, and lazy full iteration — all driven by automata over the FST, never a full scan — in a compressed, serialisable, memory-mappable form. The only structure here that answers ordered and typo-tolerant queries. Use it for autocomplete, fuzzy search, browse, and ordered scans of a large catalog.
  • CompactHashIndex — the smallest string → dense id map: a minimal perfect hash (ptr_hash) plus a small fingerprint per key, storing no keys at all. 1.30 bytes/key on real dictionary words — 2.3× smaller than marisa-trie and below every trie benchmarked (see Benchmarks) — at the cost of probabilistic membership (a tunable 256^-k false-positive rate) and no reverse lookup. Use it when a fixed vocabulary's footprint is paramount and rare false positives are acceptable.
  • PerfectHashIndex — a minimal-perfect-hash dictionary with verified membership (id) and reverse lookup (key); the arena stores full keys, so it is exact but larger. For a known-closed vocabulary, id_unchecked skips the membership comparison and is faster than std::HashMap. Use it as a fixed-vocabulary token↔id map on a hot path when you need exact membership and id → key.

All three assign dense ids in [0, n) and serialise to a flat blob (save / load, or zero-copy load_mmap) — build once, persist, then reload and query many times. All are immutable after building. The mph feature (on by default) provides the two hash indexes; --no-default-features is fst-only.

Python

pip install lexindex
from lexindex import CompactHashIndex, PerfectHashIndex, StringIndex

idx = StringIndex(["apple", "apricot", "banana", "cherry"])
idx.id("banana")             # 2  (sorted rank)
idx.key(0)                   # "apple"  — reconstructed from the FST, no stored reverse map
idx.prefix("ap")             # [("apple", 0), ("apricot", 1)]
idx.fuzzy("aple", 1)         # [("apple", 0)]  — typo-tolerant
idx.successor("ba")          # ("banana", 2)   — nearest key >= query
idx.predecessor("ba")        # ("apricot", 1)  — nearest key <= query
list(idx)                    # [("apple", 0), ...]  — lazy iteration in sorted order
idx.ids_of(["apple", "x"])   # [0, None]  — batched: one FFI call, not one per key
idx.save("catalog.bix")      # persist; StringIndex.load("catalog.bix") reloads it

c = CompactHashIndex(["GET", "POST", "PUT", "DELETE"])  # smallest string->id (~1.3 B/key at scale)
c.id("POST")                 # dense id in [0, n); probabilistic membership, no id->key
c.id_unchecked("POST")       # fastest lookup for a known-closed vocabulary

d = PerfectHashIndex(["GET", "POST", "PUT", "DELETE"])
d.id("POST")                 # dense id in [0, n); membership verified, returns None if absent
d.key(d.id("POST"))          # "POST"  — exact reverse lookup (keys stored)

No runtime dependencies; a single abi3 wheel covers CPython 3.11+. See examples/quickstart.py for all three indexes end to end, and the documentation site.

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>(())
use lexindex::CompactHashIndex;           // requires the default `mph` feature

// The smallest string->id map: 1 fingerprint byte/key ⇒ ~1.3 B/key, ~0.4% membership false-positive.
let dict = CompactHashIndex::build(["GET", "POST", "PUT", "DELETE"], 1)?;
let id = dict.id("POST").unwrap();             // Some(slot); a non-member may rarely read as present
assert!(dict.contains("GET"));
let raw = dict.id_unchecked("POST");           // no fingerprint check — for a known-closed vocabulary
assert_eq!(raw, id);
// no key(id): CompactHashIndex stores no keys. Use PerfectHashIndex when you need id → string.
# Ok::<(), lexindex::IndexError>(())

Design notes

  • StringIndex is the FST alone — id → key is reconstructed by a rank-walk, with no stored reverse map. Ids are the sorted rank of each key, which is exactly the FST's output value, so key(id) walks the automaton from the root, at each node taking the last transition whose accumulated output stays ≤ id, and returns the path once the outputs sum to exactly id. That is O(key length) and needs no auxiliary structure, so the serialised blob is just [magic "BIX4"][fst] — half the size of the 0.2.0 front-coded layout on real words (12.6 → 5.95 B/key) and simpler to reason about. from_bytes validates the magic and hands the rest to fst, which is itself bounds-checked, so loading an untrusted blob can fail but never corrupts.
  • CompactHashIndex stores no keys — only a minimal perfect hash and one small fingerprint per slot. id(key) hashes the key to a slot (the MPH), then compares the key's independent k-byte fingerprint against the stored one; a match is a hit. Because the two hashes are independent, a non-member survives both only with probability 256^-k, the tunable false-positive rate. Dropping the key arena is what takes it below marisa-trie; the price is that membership is probabilistic and there is no id → key. The blob is [magic "BCH1"][n][fp_bytes][mph][fingerprints].
  • PerfectHashIndex keys the MPH on a deterministic 64-bit hash of each string (so queries take &str without 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, and the stored keys give exact id → key. 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, not std's DefaultHasher), so a saved MPH (the ptr_hash structure serialised via epserde, alongside the arena) reloads and queries identically on any build — the precondition for persistence. CompactHashIndex shares the same version-stable slot hash plus a second independent one for the fingerprint.
  • Zero-copy load_mmap (the default mmap feature, 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. StringIndex maps the whole FST; CompactHashIndex maps its fingerprint table; PerfectHashIndex maps 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.
  • mph is opt-in-by-default: with --no-default-features the crate depends only on fst (and keeps StringIndex). Enabling mph pulls ptr_hash and its dependency tree, which currently carries a few informational RustSec advisories (unmaintained / unsound) on transitive crates — cargo audit reports them as warnings, not vulnerabilities. The fst-only build is free of them.

Benchmarks

Serialised size on real English words

python bench/compare.py on /usr/share/dict/words (479 823 words, 9.3 B/key raw). Keys are a real vocabulary, never a synthetic entity-{i} sequence — sequential keys collapse the FST to a near-regular automaton and report a misleading ~0 B/key, so the benchmark refuses them. Smaller is better; the capability columns are why you would still pick a larger one.

library prefix range fuzzy reverse id→str exact membership zero-copy mmap bytes/key
lexindex CompactHashIndex (fp=1) probabilistic 1.30
lexindex CompactHashIndex (fp=2) probabilistic 2.30
marisa-trie 2.98
lexindex StringIndex 5.95
lexindex PerfectHashIndex 13.63
DAWG (dawg2) 23.96
datrie 30.69

Two honest crowns. CompactHashIndex is the smallest string → dense id map — 2.3× below marisa-trie — when you can accept a bounded false-positive rate (256^-k: ≈0.4 % at 1 byte, ≈0.0015 % at 2, which the benchmark confirms) and don't need id → key. StringIndex is the only structure that answers fuzzy and range queries at all, at 4× below a plain DAWG. marisa-trie remains the pick when you need exact membership and ordering and the smallest such index — lexindex doesn't claim that particular cell (see below for why).

Against other Rust string indexes

marisa-trie is C++. Among ordered string indexes you can cargo add, none is smaller than StringIndex — the double-array tries trade space for lookup speed, and no succinct LOUDS trie (marisa / XCDAT / CoCo-trie-style) exists in Rust to depend on. So StringIndex at 5.95 B/key is the smallest ordered string → id index available in pure Rust — second only to a C++ library, and the only one of them that does fuzzy and range. Same real words:

Rust structure bytes/key vs marisa
marisa-trie (C++, reference) 2.98 1.0×
lexindex StringIndex (ordered + fuzzy + reverse) 5.95 2.0×
fst::Set (membership only — no ids, no reverse) 4.85 1.6×
yada (double-array) 15.98 5.4×
crawdad::MpTrie (minimal-prefix) 19.63 6.6×
crawdad::Trie (double-array) 26.22 8.8×

Measured with crawdad 0.4, yada 0.5, fst 0.4 over the same word list; size = serialised bytes (serialize_to_vec().len()) ÷ key count. Not lexindex dependencies — reproduce in a throwaway crate.

Reaching marisa's 2.98 needs its recursive succinct-trie label nesting, which the byte-oriented fst automaton is ~1.6× away from by construction (even a bare fst::Set, which stores no ids at all, is 4.85) — so beating it on the ordered index means reimplementing marisa from scratch, not a bounded tweak. CompactHashIndex takes the size crown the other way: by dropping the keys entirely.

Point-lookup latency vs the standard library

cargo run --release --example bench (1 M keys). Absolute numbers are machine-dependent; the ratios are the point.

structure build lookup note
lexindex PerfectHashIndex::id_unchecked ~156 ms ~232 ns closed vocabulary, no membership check
std::HashMap<String, u32> ~205 ms ~290 ns in-RAM, not serialisable
lexindex PerfectHashIndex::id (verified) ~190 ms ~373 ns one extra cache line + key compare
lexindex StringIndex (FST) ~80 ms ~386 ns and prefix / range / fuzzy
std::BTreeMap<String, u32> ~39 ms ~833 ns in-RAM

The lexindex build column, and PerfectHashIndex::id's lookup, were re-measured on 0.5.0 (min of 12 runs on an idle machine): the build no longer copies the corpus to sort it, and id is the one lookup that reads the key arena, whose offsets 0.5.0 narrowed. The std rows and the other lookup figures are the earlier measurement — those paths did not change, and this machine measures the std maps slower than the earlier one did (BTreeMap build 39 → 55 ms), so carrying the old numbers forward keeps the comparison conservative against lexindex rather than flattering it.

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. CompactHashIndex shares that same MPH lookup while storing 10× less. 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: CompactHashIndex when footprint dominates and a rare false positive is fine; PerfectHashIndex::id for exact membership + reverse; StringIndex when order or fuzzy/prefix matters; HashMap when you just need a general in-RAM map with nothing persisted.

Scaling to millions of keys

python bench/scale.py on real high-entropy keys (dictionary-word bigrams). Build time and memory grow linearly, lookups stay sub-microsecond, and CompactHashIndex's 1.30 bytes/key holds constant as n grows:

n structure build bytes/key peak RSS lookup
1 M CompactHashIndex 0.28 s 1.30 162 MB 232 ns
10 M CompactHashIndex 4.4 s 1.30 1.35 GB 397 ns
10 M StringIndex 4.7 s 2.00* 1.08 GB 797 ns

* bigram keys share more prefixes than single words, so StringIndex compresses below its 5.95 B/key on the raw dictionary — the honest single-word figure is in the size table above. Peak RSS includes the input key list, which dominates at this scale and is why the column falls by 8-17% rather than by the 47-73% the build itself dropped in 0.5.0. Linear extrapolation puts 100 M at ~50 s and ~13.5 GB (a big-memory box).

License

MIT © Ilia Gradina

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lexindex-0.5.0.tar.gz (82.7 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

lexindex-0.5.0-cp311-abi3-win_amd64.whl (441.3 kB view details)

Uploaded CPython 3.11+Windows x86-64

lexindex-0.5.0-cp311-abi3-musllinux_1_2_x86_64.whl (770.8 kB view details)

Uploaded CPython 3.11+musllinux: musl 1.2+ x86-64

lexindex-0.5.0-cp311-abi3-musllinux_1_2_aarch64.whl (727.0 kB view details)

Uploaded CPython 3.11+musllinux: musl 1.2+ ARM64

lexindex-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (559.2 kB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ x86-64

lexindex-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (550.1 kB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ ARM64

lexindex-0.5.0-cp311-abi3-macosx_11_0_arm64.whl (509.6 kB view details)

Uploaded CPython 3.11+macOS 11.0+ ARM64

lexindex-0.5.0-cp311-abi3-macosx_10_12_x86_64.whl (526.1 kB view details)

Uploaded CPython 3.11+macOS 10.12+ x86-64

File details

Details for the file lexindex-0.5.0.tar.gz.

File metadata

  • Download URL: lexindex-0.5.0.tar.gz
  • Upload date:
  • Size: 82.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for lexindex-0.5.0.tar.gz
Algorithm Hash digest
SHA256 045ae741805dc65680f15b4e83dcba447ec0189570f6cc3880cbac7765dc2180
MD5 9aee07f333f537e19028395d5c34af57
BLAKE2b-256 ed620eee2372f314fab537fa1260910c0bc7b7668753829c80b26a6727bf3748

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0.tar.gz:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-win_amd64.whl.

File metadata

  • Download URL: lexindex-0.5.0-cp311-abi3-win_amd64.whl
  • Upload date:
  • Size: 441.3 kB
  • Tags: CPython 3.11+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 d8e17c590629545ed871795547eb132807c385f2d147384c6b66adac56c10028
MD5 66f80c17d07e726fca4f793954b30ac6
BLAKE2b-256 cf93ecc388a7a9f4e51de400e967798523f7115003ca7b95df35f1e3dec3d89d

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-win_amd64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-musllinux_1_2_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-musllinux_1_2_x86_64.whl
Algorithm Hash digest
SHA256 6d690e699e2c0adc23ed4aa8d2d94f12e1f7ffc6304917c3a21bde27191edac6
MD5 b7ae5202e396bacda0a70f62baf2b184
BLAKE2b-256 2f3b24997591c0282e656c2508a0e947f30bae1ebab325919d1acfe0ca203d07

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-musllinux_1_2_x86_64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-musllinux_1_2_aarch64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-musllinux_1_2_aarch64.whl
Algorithm Hash digest
SHA256 a25c2205a9806badbe45ca9cf8a20a3877009e5132a313e5755baa86715b7e2e
MD5 d14a1209ab2ad442a1d78833b4ccfe4a
BLAKE2b-256 dd6a18f601ecfd56b280bbb04f4d117d47b0546c4cbbd3a5fa4ef597ffb5e14e

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-musllinux_1_2_aarch64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 1ad51072fbc85edaba01ebee0064198e1958b8ffc908f384bf870f4e65025e7a
MD5 36e69c4166105c847fd35a895d5fbe57
BLAKE2b-256 e0a1350d0984ecf03abe974f4407e03327eb0ca736af2f8f843c9621ea5a37bb

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 4b34c085a4f7ff22c279c0a2edf190e94d2e1bebcb19fc8aea6838126dc3b72e
MD5 3ff886d3b148c415a47d237e9fb1825f
BLAKE2b-256 5efd383faf1f390c901443b6f9721ddfebb5bcb6c99b1987b726d9d7a6edf822

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 6d29446123e06d8e6c5fe5510dabf42b08fc76ab06faa7c44213f97c078aae33
MD5 cf277cd3c55ff3b33b53d5ecbcb8ed1a
BLAKE2b-256 2df84277afc7514d687a90f9924e35138560aba33d0ee689cab8f75ae2b0711d

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file lexindex-0.5.0-cp311-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.5.0-cp311-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 24b4c21dc0f6c8a5977265975f10e20ce453bd8373d799cc85c41d13ab695ce2
MD5 fd06f96b81e7c5c8f706c6af49c9cd6c
BLAKE2b-256 af2497ad26f32b33f6eacbbc87c9ecff02f63ecd1e232764b614d3b816e33273

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.5.0-cp311-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on ilgrad/lexindex

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.9.0

8 files

0.8.1

8 files

0.8.0

8 files

0.7.0

8 files

0.6.0

8 files

0.5.1

8 files

This release

0.5.0 This release

8 files

0.4.0

8 files

0.3.0

6 files

0.2.0

6 files

0.1.0

6 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page