Retrieval
The narrative RAG channel — paragraph-granular chunking, CJK-aware Okapi BM25, an injectable vector channel, RRF fusion, LLM listwise rerank, and the adapter that strips RESTRICTED content before it can reach a prompt.
The retrieval domain (src/ragspine/retrieval/) is RAGSpine's narrative RAG channel —
the half that answers "why / what happened" questions by retrieving document chunks,
fusing lexical and (optional) vector signals, reranking, and handing cited snippets to the
agent. It is the counterpart to the deterministic structured channel; see
Dual-channel for how the agent routes between the two.
Two properties are non-negotiable here and enforced in code:
- Semantic hybrid by default, pure-BM25 as the zero-dep fallback. The vector channel is
injectable. With the
[embed-onnx]extra installed, retrieval is genuinely hybrid (BM25 + ONNX semantic → RRF) with no config (RAGSPINE_EMBEDDING=auto); with no embedding backend wired it is pure Okapi BM25 + RRF — fully offline, deterministic, zero SDKs. - RESTRICTED isolation at two exits. Sensitivity-
RESTRICTEDcontent is stripped at both thererank/andlink/exits before it can reach a prompt. See RESTRICTED isolation.
Layout
The pipeline reads left to right: chunking produces and versions chunks → lexical
(with optional vector) scores and fuses them → rerank reorders the top candidates →
link adapts the result into the agent and drops RESTRICTED.
Current retrieval product presets
RAGSPINE_RETRIEVAL_MODE=auto keeps the configured hybrid behavior. The aliases hybrid and
vector also allow the embedding/vector path. economy, bm25, and lexical are an explicit
zero-embedding preset: service assembly does not construct an embedding backend or a vector store.
Metadata predicates support eq, ne, in, nin, gt, gte, lt, lte, and between.
They are deterministic, order-preserving narrowing operations. Automatic filter extraction is a
separate optional stage; a failed or absent extraction never broadens past the unfiltered candidate
set silently.
For multiple libraries, MultiIndexRetriever asks a router for library IDs, runs each selected
index independently, and fuses the ranked lists with RRF. Results carry library_id provenance.
If routing fails, the safe availability fallback searches every configured library.
chunking — paragraph-granular chunker + versioned store
chunking/chunking.py turns a document's plain text into retrieval chunks. The token
budget is approximated by character count (no third-party tokenizer), keeping it
offline and deterministic.
Prop
Type
Constants: DEFAULT_CHUNK_CHARS = 480, DEFAULT_OVERLAP_CHARS = 80. Oversized single
paragraphs are split by sentence enders (。!?;.!?;), then hard-cut, with no overlap
between the sub-chunks — so a chunk's text always stays a contiguous substring of the
source, which keeps citations honest (see Provenance).
chunk_document raises ValueError if max_chars <= 0, overlap_chars < 0, or
overlap_chars >= max_chars.
chunking/chunk_store.py is the versioned chunk store (SQLite, mirroring the fact
store: explicit schema, parameterized SQL, a read-only execute_read entry point).
StoredChunk— everyChunkfield, including parent/window fields, plus ingestion metadata:valid_as_of,ingested_at,version(default1),active(defaultTrue). Older SQLite schemas are migrated additively.ChunkStore(db_path)—init_schema()creates thenarrative_chunktable and is idempotent.replace_doc_chunks(doc_id, chunks, valid_as_of="") -> intdoes a versioned replacement: it bumpsversion = max(version) + 1, marks old rowsactive=0, inserts the new chunksactive=1, and returns the number of rows written. Re-ingesting is idempotent; passing an empty list withdraws the document from the active set.iter_chunks(*, doc_id=None, topic=None, entity=None, geography=None, period=None, language=None, include_inactive=False) -> list[StoredChunk]— an AND-combined metadata pre-filter (active-only by default), used to narrow candidates before any scoring.
lexical — Okapi BM25 (CJK uni+bigram) + RRF fusion
lexical/retrieval.py is the scoring core. Everything is pure Python — no rank-bm25, no
SDKs.
tokenize(text) -> list[str]— lowercases; ASCII alphanumeric runs become words; CJK runs are emitted as both unigrams and adjacent bigrams. That dual granularity is what makes BM25 work for Chinese without a segmenter.bm25_scores(query_tokens, docs_tokens, k1=1.5, b=0.75) -> list[float]— standard Okapi BM25 (DEFAULT_BM25_K1 = 1.5,DEFAULT_BM25_B = 0.75).rrf_fuse(rankings, k=60) -> dict[str, float]— Reciprocal Rank Fusion,score += 1.0 / (k + rank)with rank starting at 1. The constant isDEFAULT_RRF_K = 60(the standard RRF value).GlossaryQueryRewriter(max_queries=5)— a deterministic, rule-based multi-query rewriter that expands a query using the glossary's entity/metric synonyms (zero LLM). The original query is always first.
These compose into the retriever classes:
Prop
Type
HybridRetriever.search(...) applies the metadata pre-filter before any scoring or
embedding, computes chunk vectors lazily (cached by chunk_id) only for surviving
candidates, and breaks ties deterministically by (-fused_score, chunk_id).
HybridRetriever also exposes .topology() -> PipelineGraph, a thin delegate into the
pipeline topology exporter — so you can render the actual wiring
of a configured retriever as Mermaid / DOT / JSON.
vector — injectable embedding backends (default: none)
The vector channel is an extension point, not a default. The EmbeddingBackend Protocol
(defined in lexical/retrieval.py) has a single method:
class EmbeddingBackend(Protocol):
def embed_texts(self, texts: list[str]) -> list[list[float]]: ...You inject an implementation via the embedding_backend= keyword on HybridRetriever,
NarrativeIndex, and build_narrative_retriever. The library-level default argument is
None — the vector channel is off and retrieval is pure BM25 + RRF. At the service
layer, though, RAGSPINE_EMBEDDING now defaults to auto: the ONNX semantic backend when
[embed-onnx] is importable, else None — so a default install with the extra is genuinely
hybrid out of the box, while a bare install stays byte-identical pure BM25.
vector/embedding_backends.py ships three concrete backends plus a factory:
OnnxEmbeddingBackend
The recommended semantic default (behind [embed-onnx], via fastembed). Model paraphrase-multilingual-MiniLM-L12-v2 (384-dim, multilingual — ZH/EN cross-lingual), offline and deterministic. Selected by onnx / auto; first-pull-then-offline weight download.
DeterministicEmbeddingBackend
Offline lexical-hash backend (blake2b token bucketing + L2 normalize). Zero network/SDK. Its docstring flags it as non-semantic — highly correlated with BM25, no true semantic recall gain.
SentenceTransformerEmbeddingBackend
Default model Qwen/Qwen3-Embedding-0.6B; device auto-detected (cuda → mps → cpu, overridable via RAGSPINE_EMBEDDING_DEVICE). Model is lazily loaded on first embed.
OpenAIEmbeddingBackend
Default model text-embedding-3-large; lazy `import openai`; wraps SDK errors as ProviderError.
from ragspine.retrieval.vector.embedding_backends import make_embedding_backend
# spec (case-insensitive; defaults to env RAGSPINE_EMBEDDING_BACKEND):
# None / "none" → None (pure BM25 + RRF, the zero-dep fallback)
# "auto" → OnnxEmbeddingBackend if [embed-onnx] importable, else None
# "onnx" / "fastembed" / "minilm" → OnnxEmbeddingBackend (semantic, offline, deterministic)
# "deterministic" → DeterministicEmbeddingBackend
# "openai" → OpenAIEmbeddingBackend
# "qwen3" / "st" / "sentence-transformers" → SentenceTransformerEmbeddingBackend
backend = make_embedding_backend("onnx")vector/store.py additionally provides a pluggable VectorStore Protocol
(upsert / query / delete / count) with a zero-dependency InProcessVectorStore
(brute-force cosine, id-ascending tie-break). Note its query honors a where filter but
does not auto-drop RESTRICTED — that removal stays at the two authoritative exits below.
rerank — LLM listwise reranker (RRF fallback)
rerank/listwise_rerank.py reorders the top candidates with an LLM judge, behind the
ListwiseJudge Protocol:
class ListwiseJudge(Protocol):
def judge(self, query: str, candidates: list[str]) -> list[int]: ...The entry point is listwise_rerank(query, results, judge, *, top_n=10) (DEFAULT_TOP_N = 10). Two behaviors matter:
- RESTRICTED exit #1. Candidates whose
chunk.sensitivity(case-insensitively) equals"RESTRICTED"are excluded from the set sent to the judge and held at their original RRF positions — RESTRICTED text never reaches the judge prompt. If every candidate is RESTRICTED, the judge is never called. - RRF fallback. On any judge exception or malformed output, the open subset degrades to
identity (RRF) order.
listwise_reranknever raises.
Supporting pure functions — build_listwise_prompt(query, candidates) and
parse_listwise_response(text, n_candidates) (robust parse to a length-n permutation,
falling back to identity) — make the rerank deterministic and testable without a real model.
link — adapter into the agent (strips RESTRICTED at exit)
link/narrative_link.py is the seam between this domain (the retrieval "B-line") and the
agent (the "A-line"). It adapts a NarrativeIndex to the agent's
NarrativeRetriever contract (which is defined on the agent side, in agent/agent.py).
-
NarrativeIndexRetriever(index, *, retry_without_filters=True)— itsretrieve(query, *, filters=None, top_k=50) -> list[dict]mapsfiltersto metadata kwargs, calls the underlying index, retries once without filters if the filtered result is empty, and returns snippet dicts.RESTRICTED exit #2. The return is built as a comprehension that drops every chunk whose sensitivity equals
"RESTRICTED"before any snippet dict is produced:return [ _to_snippet(r) for r in results if str(r.chunk.sensitivity).upper() != RESTRICTED_SENSITIVITY ]So RESTRICTED text never reaches the LLM synthesis prompt — the same constant (
RESTRICTED_SENSITIVITY = "RESTRICTED") guards both exits. -
ProviderListwiseJudge(provider)— a concreteListwiseJudgebacked by the agent'sLLMProvider. It builds the prompt, makes oneprovider.chat(...)call, and parses the response; provider errors propagate and are caught bylistwise_rerank's degradation. -
build_narrative_retriever(chunk_db, provider=None, *, embedding_backend=None) -> tuple[NarrativeIndexRetriever, ChunkStore]— the CLI/service wiring entry. It opens the chunk store, callsinit_schema, and assembles the default chain: pure BM25 + RRF (no vector backend by default) +GlossaryQueryRewritermulti-query + (whenprovideris given) aProviderListwiseJudgererank. The caller owns closing the store.
A snippet dict carries full provenance: text, doc_id, title, source_locator,
chunk_id, the metadata fields, sensitivity, and a nested scores dict
({"bm25", "vector", "fused"}).
Wiring it up
from ragspine.retrieval.link.narrative_link import build_narrative_retriever
# Default: pure BM25 + RRF + glossary multi-query + (with a provider) listwise rerank.
retriever, store = build_narrative_retriever("data/chunks.db")
try:
snippets = retriever.retrieve("为什么营收下滑", filters={"entity": "ACME_CN"}, top_k=10)
# snippets is RESTRICTED-free and carries full lineage per item
finally:
store.close()Both RESTRICTED exits (rerank/ and link/) must stay. They are the code-enforced half of the
RESTRICTED isolation invariant; removing either one would
let restricted content reach a prompt.
The opt-in capability stack (0.7.0+)
Everything above is the default loop: offline, deterministic, BM25 + RRF (dense on
automatically when [embed-onnx] is present). Releases 0.7.0 and 0.8.0 add a broad set of
mainstream RAG techniques as opt-in layers on the existing Protocol seams. Each is
default-off and byte-identical when unselected, chosen by a make_* factory or the
matching RAGSPINE_* environment variable, and each inherits the RESTRICTED two-exit
isolation and provenance invariants — a new layer never weakens anti-fabrication. They group
by the stage they act on.
The default stays deterministic and offline; selecting a layer is a deliberate opt-in. Numbers always stay in the structured channel — every layer below only shapes narrative retrieval. The per-release list is in the Changelog.
Indexing & chunking
- Contextual Retrieval (
RAGSPINE_CONTEXTUAL/make_index_text_fn) — prepends a deterministic context header (title · entity · period · heading, controlled-vocab, zero fabrication) to the index/embed text only.chunk.text,source_locator, and citations stay byte-identical. - Layout-aware & parent-child (
RAGSPINE_CHUNKER=layout|parent_child) — split on structural boundaries instead of fixed char budget. Children carryparent_id,heading,window_text, andparent_locator; the store persists them and retrieval expands the selected child into separate generation-onlyprompt_text. - Sentence-window & semantic (
RAGSPINE_CHUNKER=sentence_window|semantic) — one chunk per sentence with a synthesis-time window, or embedding-boundary splits (semantic uses[embed-onnx]). - Domain presets — laws / qa / book (
RAGSPINE_CHUNKER=laws|qa|book) — thin layout-aware chunkers that only change heading detection for one document family each: laws starts a section at every clause (第N条/款/项), book at every chapter (第N章/节/篇, or a markdown / numbered heading), and qa pairs each question (Q:/问:/ a?-ending line) with the answer paragraphs that follow under a sharedparent_id. Everything else — the budget,parent_id, and locators — is inherited from the base chunker.
Parent/window expansion never changes citation identity. text, chunk_id, and source_locator
remain the matching child; expanded context uses prompt_text. A RESTRICTED child is dropped
before _to_snippet, together with its window, so a safe-looking parent cannot reintroduce
restricted text.
- RAPTOR multi-granularity tree (
make_raptor_retriever/RAGSPINE_RAPTOR*) — recursive deterministic threshold clustering; per-clusteris_synthesissummaries carry the union of their members' provenance and are never citable facts. Retrieval can pull a leaf (detail) or an internal node (theme).
Representation & rerank
- Semantic dense default (
RAGSPINE_EMBEDDING=onnx|auto,[embed-onnx]) — theOnnxEmbeddingBackendabove;autoresolves to ONNX when importable, else pure BM25, so the shipped loop becomes genuinely hybrid BM25 + dense → RRF with no config. - Local cross-encoder rerank (
RAGSPINE_RERANKER=cross_encoder|ce|auto,[rerank]) — the offline rerank brain (CrossEncoderReranker, ms-marco MiniLM); when selected it takes precedence over the LLM listwise judge. - ColBERT late-interaction (
RAGSPINE_RERANKER=colbert,[colbert]) — token-level multi-vector MaxSim scoring, shipped as a reranker. - SPLADE learned-sparse (
RAGSPINE_RERANKER=splade,[splade]) — neural sparse term-expansion scoring (interpretable like BM25, stronger), shipped as a reranker.
The cross-encoder, ColBERT, and SPLADE rerankers all run inside listwise_rerank, so they
inherit RESTRICTED exit #1 for free — a RESTRICTED candidate never reaches the reranker.
ColBERT / SPLADE ship as rerankers; their multi-vector / sparse retrieval backends (indexes)
are an honest follow-up.
Query transformation
- LLM decomposition (
RAGSPINE_QUERY_DECOMPOSE=llm) — multi-sub-question fan-out; each sub-question re-runs the full guarded pipeline and its answers are deterministically merged. - HyDE · RAG-Fusion · step-back (
RAGSPINE_QUERY_TRANSFORM=hyde|rag_fusion|step_back) — LLM query transforms over the base retriever. RAG-Fusion reusesrrf_fuse; HyDE's hypothetical document is a retrieval probe, never a citable fact. - Adaptive-RAG (
RAGSPINE_ADAPTIVE) — a deterministic-heuristic (or opt-in LLM) complexity classifier routes between the single-shot path and decomposition. - Corrective retrieval / CRAG (
RAGSPINE_CORRECTIVE) — upgrades the loneretry_without_filtersfallback into a bounded (≤2) deterministic grade→act loop (drop-filters → rewrite → refuse); refusing weak context is the anti-fabrication-safe choice.
Every generated variant re-runs the deterministic security gate before retrieval — a competitor sub-query is refused, so home numbers never leak.
Post-retrieval
- MMR · lost-in-the-middle · compression (
RAGSPINE_POSTPROCESSOR, e.g."mmr,lost_in_middle") — a deterministicNodePostprocessorchain that runs after rerank and before prompt assembly: MMR diversity de-dup, lost-in-the-middle reordering (best hits to the head and tail), and extractive context compression. Compression writes a separateprompt_textkey that the agent prefers for the prompt, leavingtextand every reference field byte-identical. The LLMLingua-2 / LLM compressor is a seam follow-up.
Graph and multi-hop
- Structured relation graph (
graph/domain) — a deterministic typed graph over the controlled dimensions for subsidiary roll-up, peer comparison, and derivation tracing (fully cited), plus aGraphStoreProtocol (RAGSPINE_GRAPH_STORE, in-process default +[graph]networkxadapter) and an opt-in narrative-GraphRAG extraction / community skeleton (behind[graph]+[llm]). It is a standalone multi-hop surface, not a router branch — see Channels. - Relation-extractor slot (
RAGSPINE_RELATION_EXTRACTOR,make_relation_extractor) — an opt-in slot besidebuild_relation_graphfor relations that live only in narrative text. The default (None) leaves the base graph byte-identical; the deterministic co-occurrence extractor adds clean-lineageco_occurs_withedges; the LLM extractor (behind[llm]) stamps every edgemodel-derived+unverified, screens both endpoints through theSecurityGate, and never lets RESTRICTED text reach the model. See ADR 0015.
Multimodal
- ColPali visual-document retrieval (
RAGSPINE_VISUAL_EMBEDDER=colpali,[colpali]) — page-as-image late interaction (MaxSim over visual patches, reusing the ColBERT scorer), for chart- and figure-dense reports where OCR→text loses layout. Opt-in and GPU; a visual hit is a page-reference lead (is_visual), never a citable fact, and RESTRICTED pages are dropped at index construction. Real GPU end-to-end is a follow-up (colqwen2is the more permissive model alternative).
Opting in is uniform — inject a factory result, or set the env var and let the service wire it:
from ragspine.retrieval.rerank.cross_encoder import make_reranker
from ragspine.retrieval.postprocess import make_postprocessor
from ragspine.retrieval.link.narrative_link import build_narrative_retriever
# Offline cross-encoder rerank + an MMR / lost-in-the-middle post-chain.
retriever, store = build_narrative_retriever(
"data/chunks.db",
reranker=make_reranker("cross_encoder"), # or RAGSPINE_RERANKER=cross_encoder
postprocessor=make_postprocessor("mmr,lost_in_middle"), # or RAGSPINE_POSTPROCESSOR="mmr,lost_in_middle"
)See also
Dual-channel
How the agent routes between the structured and narrative channels.
RESTRICTED isolation
The two-exit filtering invariant this domain enforces.
Agent
The orchestrator that consumes a NarrativeRetriever and synthesizes cited answers.
Extension points
EmbeddingBackend, ListwiseJudge, NarrativeRetriever, and the other Protocols.
Storage
The sqlite persistence layer — a numeric fact store and a narrative chunk store, both with full source lineage. The Fact dataclass, the dim_key upsert key, and deterministic found/not-found reads.
Agent
The orchestration layer — four-slot intent parsing, the clarification gateway, a deterministic security gate, three-path routing, the tool-use loop, the LLM provider seam, and the per-path anti-fabrication guard.