6.0 KiB
0003. Agent hybrid retrieval (dense + dense + sparse + late-interaction rerank)
Status
Accepted
Context
The AI agent needs to retrieve the most relevant chunks for a query against
the chunks collection defined in ADR-0001, which already provisions four
named vectors — dense_nomic, dense_openai, sparse, and
late_interaction — for exactly this purpose. The goal is higher relevance
than single-dense-vector search by combining two independent semantic
signals with lexical (sparse) retrieval, then refining the result with a
late-interaction (ColBERT-style) rerank.
Qdrant's Query API supports doing this as a single server-side request via
prefetch stages, fusion, and a final rerank stage — avoiding a client-side
fan-out of multiple separate queries. It also explicitly cautions that
hybrid search/reranking can mask underlying embedding-model quality issues
rather than fix them, so it shouldn't be adopted without a baseline
comparison.
Both dense models are now fixed: dense_nomic uses nomic-embed-text-v2-moe
(0004), which requires a
search_query: task prefix on the embedded query text (mirroring
search_document: on the ingestion side); dense_openai uses the OpenAI
large embedding model, no prefix convention needed. sparse uses
bm25-fa-norm-stop (ADR-0001). The late-interaction/reranker model is now
decided — jina-colbert-v2 — see 0005
for the model comparison, the GPU dependency it introduces, and its
unresolved commercial-license status.
Decision
Query shape
A single Qdrant Query API request per agent query, structured as:
- Prefetch 1 — dense ANN: search the
dense_nomicvector, top ~100–200 candidates. - Prefetch 2 — dense ANN: search the
dense_openaivector, top ~100–200 candidates. - Prefetch 3 — sparse ANN: search the
sparsevector, top ~100–200 candidates. - Fusion: combine all three prefetch results via RRF (Reciprocal Rank Fusion) as the default — per Qdrant's own guidance, RRF "ignores score magnitude, a decent default to start with" and handles the incomparable score scales across two dense models and a sparse model.
- Rerank: apply late-interaction (
late_interactionmultivector, max-sim) reranking over the fused top-N (not the full collection) to produce the final top-k returned to the agent, usingjina-colbert-v2(0005).
Every prefetch and the final query carries the same tenant_id/domain
filter from ADR-0001's payload schema — identical isolation guarantee to
ADR-0002.
Context-window expansion
For each chunk returned by the fused/reranked query, the agent may expand
its context by pulling the immediately preceding and following chunks before
building the prompt. This uses the previous_chunk_id/next_chunk_id
pointer fields from ADR-0001 — a single batch "retrieve points by ID" call
per result chunk, not an additional filtered search. Since this runs on every
returned chunk, it relies on those pointers being kept correct by ADR-0002's
CRUD mutation logic (reorder/insert/delete).
Bounding rerank cost
Late-interaction rerank is the most compute-expensive stage. It is applied only to the fusion's top-N output (e.g. top 50), never to the full candidate set or the full collection, to keep latency bounded.
Evaluate before enabling in production
Before turning this on for real traffic, run an evaluation comparing hybrid
- rerank against a dense-only baseline on representative queries. Qdrant's own guidance warns against adopting hybrid search prematurely, since it can paper over embedding-model quality problems instead of addressing them. If the baseline is already good, the added complexity/cost of this pipeline should be justified with evidence, not assumed.
Consequences
Positive
- Single round-trip server-side fusion + rerank — lower latency than orchestrating multiple queries and fusing client-side in FastAPI.
- Directly reuses the vector schema and tenant isolation already established in ADR-0001/0002 — no divergent data model for the agent path.
- Bounding rerank to the fused top-N keeps the expensive late-interaction step's cost predictable regardless of collection size.
Negative
- Late-interaction multivectors are storage/compute heavy; with
jina-colbert-v2chosen (0005) this stage now also requires GPU capacity (flash_attn/CUDA), not just the Docker deployment ADR-0001 assumed. - RRF is score-magnitude-agnostic by design — if a use case later needs
absolute score information (e.g. a relevance threshold), a different
fusion method (DBSF or a custom
FormulaQuery) would need to be evaluated separately. - Adds a mandatory evaluation step before production rollout, rather than shipping hybrid search immediately.
- Three prefetch stages instead of two (two dense + one sparse) means every
agent query embeds the question with both
dense_nomicanddense_openai— an extra embedding call and an external OpenAI API dependency on the query's critical path, not just at ingestion time.
Alternatives Considered
- Client-side fan-out (separate dense and sparse queries against Qdrant, manual RRF fusion in FastAPI): rejected — more round trips and latency than Qdrant's native server-side Query API fusion, for no functional benefit.
- Cross-encoder reranking via FastEmbed instead of late-interaction: not adopted as the initial decision, since ADR-0001 already reserves a multivector field for late-interaction rerank; noted as a possible additional future stage rather than a replacement.
- DBSF or FormulaQuery as the default fusion method: rejected as the starting point — RRF is simpler and Qdrant's recommended default; DBSF/ FormulaQuery remain available as a later optimization if empirical evaluation shows RRF underperforming.