Skip to main content

Compact, immutable string<->id indexes for huge catalogs: an ordered FST with prefix/range/fuzzy, plus minimal-perfect-hash dictionaries (the compact one is ~1.3 B/key).

Project description

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, fuzzy (bounded Levenshtein edit distance), and subsequence 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.save("catalog.bix")      # persist; StringIndex.load("catalog.bix") reloads it

c = CompactHashIndex(["GET", "POST", "PUT", "DELETE"])  # smallest string->id (~1.3 B/key)
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+.

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 → 6.0 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 17.62
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 (measured 0.36 % at 1 byte, 0.001 % at 2, matching the 256^-k theory) and don't need id → key. StringIndex is the only structure that answers fuzzy and range queries at all, at 5× 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.

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 ~310 ms ~232 ns closed vocabulary, no membership check
std::HashMap<String, u32> ~205 ms ~290 ns in-RAM, not serialisable
lexindex PerfectHashIndex::id (verified) ~376 ms ~377 ns one extra cache line + key compare
lexindex StringIndex (FST) ~138 ms ~386 ns and prefix / range / fuzzy
std::BTreeMap<String, u32> ~39 ms ~833 ns in-RAM

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 13× 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.

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

lexindex-0.3.0.tar.gz (61.0 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.3.0-cp311-abi3-win_amd64.whl (416.0 kB view details)

Uploaded CPython 3.11+Windows x86-64

lexindex-0.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (539.5 kB view details)

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

lexindex-0.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (529.1 kB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ ARM64

lexindex-0.3.0-cp311-abi3-macosx_11_0_arm64.whl (489.4 kB view details)

Uploaded CPython 3.11+macOS 11.0+ ARM64

lexindex-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl (504.8 kB view details)

Uploaded CPython 3.11+macOS 10.12+ x86-64

File details

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

File metadata

  • Download URL: lexindex-0.3.0.tar.gz
  • Upload date:
  • Size: 61.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for lexindex-0.3.0.tar.gz
Algorithm Hash digest
SHA256 d4834a3146ebc2fc7d067c3f70b848785609609a244506eb4b8d80a92384d377
MD5 cfb4ef0a531d805ac3750f38088469f5
BLAKE2b-256 3fb16754f2839101544ca00281454ecee231513ff391cde05c2bcd597dbbb3fc

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.3.0-cp311-abi3-win_amd64.whl.

File metadata

  • Download URL: lexindex-0.3.0-cp311-abi3-win_amd64.whl
  • Upload date:
  • Size: 416.0 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

Hashes for lexindex-0.3.0-cp311-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 4061b807a3867e8a538309b0fbb019d2240f1f581f299dada6b634057bd831cb
MD5 468b309521e501fd57df8a7984db2076
BLAKE2b-256 880ff56c6f51983ab6899602453252d4be2a1a28980a8b698d25a1c06d8e7992

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 ec778a393b61dd429d9f83183ac54fc0bc8887af45a9c2e8afb6fcc4632f3c28
MD5 bd9d4ce886dc6b3871ea810498966120
BLAKE2b-256 ed47e3614856c2a79916150756090b346ab5afe41755c9fae572b23b1ea4a5fe

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for lexindex-0.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 a7b591407917f7e1ec8a36099627e7978ef701279f357443ddad85a07cca2de4
MD5 45de252865a34a2f2c80cccbf10d84fd
BLAKE2b-256 3f2fd792dc0dbabc467ae88837ad8618b6af82b65044573d3136d54d526edb53

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.3.0-cp311-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for lexindex-0.3.0-cp311-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 2451aaf6e83cc789072ba40069725262380758a9b7692cfa776dd5cd7ae9c33a
MD5 5e2ce718d71cc745d1cd46254ed476f2
BLAKE2b-256 1106d7da4d78fd0be34d264a14b9ac11b099385494b5efe5e33dc496d89af9a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.3.0-cp311-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.3.0-cp311-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 67085e25a47b7a8f88b286409e3b032a5f8e5734d0b5fbc73d4817dfbc8c4406
MD5 7fbdcb19c4bcfbc8384903f901998791
BLAKE2b-256 4f1131a6c1ab54c5da27709ac4d19519afae6fcdde3c1381d1de21a39c062ed5

See more details on using hashes here.

Provenance

The following attestation bundles were made for lexindex-0.3.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.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page