A provider-agnostic agentic RAG library in Rust — named after the Sumerian god of wisdom and knowledge.
Retrieval is a tool the agent calls, not context stuffed into the prompt. The LLM provider and the domain (scope, corpus) are injected via config — the library holds no opinion on either. The same binary can serve a technical-docs assistant or a narrative knowledge base with zero domain code.
Status: early / proof-of-concept. The retrieval spine works end-to-end (indexing → hybrid-ready search → citation-grounded agentic answers). APIs will move.
- Retrieval-as-tool. The agent decides when to search, reformulates, and stops when it has enough grounded evidence. This keeps the cached prompt prefix stable (tool results extend the suffix) — unlike static RAG which rewrites the prefix every query.
- Provider-agnostic. LLM and embeddings via genai; swap OpenAI / Anthropic / Gemini / Ollama / … by config. LLM and embedder may be different providers.
- Grounded & citeable. Every claim cites a retrieved passage by handle; the library resolves handles to provenance (section + page range) and can verify quotes against the source. The model never emits raw ids or invents page numbers.
- Modular search. Dense + lexical (BM25) + rerank compose behind one
Retrieverseam; a hybrid backend (e.g. Qdrant) is simply one source. Fusion is two-axis: relevance (RRF, within a collection) then authority (across tiers).
| Side | Modules | Role |
|---|---|---|
| Indexing (occasional) | indexing, embed, model |
manifest → sections → chunks (title path prepended for contextual embedding) → persisted vectors |
| Retrieval (per query) | retrieval, search |
Retriever backends → SearchEngine (RRF + tier fusion + optional rerank) |
| Inference | agent, prompt, registry |
homemade agentic loop (search + answer tools), handle registry, citation resolution |
| Glue | config, providers |
env-driven config; genai client construction |
See SPEC.md for the full design rationale.
Indexing runs occasionally (not per query). A producer turns a source into
Vec<Document> and hands it to the single write path, Index::upsert; the library
owns chunking and embedding, so a producer is a thin adapter for one source
shape — a paginated book or PDF (via an outline manifest), an exported knowledge
base, a web export, your app's own evolving records, a set of curated corrections.
The shape barely matters; what matters is the tier you stamp on each
Document — its authority rank. The built-in manifest_documents handles outline
manifests; any other shape is a few lines (see examples/).
flowchart TB
subgraph prod["producers · one thin adapter per source shape"]
direction TB
M[outline manifest · book / PDF / web]
A[your app's records · Library::ingest]
X[curated notes / corrections]
end
M & A & X --> DOC[Documents · each stamped with a tier + trust]
DOC --> UP[Index::upsert · single write path]
subgraph store["LocalStore · library-owned"]
direction TB
CH[chunk · split on paragraphs] --> EM[embed new/changed only<br/>label + text · contextual]
EM --> P[persist]
end
UP --> CH
P --> D[(name.docs.json · chunks)]
P --> V[(name.vecs · f32 LE matrix)]
P --> T[(name.tantivy · feature tantivy)]
D & V & T -.-> R([retrieval pipeline])
Each source lands in its own collection at a tier, so you index a static reference tier once and keep re-upserting the living tiers as they change — cheaply, because upsert is incremental (only new or text-changed chunks are re-embedded, keyed by content) and embedding is contextual (the provenance label is prepended to the chunk text, so titles shape the vector). The lexical index is rebuilt from the same chunk set on every write, so dense and BM25 never drift apart.
Retrieval is a tool the agent calls. One SearchEngine::search(query, k, filters)
composes everything behind a single seam, fused on two axes — and the second,
tiers, is enki's organizing idea.
- Modality (within a tier) — each
Retriever(dense vectors, lexical BM25, or a single hybrid backend) returns a ranked list; RRF fuses them by rank, so scores from different models never need to be comparable. - Tier / authority (across tiers) — sources are grouped into collections by
tier, an authority rank assigned at index time (a stable reference tier low;
your living, then curated content higher). Every tier is searched, and the
per-tier lists are fused by RRF — so a higher tier sits beside the
reference for the same query rather than replacing it, with no hard overrides
or per-query routing.
Filters { trust_min }gates the same authority axis at query time. An optional cross-encoder reranker reorders the merged candidates before the finalk.
flowchart TB
Q([query text]) --> EMB[embed once]
subgraph t0["tier 0 · reference (manuals, PDF)"]
direction TB
r0d[dense] --> r0m{{RRF · modality}}
r0l[lexical · BM25] --> r0m
end
subgraph t1["tier 1 · living (app content)"]
direction TB
r1d[dense] --> r1m{{RRF · modality}}
r1l[lexical] --> r1m
end
subgraph t2["tier 2 · curated (expert notes)"]
direction TB
r2d[dense] --> r2m{{RRF · modality}}
r2l[lexical] --> r2m
end
EMB --> r0d & r1d & r2d
Q --> r0l & r1l & r2l
r0m & r1m & r2m --> TF{{RRF · across tiers · authority}}
TF --> RK[rerank · cross-encoder<br/>optional]
RK --> OUT([top-k passages → agent])
Tiers are the only opinion; everything else is a trait (Retriever, Fuser,
Reranker, Embedder) — swap a brute-force scan for Qdrant, add BM25, or plug a
custom backend without touching the engine. Backends are compile-time features
chosen at runtime (see below).
Use it directly, without the agent — raw ranked passages for your own harness:
let library = Library::open(&config).await?;
for Scored { chunk, score } in library.search("your query", 8).await? {
println!("{score:.3} [tier {}] {}", chunk.tier, chunk.provenance.label);
}cp .env.example .env # then edit models/endpoints
# point ENKI_MANIFEST at a document.manifest.json (hierarchical outline + content)
cargo run -- index # build the index (once)
cargo run -- ask "your question here" # blocking, prints answer + citations
cargo run -- stream "your question here" # streams the answer as it is generatedAny genai-supported provider works; set ENKI_LLM_MODEL / ENKI_EMBED_MODEL and
the matching endpoint/credentials.
let library = enki::library::Library::open(&config).await?; // hides all the wiring
let answer = library.ask("How does a dice roll work?").await?;
println!("{}", answer.markdown); // grounded, with [n] markers
for c in &answer.citations { /* c.number, c.provenance, c.quote_verified */ }
// or stream tokens as they arrive (Search / Token / Done events)
let mut events = std::pin::pin!(library.stream("..."));
while let Some(ev) = events.next().await { /* … */ }
// manage living content (idempotent by document id)
library.ingest("instance", documents).await?;
library.delete("instance", &["npc:old-villain".into()]).await?;
// filter retrieval (trust floor, tags) on any read path
let filters = enki::retrieval::Filters { trust_min: Some(TrustStatus::Endorsed), tags: vec![] };
let answer = library.ask_with("…", filters.clone()).await?;
let hits = library.search_with("…", 8, filters).await?; // raw passages, no LLMBring your own parts (e.g. a fastembed embedder, custom collections) instead of
open's genai defaults — the escape hatch that keeps ask/stream/ingest:
let library = Library::builder(engine, embedder, llm_client, "gemini-2.5-flash")
.system(system_prompt).cache_dir(".cache").top_k(8).build();See examples/query.rs for a runnable version.
A provider-agnostic eval harness replays a fixed question set and measures
relevance (expected facts present, expected source cited) and latency. The
embedder — hence the index — stays fixed, so you re-run the same harness against a
different LLM by swapping ENKI_LLM_MODEL / ENKI_LLM_ENDPOINT.
export ENKI_MANIFEST=examples/ressources/dnd/books/FR_SRD_CC_v5_2_1/document.manifest.json
export ENKI_CACHE_DIR=.cache-dnd
cargo run -- index # build corpus (once)
cargo run --example ingest_srd_spells # build spells (one doc per spell)
cargo run --example ingest_campagne -- examples/ressources/dnd/campagne # build instance
cargo run --example eval # replay + reportThe question set (eval_questions.json)
spans the library's differentiators: book-only facts, campaign-only narrative,
cross-collection tier fusion (book rule + character sheet), house-rule
overrides (campaign authority beats the manual), multi-hop, and out-of-corpus
refusal. A small freely-licensed D&D SRD + simulated campaign ships under
examples/ressources/dnd so the eval is reproducible.
The library instruments its hot paths (search, embedding, agent rounds) with
[tracing] spans and structured events — it installs no subscriber itself.
- Default console sink (feature
console, on by default): callenki::telemetry::init_console()once; it logs to stderr, filtered byENKI_LOG(ENKI_LOG=enki=debugfor per-search timings,offto silence). The CLI and examples already call it. - Your own sink: build with
--no-default-featuresand install anytracingsubscriber/layer (file, JSON, an in-app buffer, a closure) — the natural path for a Tauri backend.tracing'sSubscriberis the "trait" seam, a layer closure is the "fn" seam; enki adds no logging abstraction of its own.
All config is environment-driven (loaded from .env if present). See
.env.example: ENKI_LLM_PROVIDER, ENKI_LLM_MODEL,
ENKI_LLM_ENDPOINT, ENKI_LLM_API_KEY, ENKI_EMBED_PROVIDER, ENKI_EMBED_MODEL,
ENKI_EMBED_ENDPOINT, ENKI_EMBED_API_KEY, ENKI_MANIFEST, ENKI_LIBRARY_SCOPE,
ENKI_TOP_K,
ENKI_MAX_ROUNDS, ENKI_BACKEND, ENKI_CACHE_DIR, ENKI_QDRANT_URL,
ENKI_QDRANT_API_KEY, ENKI_SPARSE, ENKI_SPARSE_MODEL, ENKI_QDRANT_FUSION,
ENKI_GRAPH, ENKI_LEXICAL, ENKI_RERANK, ENKI_RERANK_CACHE, ENKI_LOG.
Config is grouped by concern — embed, llm, retrieval, agent,
indexing — so each part of the code (and each consumer) depends only on what it
uses: a retrieval-only harness builds an EmbedConfig + RetrievalConfig and
never touches the LLM/agent config. Each group has its own from_env, and
Config::from_env composes them.
ENKI_LLM_PROVIDER=ollama targets a local endpoint; any other value uses genai's
native routing — the adapter is picked from the model name (gemini-…, gpt-…,
claude-…). The API key is injected through config (Config.llm_api_key /
Config.embed_api_key), so an embedding app supplies it from its own
settings/keychain — no process env var required. If left None, genai falls back
to the provider's own env var (GEMINI_API_KEY, …), convenient for the CLI.
LLM and embedder are configured independently, so a cloud LLM can pair with a local embedder (keep embeddings on the model the index was built with):
let mut cfg = Config::from_env();
cfg.llm.provider = "gemini".into();
cfg.llm.model = "gemini-2.5-flash".into();
cfg.llm.api_key = Some(user_settings.gemini_key); // from the app, not the env
let library = Library::open(&cfg).await?;# CLI equivalent (env fallback):
ENKI_LLM_PROVIDER=gemini ENKI_LLM_MODEL=gemini-2.5-flash ENKI_LLM_API_KEY=… \
cargo run -- ask "..." # embedder stays ENKI_EMBED_PROVIDER=ollama / bge-m3ENKI_BACKEND picks where vectors live — the read and write path:
local(default): on-disk vectors + brute-force scan. Zero infra; ideal for an embedded desktop app. Persisted underENKI_CACHE_DIR.qdrant(featureqdrant): a Qdrant server. HNSW retrieval withtrust_minpushed down as a server-side filter, andingest/deletewrite straight to it. Dense, or hybrid (dense + sparse) — see below.
ENKI_BACKEND=qdrant ENKI_QDRANT_URL=http://localhost:6334 \
cargo run --example qdrant_smoke --features qdrant # ingest → search → askENKI_QDRANT_API_KEY is injected via config (Qdrant Cloud); None for a local
instance. tantivy is a local-file index, so it is rejected with the qdrant
backend — use ENKI_SPARSE for Qdrant-side lexical.
Hybrid (dense + sparse). ENKI_SPARSE adds a sparse (lexical) vector fused
with the dense one server-side (ENKI_QDRANT_FUSION=rrf|dbsf), in one Query
API call. To the engine it is still a single Hybrid source, so fusion/rerank
above are untouched. The collection is created with named dense + sparse
vectors (Modifier::Idf) — switching a dense-only collection to hybrid needs a
reindex. Two drivers, both behind the public SparseEmbedder trait (bring your
own via Library::builder):
hashed— zero-dep, offline, model-free: hashed term-frequencies + server-side IDF (BM25-ish). Multilingual, wasm-safe, no ONNX. The lightweight default.fastembed(featurefastembed) — a learned sparse model:bge-m3(multilingual, same family as the dense bge-m3) orsplade(English).
The right sparse model depends on your corpus and language — the library ships
the mechanism and two drivers, not an opinion. Measure with retrieval_eval.
Backends are compile-time features; the active one is chosen at runtime and the engine errors if you select one that wasn't compiled in.
cargo run --features tantivy -- index # build dense + lexical indexes
ENKI_LEXICAL=tantivy cargo run --features tantivy -- ask "..." # hybrid retrievalENKI_LEXICAL=none (default) is dense-only; tantivy adds a BM25 source fused
with dense via RRF. ENKI_RERANK=fastembed adds a cross-encoder second stage
(bge-reranker-v2-m3) that reorders the fused candidates; ENKI_RERANK_CACHE points
at an existing model cache to skip the ~2 GB download.
Note: on the bundled D&D eval, dense-only (bge-m3) already retrieves precise passages well (recall@5 = 100%, MRR 0.96 via the retrieval eval below), so neither BM25 nor reranking improve it here — naive equal-weight RRF even hurt. They earn their place on larger, noisier corpora where the gold passage is retrievable but mis-ranked (recall@20 ≫ recall@5). Measure before enabling.
ENKI_GRAPH=local loads a corpus-wide relation graph and exposes two agentic
tools — neighbors (walk typed relations of an entity) and open (fetch entities
by id). It joins the vector store's content to a relational skeleton by entity
id, so the agent can answer relational questions ("who belongs to X?") that pure
similarity can't.
Enki does not infer the graph (no LLM extraction). The caller supplies typed edges — a domain app usually already has them:
use enki::graph::Edge;
library.relate(vec![
Edge::new("pnj:lyra", "membre_de", "faction:ordre"),
Edge::new("pnj:thorgrim", "membre_de", "faction:ordre"),
]).await?;
// then: library.ask("Who are the members of the Order?") → the agent searches,
// finds the faction, and calls `neighbors` to enumerate its members.One global graph, transverse to collections/tiers. Traversal is bounded (neighbour
lookups; multi-hop is the agent's job, not a query language) and gated by the same
trust/tag filters. Local (graph.json) for now; a server backend (Neo4j) can slot
behind the same traits later. See examples/graph_demo.rs.
retrieval_eval scores retrieval in isolation (no LLM): recall@5, recall@20, MRR
against gold passages. This is the tool to decide whether a backend helps — the
end-to-end answer eval is dominated by model-generation noise.
ENKI_LEXICAL=none cargo run --features tantivy --example retrieval_evalCorpus data is not committed (/data is gitignored) — bring your own and mind
its license. A small freely-licensed sample dataset will be added under a tracked
directory. See SPEC.md for the manifest format.
- Section-based chunking + contextual embedding (title path)
- Dense brute-force retriever + modular
SearchEngine(two-axis fusion) - Agentic loop, handle registry, citation resolution + quote verification
- Binary vector store (
.vecs/.ids); RAM by default, mmap underfs - Second collection / tier (multi-collection fusion)
- BM25 (Tantivy) lexical retriever, fused with dense (feature
tantivy,ENKI_LEXICAL) - Cross-encoder reranking (fastembed / bge-reranker-v2-m3; feature
fastembed,ENKI_RERANK) - Pure retrieval eval (recall@k / MRR, LLM-free) + deterministic decoding
- Agentic-loop robustness (stale/dup guard, no-blank fallback, budget cap)
- Example producers: manifest (corpus), campaign vault (instance), per-spell SRD (spells)
- Relation graph — caller-supplied typed edges +
neighbors/openagentic tools (ENKI_GRAPH=local); Neo4j backend later, same traits - Qdrant store backend — dense retrieval + write (feature
qdrant,ENKI_BACKEND) - Qdrant native hybrid — dense + sparse, server-side fusion (
ENKI_SPARSE); two drivers behindSparseEmbedder: hashed-TF (zero-dep) + fastembed (bge-m3/SPLADE) - Multi-provider LLM (Ollama / Gemini / OpenAI / Anthropic via genai routing)
- Prompt caching (ephemeral CacheControl) on the stable system+tools prefix
Quality gates run automatically via lefthook git hooks (fmt + clippy + test on
commit; cargo deny + cargo machete on push):
cargo install lefthook cargo-deny cargo-machete
lefthook installMIT © Michaël DOHR.