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

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).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

  • StringIndex keeps 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 random key(id) decodes up to a bucket of deltas and returns an owned String. The serialised blob is [magic][fst][front-coded dict]; from_bytes validates every length and pointer, 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.
  • 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 thing (FST + front-coded dictionary); 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. 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 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

lexindex-0.2.0.tar.gz (48.1 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.2.0-cp311-abi3-win_amd64.whl (408.1 kB view details)

Uploaded CPython 3.11+Windows x86-64

lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (532.5 kB view details)

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

lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (520.4 kB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ ARM64

lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl (482.4 kB view details)

Uploaded CPython 3.11+macOS 11.0+ ARM64

lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl (497.0 kB view details)

Uploaded CPython 3.11+macOS 10.12+ x86-64

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

Hashes for lexindex-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c989962adafb0091c3802e8434de91ff0117446964860759b4edf592ac2021c0
MD5 7eda88d839b801f212e274bf1bade549
BLAKE2b-256 78d9748d3876b4a79f75691f99f82aacabe95ca46d84659794fc307f7d901dfc

See more details on using hashes here.

Provenance

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

Hashes for lexindex-0.2.0-cp311-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 e881f5ff310e6d48856acb7023c7292f2ae42bc24fb95767fa9fc4e9dee384b1
MD5 63b2c29db014c4b458216bbfbd72535d
BLAKE2b-256 b9d11ea57e4ece9e660583a15e3d7a6661886a73a514c957020c492bd541b3eb

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for lexindex-0.2.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 3fd1eaf71514225bc67a0670cf9100e85eb1e614d2b3afc0def9584fef420e83
MD5 82f7fde2dcc10b1c2edd7a58b08f99a9
BLAKE2b-256 ebdaec2b16702e8b2e4965ad094777a5300a56b8207dc1e5f47eeef58079e624

See more details on using hashes here.

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

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.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for lexindex-0.2.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 2b46701b0cad140f88561eed27862e286e0630cc8854a1d6bd4175729e996192
MD5 09082b693e39c305e508d31d4568e7af
BLAKE2b-256 0594d3e385c1d2cc2c46b11a9891b9443124dcdd8a03b0f8707bdaeb358290de

See more details on using hashes here.

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

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.2.0-cp311-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for lexindex-0.2.0-cp311-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 5aab006b41bdba1449649928b86526576853abbb2290782a05dd100b27270469
MD5 432ab152b078fa1ede1c624839766c56
BLAKE2b-256 a27fe2c912f9383741c609a6fada77f6fb513a1733a44ed516a7f95e821447f4

See more details on using hashes here.

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

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.2.0-cp311-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for lexindex-0.2.0-cp311-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 9a2ada73632e9aa8c60da78f35244cc720e4c2276082495229c3a86b8cad2a8f
MD5 d7cf0fc14cd4b0dc76f368657741251e
BLAKE2b-256 4b873ebd8faf09506f8301b06d59810561320fe159fdb3a0674079ad2510325e

See more details on using hashes here.

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

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