A hybrid retrieval engine for retrieval-augmented generation (RAG) that runs even on a phone
Rust
1
52 commits
updated Sep 24, 2026
A hybrid retrieval engine for retrieval-augmented generation (RAG), written in Rust and built to run on the device: an iPhone, an Android phone or a laptop searches its own index offline, with no server and no network.
One query goes through four stages:
┌─ lexical (BM25, tantivy) ─┐
query ──────┤ ├─ fusion (reciprocal rank) ─ re-rank (cross-encoder) ─ hits
└─ dense (MiniLM, int8) ───┘
The same engine is reachable from Rust, Python, Swift and Kotlin. On one machine every surface returns the same hits with the same scores, bit for bit; on another processor only the cross-encoder's scores move, in the last bits. Three demo applications search Simple English Wikipedia (about 240,000 articles, 428,000 passages) through it, on a laptop, an iPhone and Android.
Status: 0.1.0, not published. Everything builds from this repository; nothing is on crates.io, PyPI, Swift Package Index or Maven yet. The fifth stage the design names — a learned ranker over the pipeline's features (LTR) — is not built:
crates/xtriever-ltris a placeholder.
Σ 1/(60 + rank)).Beyond the ranking itself:
The models run on the CPU. The two default models are eight-bit GGUF artefacts (leliuga/all-MiniLM-L6-v2-GGUF, cstr/ms-marco-MiniLM-L-6-v2-GGUF), 51.5 MB together. The float originals load through the same call.
nDCG@10 on the test sets of three BEIR datasets, from the repository's own evaluation harness with the default models:
| configuration | SciFact | NFCorpus | FiQA | mean |
|---|---|---|---|---|
| BM25 alone | 0.686 | 0.323 | 0.250 | 0.420 |
| dense alone | 0.646 | 0.315 | 0.369 | 0.444 |
| BM25 + dense, fused | 0.715 | 0.354 | 0.370 | 0.480 |
| fused + re-ranked (the default) | 0.722 | 0.362 | 0.390 | 0.491 |
| the same with sparse expansion | 0.722 | 0.358 | 0.407 | 0.495 |
Recall@100 of the default pipeline: 0.955, 0.321 and 0.706.
The harness names the rows lexical-baseline-v2, dense-baseline-v1, hybrid-baseline-v2,
hybrid-rerank-v3 and hybrid-sparse-rerank-v1. The records behind every number are committed
under specs/*/: 013 for BM25,
026 and 027
for the rest. A ranking change is measured on all three datasets before it lands, and CI runs a
SciFact smoke test of the lexical stage whenever a ranking crate changes.
The iOS demo searching all of Simple English Wikipedia on an iPhone 16e, in Release, over 20 measurement queries, re-ranking 10 candidates (record):
| index | 585 MB for 427,947 passages, opened in place from the app bundle |
| models | 51.5 MB |
peak memory (phys_footprint) | 335 MB, under the project's 600 MB ceiling (ADR-0010) |
| first list (fused), median | 0.20 s |
| re-ranked list, median | 1.21 s more |
| index open | 0.74 s |
The same queries on a 2021 MacBook Pro (M1 Pro), through Python: 0.14 s fused, 0.99 s re-ranked (record).
Most of a search is the cross-encoder: each re-ranked candidate is one forward pass, whatever the corpus size. That is why the demos show the fused list first and the re-ranked order after. A 20,000-passage slice of Wikipedia opens faster on the same phone (0.45 s) and fuses faster (0.16 s), but re-ranks no faster (the 026 report has both).
The Android demo has only emulator records so far. Its results match the host's to the bit for everything but the cross-encoder, whose scores are within 7e-6 of the host's.
rust-toolchain.toml pins the toolchain
(1.91.1, edition 2024) and its targets; rustup installs them on first use.reference/ run in pinned environments that
scripts/setup-reference-venv.sh <feature> creates.scripts/check-toolchain.sh checks the Rust toolchain and the iOS side.
The models are never committed. The fetch script downloads each pinned revision and checks every file's size and SHA-256:
scripts/fetch-model.sh --manifest reference/models/manifest-q8.json # embedder → reference/models/all-MiniLM-L6-v2-q8
scripts/fetch-model.sh --manifest reference/models/manifest-rerank-q8.json # re-ranker → reference/models/ms-marco-MiniLM-L-6-v2-q8
An eight-bit artefact carries weights only; the script also fetches the float model's configuration and tokenizer, verified against their own pins.
cargo build --workspace
cargo nextest run --workspace
Tests that need the models or a dataset are marked ignored; run them with
--run-ignored all once the models are fetched.
Build the wheel and run the smallest demo: ten documents, one index, one search, first fused, then re-ranked.
cd python && uv venv .venv && uv pip install "maturin>=1.15,<2"
.venv/bin/maturin build --release && uv pip install ../target/wheels/xtriever-*.whl
cd .. && python/.venv/bin/python apps/python-minimal-demo/demo.py "how do bees make honey"
| surface | where | what it is |
|---|---|---|
| Rust | crates/xtriever-pipeline | HybridIndex: create, add, commit, merge, open, search |
| Python | python/ | a wheel built with maturin over the FFI crate |
| Swift | swift/Xtriever | a Swift package: an XCFramework and an async XtrieverIndex |
| Kotlin | android/xtriever | a Gradle library module for 64-bit ARM Android 8.0 or later |
Python, Swift and Kotlin are generated by uniffi from
one crate, crates/xtriever-ffi, so they expose the same operations and
records. None of them holds retrieval logic of its own.
In Python:
import xtriever
index = xtriever.IndexHandle.open(
"path/to/index", # a hybrid index directory
"reference/models/all-MiniLM-L6-v2-q8", # the embedder (required)
"reference/models/ms-marco-MiniLM-L-6-v2-q8", # the re-ranker (or None: fused order only)
xtriever.LoadPath.MMAP, # both models memory-mapped
)
response = index.search("why is the sky blue", xtriever.SearchOptions(k=5, explain=True))
for hit in response.hits:
print(hit.external_id, hit.score, hit.rerank_score, hit.text[:80])
python/README.md covers building an index, documents and fields, errors and the sparse option. The Swift and Kotlin READMEs show the same in their languages.
| demo | what it shows |
|---|---|
apps/python-minimal-demo | the whole pipeline in one file under 80 lines, over ten documents |
apps/python-wiki-demo | a command line over Simple English Wikipedia: search, explanations, stage reports, building the index from the raw snapshot, and a parity check against the phone's goldens |
apps/ios-wiki-demo | a SwiftUI app searching Wikipedia offline on an iPhone: the whole edition, or a 20,000-passage slice for a quicker install |
apps/android-wiki-demo | the same as a Jetpack Compose app on Android |
The Wikipedia index is built by the command line from a pinned snapshot:
scripts/setup-reference-venv.sh 008 # the converter's pinned Python environment
scripts/fetch-wiki.sh # the pinned snapshot, verified
RAYON_NUM_THREADS=1 cargo run --release -p xtriever-cli -- wiki build \
--out target/xt-wiki --cache-dir target/xt-wiki-cache # about 12 hours; resumable
Add --limit N to build from the first N articles. The iOS README gives the slice's build.
The evaluation harness downloads the BEIR datasets, verifies them by hash, and scores a configuration:
scripts/fetch-beir.sh scifact # or nfcorpus, fiqa; all three by default
cargo run --release -p xtriever-eval --example beir -- run --dataset scifact \
--config hybrid-rerank-v3 --out /tmp/scifact.json
cargo run --release -p xtriever-eval --example beir -- delta \
specs/026-eight-bit-precision/runs/hybrid-rerank-v3.scifact.json /tmp/scifact.json
The first run embeds the corpus and caches it (minutes for SciFact, over an hour for FiQA).
--config takes the names under How good it is, and delta prints the change
against any committed record.
Behaviour that has a reference implementation (BM25 scoring, tokenization, embeddings, the
cross-encoder, the sparse encoder) is tested against golden files generated by the Python
scripts in reference/, at tolerances each feature's spec states.
crates/
xtriever-core the contract: traits, types, errors (std only)
xtriever-analysis text analysis and chunking (std only)
xtriever-lexical BM25 through tantivy
xtriever-dense the embedder, int8 vectors, the sparse encoder
xtriever-rerank the cross-encoder
xtriever-ltr placeholder for the learned ranker (not built)
xtriever-pipeline the hybrid index: fusion, re-ranking, degradation (std only)
xtriever-eval the BEIR evaluation harness (std only)
xtriever-ffi the uniffi surface for Python, Swift and Kotlin
xtriever-cli `xtriever wiki build | verify | expected`
python/ the Python package
swift/Xtriever/ the Swift package
android/xtriever/ the Kotlin library module
apps/ the four demos
reference/ Python reference implementations and fixture generators
scripts/ model, dataset and platform build scripts, the demo check
specs/NNN-*/ one directory per feature: spec, plan, tasks, report, run records
docs/adr/ architecture decision records
Dependencies point one way: core ← stage crates ← pipeline ← ffi and cli. Crates
marked "std only" have no C or C++ dependencies, no async and no threads of their own. The C
and C++ dependencies stay in dense, rerank and ffi, and deny.toml enforces that.
.specify/memory/constitution.md is the project's highest
authority. Its rules:
Each feature goes through Spec Kit — specify, clarify,
plan, tasks, implement — and leaves a directory under specs/ with its spec, plan, task list,
run records and a report stating what was measured, what was found and what was deliberately
not done. There are 27 so far; the reports are the best history of why the engine is the way
it is. Decisions that change a contract, a format or the rules are recorded in
docs/adr/.
Before a change is done, the local gate runs formatting, clippy with warnings as errors, the
test suite, the dependency checks, the iOS, Android and wasm32 builds (wasm32 fails today, a
known and tracked gap), the reference fixtures, the Python package, and
scripts/check-demos.sh, which builds every demo against the engine. The full list is in
CLAUDE.md. CI runs the portable part on Linux, macOS and Windows.
Xtriever is licensed under the Apache License, Version 2.0.
The models are not part of this repository and carry their own licences; see each model's card on Hugging Face (linked above). The Wikipedia text the demos search is from Simple English Wikipedia, available under CC BY-SA 4.0; every Wikipedia index built here ships its attribution file, and every Wikipedia demo shows it. The BEIR datasets are fetched from their published sources and are subject to their own terms.
52 commits
Rust
60.5%
Python
24.9%
Swift
5.7%
Shell
5.2%
Kotlin
3.7%
A hybrid retrieval engine for retrieval-augmented generation (RAG) that runs even on a phone
Rust
1
52 commits
updated Sep 24, 2026
A hybrid retrieval engine for retrieval-augmented generation (RAG), written in Rust and built to run on the device: an iPhone, an Android phone or a laptop searches its own index offline, with no server and no network.
One query goes through four stages:
┌─ lexical (BM25, tantivy) ─┐
query ──────┤ ├─ fusion (reciprocal rank) ─ re-rank (cross-encoder) ─ hits
└─ dense (MiniLM, int8) ───┘
The same engine is reachable from Rust, Python, Swift and Kotlin. On one machine every surface returns the same hits with the same scores, bit for bit; on another processor only the cross-encoder's scores move, in the last bits. Three demo applications search Simple English Wikipedia (about 240,000 articles, 428,000 passages) through it, on a laptop, an iPhone and Android.
Status: 0.1.0, not published. Everything builds from this repository; nothing is on crates.io, PyPI, Swift Package Index or Maven yet. The fifth stage the design names — a learned ranker over the pipeline's features (LTR) — is not built:
crates/xtriever-ltris a placeholder.
Σ 1/(60 + rank)).Beyond the ranking itself:
The models run on the CPU. The two default models are eight-bit GGUF artefacts (leliuga/all-MiniLM-L6-v2-GGUF, cstr/ms-marco-MiniLM-L-6-v2-GGUF), 51.5 MB together. The float originals load through the same call.
nDCG@10 on the test sets of three BEIR datasets, from the repository's own evaluation harness with the default models:
| configuration | SciFact | NFCorpus | FiQA | mean |
|---|---|---|---|---|
| BM25 alone | 0.686 | 0.323 | 0.250 | 0.420 |
| dense alone | 0.646 | 0.315 | 0.369 | 0.444 |
| BM25 + dense, fused | 0.715 | 0.354 | 0.370 | 0.480 |
| fused + re-ranked (the default) | 0.722 | 0.362 | 0.390 | 0.491 |
| the same with sparse expansion | 0.722 | 0.358 | 0.407 | 0.495 |
Recall@100 of the default pipeline: 0.955, 0.321 and 0.706.
The harness names the rows lexical-baseline-v2, dense-baseline-v1, hybrid-baseline-v2,
hybrid-rerank-v3 and hybrid-sparse-rerank-v1. The records behind every number are committed
under specs/*/: 013 for BM25,
026 and 027
for the rest. A ranking change is measured on all three datasets before it lands, and CI runs a
SciFact smoke test of the lexical stage whenever a ranking crate changes.
The iOS demo searching all of Simple English Wikipedia on an iPhone 16e, in Release, over 20 measurement queries, re-ranking 10 candidates (record):
| index | 585 MB for 427,947 passages, opened in place from the app bundle |
| models | 51.5 MB |
peak memory (phys_footprint) | 335 MB, under the project's 600 MB ceiling (ADR-0010) |
| first list (fused), median | 0.20 s |
| re-ranked list, median | 1.21 s more |
| index open | 0.74 s |
The same queries on a 2021 MacBook Pro (M1 Pro), through Python: 0.14 s fused, 0.99 s re-ranked (record).
Most of a search is the cross-encoder: each re-ranked candidate is one forward pass, whatever the corpus size. That is why the demos show the fused list first and the re-ranked order after. A 20,000-passage slice of Wikipedia opens faster on the same phone (0.45 s) and fuses faster (0.16 s), but re-ranks no faster (the 026 report has both).
The Android demo has only emulator records so far. Its results match the host's to the bit for everything but the cross-encoder, whose scores are within 7e-6 of the host's.
rust-toolchain.toml pins the toolchain
(1.91.1, edition 2024) and its targets; rustup installs them on first use.reference/ run in pinned environments that
scripts/setup-reference-venv.sh <feature> creates.scripts/check-toolchain.sh checks the Rust toolchain and the iOS side.
The models are never committed. The fetch script downloads each pinned revision and checks every file's size and SHA-256:
scripts/fetch-model.sh --manifest reference/models/manifest-q8.json # embedder → reference/models/all-MiniLM-L6-v2-q8
scripts/fetch-model.sh --manifest reference/models/manifest-rerank-q8.json # re-ranker → reference/models/ms-marco-MiniLM-L-6-v2-q8
An eight-bit artefact carries weights only; the script also fetches the float model's configuration and tokenizer, verified against their own pins.
cargo build --workspace
cargo nextest run --workspace
Tests that need the models or a dataset are marked ignored; run them with
--run-ignored all once the models are fetched.
Build the wheel and run the smallest demo: ten documents, one index, one search, first fused, then re-ranked.
cd python && uv venv .venv && uv pip install "maturin>=1.15,<2"
.venv/bin/maturin build --release && uv pip install ../target/wheels/xtriever-*.whl
cd .. && python/.venv/bin/python apps/python-minimal-demo/demo.py "how do bees make honey"
| surface | where | what it is |
|---|---|---|
| Rust | crates/xtriever-pipeline | HybridIndex: create, add, commit, merge, open, search |
| Python | python/ | a wheel built with maturin over the FFI crate |
| Swift | swift/Xtriever | a Swift package: an XCFramework and an async XtrieverIndex |
| Kotlin | android/xtriever | a Gradle library module for 64-bit ARM Android 8.0 or later |
Python, Swift and Kotlin are generated by uniffi from
one crate, crates/xtriever-ffi, so they expose the same operations and
records. None of them holds retrieval logic of its own.
In Python:
import xtriever
index = xtriever.IndexHandle.open(
"path/to/index", # a hybrid index directory
"reference/models/all-MiniLM-L6-v2-q8", # the embedder (required)
"reference/models/ms-marco-MiniLM-L-6-v2-q8", # the re-ranker (or None: fused order only)
xtriever.LoadPath.MMAP, # both models memory-mapped
)
response = index.search("why is the sky blue", xtriever.SearchOptions(k=5, explain=True))
for hit in response.hits:
print(hit.external_id, hit.score, hit.rerank_score, hit.text[:80])
python/README.md covers building an index, documents and fields, errors and the sparse option. The Swift and Kotlin READMEs show the same in their languages.
| demo | what it shows |
|---|---|
apps/python-minimal-demo | the whole pipeline in one file under 80 lines, over ten documents |
apps/python-wiki-demo | a command line over Simple English Wikipedia: search, explanations, stage reports, building the index from the raw snapshot, and a parity check against the phone's goldens |
apps/ios-wiki-demo | a SwiftUI app searching Wikipedia offline on an iPhone: the whole edition, or a 20,000-passage slice for a quicker install |
apps/android-wiki-demo | the same as a Jetpack Compose app on Android |
The Wikipedia index is built by the command line from a pinned snapshot:
scripts/setup-reference-venv.sh 008 # the converter's pinned Python environment
scripts/fetch-wiki.sh # the pinned snapshot, verified
RAYON_NUM_THREADS=1 cargo run --release -p xtriever-cli -- wiki build \
--out target/xt-wiki --cache-dir target/xt-wiki-cache # about 12 hours; resumable
Add --limit N to build from the first N articles. The iOS README gives the slice's build.
The evaluation harness downloads the BEIR datasets, verifies them by hash, and scores a configuration:
scripts/fetch-beir.sh scifact # or nfcorpus, fiqa; all three by default
cargo run --release -p xtriever-eval --example beir -- run --dataset scifact \
--config hybrid-rerank-v3 --out /tmp/scifact.json
cargo run --release -p xtriever-eval --example beir -- delta \
specs/026-eight-bit-precision/runs/hybrid-rerank-v3.scifact.json /tmp/scifact.json
The first run embeds the corpus and caches it (minutes for SciFact, over an hour for FiQA).
--config takes the names under How good it is, and delta prints the change
against any committed record.
Behaviour that has a reference implementation (BM25 scoring, tokenization, embeddings, the
cross-encoder, the sparse encoder) is tested against golden files generated by the Python
scripts in reference/, at tolerances each feature's spec states.
crates/
xtriever-core the contract: traits, types, errors (std only)
xtriever-analysis text analysis and chunking (std only)
xtriever-lexical BM25 through tantivy
xtriever-dense the embedder, int8 vectors, the sparse encoder
xtriever-rerank the cross-encoder
xtriever-ltr placeholder for the learned ranker (not built)
xtriever-pipeline the hybrid index: fusion, re-ranking, degradation (std only)
xtriever-eval the BEIR evaluation harness (std only)
xtriever-ffi the uniffi surface for Python, Swift and Kotlin
xtriever-cli `xtriever wiki build | verify | expected`
python/ the Python package
swift/Xtriever/ the Swift package
android/xtriever/ the Kotlin library module
apps/ the four demos
reference/ Python reference implementations and fixture generators
scripts/ model, dataset and platform build scripts, the demo check
specs/NNN-*/ one directory per feature: spec, plan, tasks, report, run records
docs/adr/ architecture decision records
Dependencies point one way: core ← stage crates ← pipeline ← ffi and cli. Crates
marked "std only" have no C or C++ dependencies, no async and no threads of their own. The C
and C++ dependencies stay in dense, rerank and ffi, and deny.toml enforces that.
.specify/memory/constitution.md is the project's highest
authority. Its rules:
Each feature goes through Spec Kit — specify, clarify,
plan, tasks, implement — and leaves a directory under specs/ with its spec, plan, task list,
run records and a report stating what was measured, what was found and what was deliberately
not done. There are 27 so far; the reports are the best history of why the engine is the way
it is. Decisions that change a contract, a format or the rules are recorded in
docs/adr/.
Before a change is done, the local gate runs formatting, clippy with warnings as errors, the
test suite, the dependency checks, the iOS, Android and wasm32 builds (wasm32 fails today, a
known and tracked gap), the reference fixtures, the Python package, and
scripts/check-demos.sh, which builds every demo against the engine. The full list is in
CLAUDE.md. CI runs the portable part on Linux, macOS and Windows.
Xtriever is licensed under the Apache License, Version 2.0.
The models are not part of this repository and carry their own licences; see each model's card on Hugging Face (linked above). The Wikipedia text the demos search is from Simple English Wikipedia, available under CC BY-SA 4.0; every Wikipedia index built here ships its attribution file, and every Wikipedia demo shows it. The BEIR datasets are fetched from their published sources and are subject to their own terms.
52 commits
Rust
60.5%
Python
24.9%
Swift
5.7%
Shell
5.2%
Kotlin
3.7%