Collection Retrieval
Retrieval is how an indexed collection turns a natural-language query into the handful of file chunks a chatbot puts in front of its LLM. Contextual retrieval has its own feature flag; hybrid search and reranking share another. Both are off by default, so a deployment that enables neither behaves exactly as OCS did before any of this existed.
apps.documents.retrieval.search_collection is the single entry point. Both callers go through
it — the chat search tools (SearchIndexTool, SearchCollectionByIdTool in
apps/chat/agent/tools.py) and the collection query preview in apps/documents/views.py — so
"what retrieval means" is defined once.
This page covers the local index path (Collection.is_remote_index=False). Remote indexes
delegate storage and search to the provider's vector store, so none of these stages apply to
them; see Index Managers.
The pipeline
query ──► dense (pgvector) ─┐
├─► RRF fusion ──► rerank ──► top_k chunks
──► lexical (Postgres FTS) ─┘
| Stage | Flag | Where |
|---|---|---|
| Contextual chunk headers (indexing time) | flag_contextual_retrieval |
apps/service_providers/llm_service/contextualizer.py |
| Lexical search and RRF fusion | flag_hybrid_search |
apps/documents/retrieval.py |
| Reranking | flag_hybrid_search plus provider/model configuration |
apps/documents/rerankers.py |
Both are team-aware waffle flags (see Feature Flags), so they can be enabled per team, by percentage, or globally, and rolled back without a deploy.
Indexing: contextual chunk headers
A chunk embedded in isolation carries no signal about which document it came from — "revenue grew
3% over the previous quarter" does not say whose revenue or which quarter. When contextual
retrieval is on, LocalIndexManager._embed_file asks a chat model for a short header situating
each chunk in its document, stores it on FileChunkEmbedding.context, and embeds
context + "\n\n" + text rather than the chunk alone. FileChunkEmbedding.text is never
mutated: it stays the source of truth, and contextualized_text composes the two.
The document is placed in the system prompt so provider prompt caching can reuse it across every chunk of the same file. A failed contextualizer call logs and returns an empty header, so a file still indexes.
Retrieval: dense and lexical, fused
Dense search is the original behaviour: cosine distance between the query embedding and each
chunk's embedding, over the HNSW index.
Lexical search matches FileChunkEmbedding.search_vector, a tsvector built from context and
text at indexing time and covered by a GIN index. It is built with the collection's own
search_language, and queries are parsed with the same one — a chunk indexed as spanish and
queried as english matches nothing at all, which is indistinguishable from having no lexical
hits. Changing search_language therefore requires re-indexing the collection.
The two rankings are combined with weighted Reciprocal Rank Fusion:
score(chunk) = w / (k + rank_dense) + (1 - w) / (k + rank_lexical)
k is settings.DOCUMENT_SEARCH_RRF_K (60) and w is Collection.search_dense_weight (0.7).
Ranks are fused rather than scores because cosine distances and ts_rank_cd values live on
incomparable scales, so score-level fusion would need per-query normalization. A chunk found by
only one of the two simply gets no contribution from the other; no imputation is involved.
Collection.search_fetch_k (40) is how many candidates each side contributes to the fusion.
Reranking
Neither dense nor lexical search ever looks at a query and a chunk together: an embedding is
computed before the query exists, and ts_rank_cd only counts term overlap. A reranker scores
the pair directly, which is why it can reorder candidates that fusion ranked purely on how each
half happened to retrieve them.
When reranking is active, search_collection widens its candidate pool to
Collection.rerank_top_n (50), scores each (query, contextualized_text) pair, and returns the
best top_k. Reranked chunks carry a rerank_score.
Only a hosted reranker is implemented, backed by Voyage AI. Voyage rather than another provider
because OCS already ships the voyageai client (a dependency of langchain-voyageai, used for
Voyage embeddings) and already models Voyage credentials as an LlmProvider, so reranking adds
no third-party dependency and no new provider type. A local cross-encoder and Cohere are both
left as follow-ups, since each would add one.
The stage can only improve the ranking. A reranker that errors, times out, or answers with
something that does not describe the candidate list it was sent leaves the search with the
ranking it already had, logs, and returns that. Collection.get_reranker() likewise returns
None for every reason not to rerank rather than raising.
Collection.reranking_enabled answers whether the stage will run at all. The hybrid-search flag
must be active and both reranker_provider and rerank_model must be present. It answers from the
loaded collection instance without fetching the provider row.
Context conditioning
search_collection accepts an optional context — the recent conversation turns, which
LangGraph injects into the chat search tools as part of the graph state. It is prepended to the
reranker's query so the reranker can tell which of several similar chunks answers the question
actually being asked: "how much does it cost" scores differently once the turn before it is
visible. Only the reranker reads it, so the tools skip formatting it unless reranking is active
for the collection.
The context is clipped to its tail (MAX_RERANK_CONTEXT_CHARS) because the recent turns are the
ones that disambiguate, and because the provider clips the query-document pair to the model's
context window — an unbounded context would push the query itself out of that window.
Enabling reranking for a collection
- Enable
flag_hybrid_searchfor the team. - Create a Voyage AI LLM provider for the team, if it does not already have one.
- On the collection, set
reranker_providerto that provider.rerank_modeldefaults torerank-2andrerank_top_nto 50.
All three are required. Leaving the provider unset leaves reranking off, silently by design; the failures that do log are the ones an operator cannot predict, such as a provider with no rerank endpoint, credentials the provider rejects, or a provider belonging to another team.
That last one is a runtime refusal rather than a form constraint: reranker_provider is
editable in the Django admin, which offers every team's providers, so get_reranker() checks
the provider's team itself. Reranking with another team's credentials would bill them and send
this collection's queries to their account.
The tuning fields (search_language, search_dense_weight, search_fetch_k, reranker_provider,
rerank_model, rerank_top_n) are model fields with no form or pipeline
node UI, to keep the node's configuration surface small while the defaults are being proven. The
node's existing max_results is what controls the final top_k.
Because nothing calls full_clean() on these fields, their validators never fire and the
database check constraints on Collection are the only thing that actually stops a bad value.
Cost and latency
Contextualization is paid once per chunk at indexing time, never at query time. Hybrid search
adds one GIN-index query and an in-memory fusion over at most 2 * search_fetch_k rows.
Reranking is the only stage with a per-query external cost: one provider call scoring
rerank_top_n candidates, which is what bounds it.