Retrieval Pipeline¶
The retrieval pipeline takes a natural language query and returns the most relevant document chunks from Qdrant. It uses the LLM to automatically extract metadata filters from the query so results are scoped to the right company, year or document type without the user having to pass filters manually.
Step-by-Step Flow¶
flowchart TD
A(["query: str\ntop_k: int\nfilters: dict or None"])
A --> B{filters\nprovided?}
B -->|"Yes (explicit)"| E
B -->|"No (auto)"| C
C["Fetch stored values\n_stored_values_cache\n(one Qdrant call if cache empty)"]
C --> D["LLM extracts filters from query\n_extract_filters_from_query()\nLLM sees actual stored values\ne.g. company_name: ['apple inc.', 'microsoft']"]
D --> D2{Filters\nextracted?}
D2 -->|"Yes"| D3["Normalize values to lowercase\ncompany_name: 'Apple' → 'apple inc.'"]
D2 -->|"No match / error"| E
D3 --> E
E["Build Qdrant Filter\n_build_qdrant_filter(filters)\nfield=value → FieldCondition\nlist=[v1,v2] → should (OR logic)"]
E --> F["Build search_kwargs\nk=top_k, or fetch_k when reranking\nfilter=qdrant_filter"]
F --> G["vectorstore.as_retriever()\nsearch_type: similarity / mmr / hybrid"]
G --> H["Embed query\nEmbeddingModel.embed_query(query)\n→ dense vector"]
H --> I{search_type}
I -->|similarity| J["Cosine similarity search\ndense vectors only"]
I -->|mmr| K["MMR search\ndense vectors\nfetch_k=20 candidates\nre-rank for diversity"]
I -->|hybrid| L["Hybrid search\ndense + sparse (BM25)\nRetrievalMode.HYBRID\nRRF fusion"]
J --> N
K --> N
L --> N
N{reranker\nconfigured?}
N -->|No| M
N -->|Yes| O["Score every candidate against the query\nCrossEncoder or Cohere Rerank\nsort, cut to top_k\nattach metadata['rerank_score']"]
O --> M
M(["List[Document]\npage_content + metadata"])
M --> P{"called via\nquery()?"}
P -->|No| Q(["returned to the caller"])
P -->|Yes| R["Build numbered source block\nwithin generation.max_context_chars"]
R --> S["LLM answers from those sources only\nor returns the refusal sentinel"]
S --> T(["Answer\ntext + citations + confidence"])
Two stages here are off unless configured. Without a retriever.rerank block
the reranking node is skipped entirely and the vector store's own top_k is
returned. query() adds the last three nodes; retrieve() stops at the
document list.
The candidate count matters: with reranking active, first-stage retrieval is
asked for fetch_k documents rather than top_k, because a reranker can only
reorder what it is handed. Under MMR, that pool is widened too, since MMR
selects k out of its own fetch_k and could not otherwise supply the
candidates the reranker was asked to score.
Auto-Filter Extraction Detail¶
When no filters are passed, the LLM is shown the exact values stored in Qdrant and asked to match:
sequenceDiagram
participant R as retrieve()
participant C as _stored_values cache
participant Q as Qdrant facet API
participant L as LLM
R->>C: _stored_values (cache miss?)
alt cache empty
C->>Q: facet(company_name, doc_type, fiscal_quarter, fiscal_year)
Q-->>C: {company_name: ['apple inc.', 'microsoft'], ...}
C-->>R: stored values dict
else cache hit
C-->>R: stored values dict (no Qdrant call)
end
R->>L: prompt with stored values + user query
Note over L: "Available values:\n company_name: ['apple inc.', 'microsoft']\nQuery: What is Apple's Q1 2025 revenue?"
L-->>R: {"company_name": "apple inc.", "fiscal_quarter": "q1", "fiscal_year": 2025}
R->>R: normalize to lowercase
R->>R: build Qdrant filter
Filter Building Logic¶
flowchart TD
F["filters dict\ne.g. {company_name: 'apple inc.', fiscal_year: [2023, 2024]}"]
F --> Loop["For each key: value"]
Loop --> TypeCheck{value type}
TypeCheck -->|"str or int\ne.g. company_name: 'apple inc.'"| Single["FieldCondition\nkey=metadata.company_name\nmatch=MatchValue('apple inc.')"]
TypeCheck -->|"list\ne.g. fiscal_year: [2023, 2024]"| Multi["Filter(should=[\n FieldCondition(match=2023),\n FieldCondition(match=2024)\n])\n→ OR logic within field"]
Single --> Must["Filter(must=[...])\n→ AND logic across fields"]
Multi --> Must
Must --> Qdrant[("Qdrant filtered search\ncompany='apple inc.'\nAND (year=2023 OR year=2024)")]
Hybrid Search Internals¶
flowchart LR
Query["Query text"]
Query -->|"embed_query()"| Dense["Dense vector\n(semantic meaning)\nOllama / OpenAI / etc."]
Query -->|"FastEmbedSparse()"| Sparse["Sparse vector\n(BM25 keyword weights)\nFastEmbed"]
Dense --> Qdrant[("Qdrant\nRetrievalMode.HYBRID")]
Sparse --> Qdrant
Qdrant -->|"Reciprocal Rank Fusion"| RRF["RRF score\n= dense_rank + sparse_rank"]
RRF --> Results["Top-K ranked chunks"]
Hybrid search is only active when use_sparse: true in config and fastembed is installed. If fastembed is missing, RAGWire falls back to dense-only search with a warning.
Stored Values Cache Lifecycle¶
stateDiagram-v2
[*] --> Empty : RAGWire() init
Empty --> Populated : First retrieve() or extract_metadata() call\n→ Qdrant facet API called
Populated --> Populated : Subsequent retrieve() calls\n(no Qdrant call, cache hit)
Populated --> Empty : ingest_documents() completes\n_stored_values_cache = None
Empty --> Populated : Next retrieve() call\n→ Qdrant facet API called again\n(now includes newly ingested data)