ADR-035: RAG Pipeline Architecture (LanceDB + Voyage 4 Hybrid Search)¶
Status: Superseded — the agentic_v2/rag/ package this ADR describes was
removed outright; see ADR-057.
Date: 2026-06-17
Amended: 2026-07-28 — reconciled with the as-built code. The original text
described the pipeline as intended; several capabilities it described in the
present tense were never written. The sections below now state what exists and
name what does not. The title is unchanged because ADR-INDEX.md and four other
documents link this ADR by title.
Related: ADR-014 (wire-format contract discipline), ADR-002 (SmartModelRouter), ADR-019 (DAG timeout), ADR-023 (ExecutionKit execution kernel)
Superseded by: ADR-057 — Remove the
agentic_v2.rag Package (Superseded by groundkit)
Context¶
The runtime needs a retrieval-augmented generation (RAG) pipeline that agents (Coder, Architect, Reviewer, Orchestrator) can invoke as a shared tool during multi-step workflows. Without a structured retrieval layer, agents rely entirely on their context window for domain knowledge; long document corpora exceed that window and cannot be updated without retraining.
Several design constraints shape the choice:
- The runtime is async-first (FastAPI + asyncio). Any blocking I/O in the retrieval path stalls the event loop and degrades concurrent workflow throughput.
- Providers, embedding models, and vector stores change. The design must abstract these behind protocols so the runtime is not vendor-locked.
- The existing Pydantic v2 contract discipline (ADR-014) must extend to all RAG data boundaries — configuration, retrieval queries, and results.
- The eval framework already defines rubric-based evaluation. RAG quality metrics (context precision, faithfulness, answer relevancy) must integrate with that framework, not require a separate harness.
- The platform targets regulated environments. Every retrieval step must be fully traceable via OpenTelemetry.
Five local vector stores were evaluated: ChromaDB, FAISS, LanceDB, Qdrant (embedded mode), and Milvus Lite. Five embedding providers were evaluated: Voyage 4, OpenAI text-embedding-3-small, Nomic Embed Text v2, Voyage Code-3, and local sentence-transformers.
Decision¶
Adopt LanceDB as the persistent vector store and LiteLLM as the embedding abstraction layer — Voyage, OpenAI, and a local Ollama model all reached through one code path — with three-stage hybrid retrieval (dense + BM25 → RRF fusion → cross-encoder reranking) and Pydantic v2 typed contracts at every data boundary.
The pipeline is implemented as a peer module at agentic_v2/rag/.
agentic_v2/rag/factory.py is what reads the configuration: it resolves
RAGConfig.vectorstore_type, EmbeddingConfig.provider, and
RerankerConfig.strategy into constructed components, exposed as
build_embedder(), build_vectorstore(), build_reranker(), and
build_rag_components().
Three details differ from the original draft of this ADR and are recorded here rather than quietly corrected, because a reader comparing this document to the code will hit them:
- The agent-facing bridge is
agentic_v2/rag/tools.py(RAGSearchTool,RAGIngestTool), nottools/rag_tool.py— that path does not exist. The bridge is not auto-registered by any tool registry; a caller constructs it. - There is no RAG YAML configuration layer and no
_extendssupport.RAGConfigis constructed in Python and has nofrom_yaml()classmethod. No RAG YAML file exists in the repository. RAGConfig.vectorstore_typedefaults to"memory". LanceDB is opt-in and requiresdb_path(enforced by amodel_validatoronRAGConfig).
Store selection — LanceDB over alternatives:
| Dimension | LanceDB | Qdrant | ChromaDB | FAISS | Milvus Lite |
|---|---|---|---|---|---|
| Async support | Native sync+async | Full native async | HTTP mode only | None | None |
| Type safety | Pydantic-native LanceModel |
Pydantic throughout | Minimal | None | Dict-based |
| Hybrid search | BM25 + built-in rerankers | Sparse+dense fusion | Basic FTS only | None | BM25 sparse |
| Metadata filtering | SQL-like WHERE clauses |
Best-in-class boolean | Basic operators | Post-hoc only | String expressions |
| Persistence | Versioned Lance format | WAL + snapshots | SQLite + segments | Manual file I/O | Single .db file |
LanceDB was selected for its data engineering fluency and modern Python alignment with the existing codebase; Qdrant is the strong runner-up for infrastructure-focused use cases.
What the shipped LanceDBVectorStore (agentic_v2/rag/vectorstore.py) actually
uses is narrower than the evaluation table suggests. It does not use
LanceModel/Vector(768) typed tables and does not use connect_async();
it calls the synchronous LanceDB API and keeps the event loop free by wrapping
each call in asyncio.to_thread. It does not use LanceDB's built-in hybrid
search or its bundled rerankers — hybrid retrieval and reranking are implemented
in this repository (see below). Lance-format versioning on write is a property of
the storage engine and holds regardless.
agentic_v2/rag/vectorstore.py also defines InMemoryVectorStore, which is the
default and has no optional dependency. LanceDBVectorStore is defined only
inside an if _LANCEDB_AVAILABLE: block and is bound to None otherwise, so
importing the package never requires lancedb.
Embedding selection — Voyage with a LiteLLM abstraction:
Voyage was selected as the recommended hosted provider: it topped the RTEB retrieval leaderboard as of early 2026 at $0.06/1M tokens, supports 32K token context windows and Matryoshka dimensionality (256–2048), and offers a shared embedding space across the family so documents indexed with one family member can be queried with another without re-indexing.
The abstraction is LiteLLMEmbedder in agentic_v2/rag/embeddings.py. It
satisfies EmbeddingProtocol and is constructed from an EmbeddingConfig. Two
module-level maps are the whole routing rule, and both are assertable without a
network call:
EmbeddingConfig.provider |
LiteLLM model string | Credential env var |
|---|---|---|
voyage |
voyage/{model_name} |
VOYAGE_API_KEY |
openai |
openai/{model_name} |
OPENAI_API_KEY |
local |
ollama/{model_name} |
none |
litellm |
{model_name} verbatim |
none — LiteLLM resolves its own |
An unrecognised provider raises EmbeddingError; it never degrades to a
placeholder. litellm is imported lazily inside a module-level _load_litellm()
function, so importing agentic_v2.rag — or constructing a LiteLLMEmbedder —
never requires the rag extra; a missing install surfaces as an EmbeddingError
naming pip install -e ".[rag]" at the first embed() call.
Credentials are read with os.getenv at call time, never taken as a constructor
argument, never logged, and scrubbed out of provider error text before it reaches
an EmbeddingError message.
Fallback order — kept, with a constraint the original text did not state.
The documented order is Voyage → OpenAI text-embedding-3-small → local Nomic
Embed Text v2 via Ollama, and FALLBACK_PROVIDER_ORDER in
agentic_v2/rag/factory.py implements exactly that, with the configured provider
leading the chain. It is opt-in (build_embedder(config, fallback=True)),
not the default, for two reasons:
- Different providers embed into different semantic spaces. A vector from
openai/text-embedding-3-smallis not comparable to one fromvoyage/…. Mixing them in a single index degrades retrieval without ever raising. The chain is therefore safe for a fresh index or a read-only query path, and unsafe against an index already populated by another provider.FallbackEmbedderrefuses a mixed chain unless the caller passesallow_mixed_provider_identities=True; the factory passes it and logs a warning naming every model in the chain. FallbackEmbedderrequires one shared dimensionality, so every chain member inheritsEmbeddingConfig.dimensions.LiteLLMEmbedderdoes not forward adimensions=request parameter to LiteLLM, so a provider whose native width differs from the configured value fails the width check and the chain advances. At theEmbeddingConfigdefault of 1536 that means the Voyage and Nomic members cannot succeed. Failing loudly is the right behaviour — a silently truncated or padded vector would corrupt the index — but it makes the chain usable in practice only when every provider natively emits the configured width.
InMemoryEmbedder (also in embeddings.py) remains what it always was: a
deterministic SHA-256 hash expansion, for tests and offline development. No
factory path returns it for a production provider string.
Retrieval architecture — three-stage pipeline:
- Hybrid retrieval. Dense vector search and BM25 sparse search in parallel
via
asyncio.gather. Implemented in this repository —BM25IndexandHybridRetrieverinagentic_v2/rag/retrieval.py— and therefore identical for the in-memory and LanceDB stores. LanceDB's ownquery_type="hybrid"is not used. - RRF fusion. Reciprocal Rank Fusion:
score(d) = Σ 1/(k + rank_i(d)), k=60.reciprocal_rank_fusion()inagentic_v2/rag/retrieval.py. - Cross-encoder reranking. Fused candidates reranked by
CrossEncoderRerankerinagentic_v2/rag/reranking.py, which defaults tocross-encoder/ms-marco-MiniLM-L-6-v2— not the…-L-12-v2this ADR originally named.RerankerConfig.model_namecan supply the L-12 model; the out-of-the-box default is L-6.LLMReranker(LLM-as-judge) andNoOpRerankerare the other two strategies;"none"is the default. ColBERT late-interaction is not implemented and appears nowhere in the codebase — it remains an upgrade path, not a shipped option.
Document ingestion:
- Recursive character splitting at 400–512 tokens with 10–20% overlap as the
default chunking strategy (69% accuracy vs. 54% for semantic chunking, per
February 2026 Firecrawl benchmark). Shipped as
RecursiveChunkerinagentic_v2/rag/chunking.py.SemanticChunkeris not implemented —ChunkingConfig.strategyaccepts"semantic"as aLiteralvalue, but only the recursive chunker exists. - Content-hash keying:
Chunk.content_hashis acomputed_field(SHA-256 of the content) on the contract. The deduplication logic that would use it is not implemented —IngestionPipeline.ingest()loads and chunks, and does not compare hashes against an existing index or skip unchanged chunks. Incremental re-ingestion is a property of the contract, not yet of the pipeline. - Chunk fields carried today:
chunk_id,document_id,chunk_index,content,content_hash, and a free-formmetadatadict. The named metadata keys (source_file,page_number,section_header,ingested_at) are a convention within that dict, not typed fields.
Typed contracts:
Pydantic v2 models at all boundaries. agentic_v2/rag/contracts.py defines
Document, Chunk, RetrievalResult (with a computed_field for
is_high_confidence), and RAGResponse. agentic_v2/rag/config.py defines
ChunkingConfig (with the model_validator ensuring overlap < chunk_size),
EmbeddingConfig, RerankerConfig, and RAGConfig (whose model_validator
requires db_path when vectorstore_type is "lancedb"). Every one is
frozen=True with ConfigDict(extra='forbid') (ADR-014 additive-only
discipline).
Two names this ADR originally listed do not exist: ChunkMetadata (chunk
metadata is a dict[str, Any] on Chunk) and DocumentIngestionRequest (the
overlap validator lives on ChunkingConfig instead). RetrievalQuery also does
not exist as a model; HybridRetriever.retrieve() takes a plain query string and
top_k. And EmbeddingConfig is not a discriminated union — provider is a
flat Literal["openai", "voyage", "local", "litellm"], which is sufficient
because LiteLLMEmbedder takes the same constructor arguments for every
provider.
Resilience:
One layer is implemented, not four. FallbackEmbedder
(agentic_v2/rag/embeddings.py) tries each provider in order and advances to the
next only on EmbeddingError — any other exception propagates, because a
TypeError from a provider is a bug, not a transient fault. When every provider
fails it raises a single EmbeddingError naming all of them.
Explicitly not implemented, and named here so the gap is not rediscovered by reading the old text as fact:
- No retry and no exponential backoff.
tenacityis not a dependency of this package. LiteLLM's ownnum_retriesis not set, so a 429 surfaces as anEmbeddingErrorand advances the fallback chain instead of retrying. - No circuit breaker. Nothing counts consecutive failures; the runtime's
SmartModelRouterbreakers cover chat completions, not this path. - No embedding cache.
- No
EmbeddingResulttype and nosourcefield.embed()returnslist[list[float]]perEmbeddingProtocol, so a caller cannot tell which chain member produced a vector. asyncio.gatherover batches runs withoutreturn_exceptions=True: the first batch failure propagates immediately, but sibling in-flight batches are not cancelled, so a failedembed()can still consume provider quota (bounded byEmbeddingConfig.max_concurrent).
Observability:
agentic_v2/rag/tracing.py defines RAGTracer, which emits string-named
events through an injected TraceAdapter callback — it does not import
opentelemetry and does not open spans. The events are rag.query_start,
rag.embed, rag.search, rag.assemble, rag.query_complete,
rag.ingest_start, and rag.ingest_complete, carrying latency, result counts,
and token counts. query_span() and ingest_span() are context managers over
that event pair, not OTEL spans.
The OTEL span tree described in the original draft (rag.embed_query →
rag.vector_search → rag.rerank → rag.assemble_context →
rag.llm_inference), the cost attributes, and OTLP export to LangSmith or
Langfuse are not implemented. Wiring RAGTracer's adapter to the platform's
existing OTEL exporter is the path to closing that gap; nothing does so today.
Module layout:
Seventeen modules as built (the original draft listed sixteen and named several that do not exist):
agentic_v2/rag/ # peer to agents/, workflows/, tools/
__init__.py # flat public API re-exports
config.py # frozen Pydantic settings (no from_yaml())
contracts.py # Document, Chunk, RetrievalResult, RAGResponse
protocols.py # LoaderProtocol, ChunkerProtocol, EmbeddingProtocol,
# RerankerProtocol, VectorStoreProtocol
errors.py # RAG error hierarchy (RAGError and five subclasses)
loaders.py # document loaders (plain text, Markdown)
chunking.py # RecursiveChunker (only)
ingestion.py # IngestionPipeline: load + chunk
embeddings.py # InMemoryEmbedder, FallbackEmbedder, LiteLLMEmbedder
vectorstore.py # InMemoryVectorStore, LanceDBVectorStore (guarded)
factory.py # config → components; reads vectorstore_type,
# embedding.provider, reranker.strategy
retrieval.py # BM25Index, reciprocal_rank_fusion, HybridRetriever
reranking.py # NoOpReranker, CrossEncoderReranker, LLMReranker
context_assembly.py # token-budget-aware context assembler
memory.py # RAGMemoryStore
tracing.py # RAGTracer: callback events (not OTEL spans)
tools.py # RAGSearchTool / RAGIngestTool bridge
Protocol names are EmbeddingProtocol and VectorStoreProtocol — not
EmbeddingProvider / VectorStore as the original draft wrote them.
Consequences¶
Positive¶
- Agents can reach document corpora that exceed their context window through
RAGSearchTool/RAGIngestToolwithout per-agent integration work. - Async throughout the pipeline: LanceDB's synchronous calls are offloaded with
asyncio.to_threadand LiteLLM's synchronousembeddingentrypoint is offloaded to the default executor when the installed version exposes noaembedding, so no retrieval path blocks the event loop. - Provider switches are a one-line config change —
EmbeddingConfig.providerplusmodel_name— with no new code, becauseLiteLLMEmbedderandbuild_embedder()handle all four provider strings identically. (There is no YAML layer, so "config" here means the Pydantic model, not a file.) - Three-stage retrieval (dense + BM25 → RRF → reranking) achieves up to 67% reduction in retrieval failures over single-stage dense search (per Anthropic contextual retrieval research).
- Pydantic v2 contracts at every boundary catch config typos at construction time
rather than silently at inference time;
extra='forbid'rejects unknown keys. - Optional backends stay optional.
import agentic_v2.ragsucceeds with neitherlitellmnorlancedbinstalled, and requesting a backend that is missing raises a typed error namingpip install -e ".[rag]"rather than anImportErroror aTypeErrorfrom callingNone.
Negative¶
lancedbandlitellmare optional (pip install -e ".[rag]"); the persistent store and every hosted embedding provider are unavailable without the extra. The runtime starts cleanly without it, falling back toInMemoryVectorStore.- No CI job installs the
ragextra.ci.ymlinstalls[dev,server,mcp,langchain,tracing](plusekin one job) andwindows-workflows-ci.ymlrunsuv sync --frozen --extra dev --extra server --extra langchain.litellmandlancedbare therefore absent everywhere in CI, soLiteLLMEmbedderandLanceDBVectorStoreare exercised only against fakes. No automated test anywhere makes a live embedding call. SeeKNOWN_LIMITATIONS.md§4.6. - Voyage is a paid API; the local Ollama fallback degrades embedding quality, and
its endpoint is not configurable —
EmbeddingConfigis frozen withextra='forbid'and carries noapi_base, solocalalways resolves to LiteLLM's own Ollama default. - The fallback chain is opt-in and narrow: it pins every member to one
dimensionality, cannot request a provider-side dimension, and mixes semantic
spaces. See the Embedding selection section above and
KNOWN_LIMITATIONS.md§4.7. - Cross-encoder reranking runs synchronously via
asyncio.to_thread; it adds latency on the retrieval hot path for large candidate sets. It also depends onsentence-transformers, which no extra inpyproject.tomldeclares — a fully installed.[rag]machine still raisesImportErrorunless the caller passespredict_fn=tobuild_reranker(). The"llm"strategy has no default scorer at all.build_rag_components(config)is therefore fully config-driven only forstrategy="none". RerankerConfig.top_kis inert at construction: no reranker takes it as a constructor argument, so a caller must thread it into thererank()call themselves or silently get the library default of 5.- Two implementations do not structurally match their protocol.
NoOpReranker.reranknames its first parameter_querywhereRerankerProtocoldeclaresquery, so keyword calls fail for the default strategy;LanceDBVectorStore.searchnames its third_metadata_filterand absorbsmetadata_filter=into**kwargs, returning unfiltered results rather than erroring. Both are recorded infactory.py's docstrings. - Parent-child chunking is not implemented; only
RecursiveChunkerexists. If added, it would need two LanceDB tables per collection.
Alternatives Considered¶
| Alternative | Disposition |
|---|---|
| ChromaDB | Rejected — sync-only in embedded mode, minimal type safety, prototype-oriented. |
| FAISS | Rejected — zero metadata awareness; a library, not a database. |
| Milvus Lite | Rejected — FLAT index only, sync-only, dict-based API. |
| Qdrant embedded | Viable runner-up; select if infrastructure engineering is the emphasis over data engineering fluency. |
| OpenAI text-embedding-3-small as default | Retained as the first fallback after the configured provider (FALLBACK_PROVIDER_ORDER); not the recommended primary because Voyage outperforms it on RTEB at comparable cost. It is, however, the value EmbeddingConfig.provider and model_name default to, so an unconfigured pipeline uses OpenAI. |
| Single-stage dense retrieval only | Rejected — three-way retrieval (BM25 + dense) consistently outperforms; IBM research confirms this at scale. |
| Synchronous pipeline | Rejected — async-first is a hard requirement per the runtime's concurrency model. |
Implementation¶
The pipeline lives in agentic-workflows-v2/agentic_v2/rag/ (seventeen modules,
listed above). The tool bridge is agentic_v2/rag/tools.py; it is constructed by
a caller, not auto-registered by the tool registry. There is no RAG YAML
configuration layer.
Implemented and covered by tests
LiteLLMEmbedder—voyage,openai,local(Ollama), andlitellmpassthrough, with lazy import, per-provider credential env vars, credential scrubbing, batching, bounded concurrency, order-preserving results, and ten distinctEmbeddingErrorconditions. Tests:agentic-workflows-v2/tests/test_rag_embeddings_litellm.py.factory.py—build_embedder,build_vectorstore,build_reranker,build_rag_components. Tests:agentic-workflows-v2/tests/test_rag_factory.py.- Hybrid retrieval, RRF, in-memory and LanceDB stores, all three rerankers,
chunking, ingestion, context assembly, memory store, tracing — the pre-existing
suites under
agentic-workflows-v2/tests/test_rag_*.py.
Both new suites are written so they pass with the rag extra absent, and
neither uses pytest.importorskip — that would skip silently in CI and leave the
code unproven while the run stayed green. They inject a fake litellm instead,
by monkeypatching the _load_litellm() seam or by setting sys.modules["litellm"]
directly; the factory suite additionally monkeypatches
vectorstore.LanceDBVectorStore to None to exercise the missing-extra error
path regardless of what is installed locally.
The consequence is the one stated in the Negative list: no automated test makes
a live embedding call or exercises a real LanceDB table in CI. The one suite
that does need a real LanceDB, tests/test_vectorstore_lancedb.py, uses
pytest.importorskip("lancedb") and is skipped there.
Not implemented — carried as open work, not as shipped capability: retry /
backoff / circuit breaker / embedding cache; the OTEL span tree and OTLP export;
SemanticChunker; ingestion-time content-hash deduplication; parent-child
chunking; ColBERT reranking; a YAML config layer; and RAG evaluation. There is no
RAGAS or DeepEval integration, no NDCG@10 metric, and no CI gate on Precision@k,
Recall@k, or MRR — those strings appear nowhere in the repository. The
KNOWN_LIMITATIONS.md entries §4.6–§4.10 track the
operational consequences.
See adr/RAG-pipeline-blueprint.md for the research
backing that informed this decision. That document is a pre-implementation
research artifact and describes several capabilities listed above as
unimplemented; read it as rationale, not as a description of the code.