Skip to main content

Compact, immutable string<->id indexes for huge catalogs: ordered FST lookup with prefix/range/fuzzy, plus a minimal-perfect-hash dictionary.

Project description

lexindex

PyPI Python CI 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.

Two complementary, build-once / query-many structures:

  • 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 (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 by ptr_hash (the mph feature, on by default). Exact string → dense id with verified membership (id) and reverse lookup; no ordering. For a known-closed vocabulary, id_unchecked skips the membership comparison and is faster than std::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), 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 and reload (e.g. mmap the file, then `from_bytes`)
idx.save("catalog.bix")?;
let idx = StringIndex::load("catalog.bix")?;
# 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

  • StringIndex keeps the FST (key → id, prefix/range) plus a string arena (id → key: one contiguous byte buffer + offsets, no per-String overhead). Ids are the sorted rank of each key, so they are stable for the same key set. The serialised blob is [magic][fst][arena]; from_bytes validates every length and offset, so loading an untrusted blob can fail but never corrupts.
  • 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. 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.
  • mph is opt-in-by-default: with --no-default-features the crate depends only on fst. 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

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 27 B/key
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. 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

lexindex-0.1.0.tar.gz (34.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.1.0-cp311-abi3-win_amd64.whl (395.9 kB view details)

Uploaded CPython 3.11+Windows x86-64

lexindex-0.1.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (520.0 kB view details)

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

lexindex-0.1.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (507.7 kB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ ARM64

lexindex-0.1.0-cp311-abi3-macosx_11_0_arm64.whl (469.7 kB view details)

Uploaded CPython 3.11+macOS 11.0+ ARM64

lexindex-0.1.0-cp311-abi3-macosx_10_12_x86_64.whl (485.2 kB view details)

Uploaded CPython 3.11+macOS 10.12+ x86-64

File details

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

File metadata

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

File hashes

Hashes for lexindex-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a7e8e3b50d51e4904ce78a7c3aa2f68b62b0b51e0ce9a43a120a6aa5985cd45c
MD5 b1b80e2d4ed502d095e59448b79dae32
BLAKE2b-256 efd83a0f0ac7625668b35c280a069b067bd884b9c9b8ccbb521920c4b2ff8892

See more details on using hashes here.

Provenance

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

File metadata

  • Download URL: lexindex-0.1.0-cp311-abi3-win_amd64.whl
  • Upload date:
  • Size: 395.9 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.1.0-cp311-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 f7b0c2d220d1474ae486351bf4abddc6fd2f53114b4437f2830df5b70b79ca86
MD5 c64fc050ebbe36fbc00a086d41dd83b6
BLAKE2b-256 379eaec81b0065955ab3f89e3d974085a779cd41de6ab98b4ca9bd3d7c3e8572

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for lexindex-0.1.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 79e4a6ad83166f408bba0a53466ea4c6f0069f83e5f6f20f924a722eec2a41d6
MD5 fd663471ceb5fc71ad8f20504eef81f4
BLAKE2b-256 266d726b8f3f144667fa44adddb009af53917e1b9abed28a3336b52655e87143

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for lexindex-0.1.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 792e3ceeec6629aa0f31de08c503e86961e0bd2a4aa35d94877255c06320ad35
MD5 abece0b9cc2056f769d3a6daa3027908
BLAKE2b-256 acc20eaf2e9979758672efff00187e8de1e285f183172a39d2b37b92880455bb

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for lexindex-0.1.0-cp311-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 d4714022d161b8e2f14e95eb35cb7d460e89e1311de6f3b3e81103ffb6b5cb8c
MD5 85b92b09bfcea6ec14e2ecb4b13b6f9b
BLAKE2b-256 0b5583d4b0952f2472b4bfa395883fee8b2d4167b3de9c11eda3b2be44cf5ea1

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for lexindex-0.1.0-cp311-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 75790c19e590712e461ebf4a80557ec0d59ab3a92e8fa31bbc662f71687c401d
MD5 837fa6df5dd9bf383e8abb6e30540ea5
BLAKE2b-256 419363e4630dd0d5aa201cfaef84c353b2d4c071f055cab062ef8dda948b773e

See more details on using hashes here.

Provenance

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