Hybrid retrieval RAG pipeline (Dense + BM25 + Cross-Encoder Reranker) with conversational memory — built with LangChain LCEL, ChromaDB, and Redis. Systematically benchmarked across 5 retrieval configurations for context retention, cross-contamination, and generation faithfulness.
- Issue: Follow-up questions like "Why does it happen?" or "What is its cost?" suffer from pronoun ambiguity and conversational dependencies. Directly querying the vector database with raw follow-ups leads to severe retrieval degradation and low recall.
- Solution: Implemented a History-Aware Query Rewriter. A dedicated LLM reformulates user queries into standalone search terms using conversation history before querying the vector store.
- Issue: Indexing multiple unrelated documents into a single global index increases semantic noise, causing the retriever to pull irrelevant chunks across document boundaries (cross-contamination).
- Solution: Implemented Metadata-Filtered Retrieval (Scoped Search). Users can scope searches to specific documents, ensuring the retriever operates strictly within target document chunks.
- Issue: Dense (semantic) embeddings fail to retrieve chunks when the ground-truth contains exact lexical terms (acronyms, numbers, domain-specific phrases) that lack strong semantic similarity to the query phrasing. Hit Rate plateaued at 88.46% with pure dense retrieval.
- Solution: Implemented a Hybrid Search (BM25 + Dense) with Reciprocal Rank Fusion (RRF) and a Cross-Encoder Reranker (
cross-encoder/ms-marco-MiniLM-L-6-v2). This three-stage pipeline (Retrieve → Fuse → Rerank) raised Hit Rate to 92.31% and MRR from 0.6179 to 0.7115.
| Layer / Component | Technology | Role & Justification |
|---|---|---|
| Orchestration | LangChain (LCEL) | Composable, pipeline-driven orchestration for query rewriting, retrieval scoping, and synthesis. |
| LLM Engine | OpenAI gpt-4o-mini |
Cost-effective, high-reasoning model for query rewriting, response generation, and evaluation judging. |
| Embeddings | OpenAI text-embedding-3-small |
1536-dimensional dense vector embeddings for semantic chunk representation. |
| Vector Database | ChromaDB | Persistent local vector store with native metadata filtering (source_doc scoping). |
| Sparse Retrieval | BM25s (bm25s) |
Lexical keyword retriever for exact term matching, fused with dense results via RRF. |
| Reranking | Cross-Encoder (ms-marco-MiniLM-L-6-v2) |
Lightweight cross-encoder reranker for precision-optimized re-scoring of top candidates. |
| Document Parsing | PyMuPDF (fitz) |
Fast, structured page-level text extraction and whitespace normalization. |
| Text Splitting | LangChain Recursive Splitter | Context-preserving recursive chunking (chunk_size=1000, chunk_overlap=200). |
| Conversation Memory | Redis | Persistent session-based chat history storage via RedisChatMessageHistory. |
| Schema Validation | Pydantic v2 | Strict JSON schema enforcement for LLM-as-a-Judge structured evaluation outputs. |
| User Interface | Streamlit | Conversational UI with dynamic file management and multi-turn session state. |
flowchart TD
%% INGESTION PIPELINE
subgraph Ingestion["1. Ingestion & Indexing Pipeline (Offline)"]
A[User Uploads PDF] --> B{SHA-256 Hash Check}
B -->|Already Indexed| Skip[Skip Embedding]
B -->|New Document| C[PyMuPDF Loader]
C --> D["RecursiveCharacterTextSplitter<br/>chunk=1000, overlap=200"]
D --> E[OpenAI text-embedding-3-small]
E --> F[(ChromaDB Vector Store)]
D --> G["BM25s Sparse Index<br/>(Tokenize + Stem)"]
end
%% RUNTIME PIPELINE
subgraph Runtime["2. Query & Generation Pipeline (Online)"]
Q[Raw User Query] --> R["History-Aware Query Rewriter<br/>LLM + Redis Session History"]
R --> SQ[Standalone Search Query]
SQ --> Dense["Dense Retriever<br/>(ChromaDB Similarity)"]
SQ --> Sparse["BM25 Retriever<br/>(Lexical Match)"]
F -.-> Dense
G -.-> Sparse
Dense --> RRF["Reciprocal Rank Fusion<br/>(RRF k=60)"]
Sparse --> RRF
RRF -->|"Top-15 Candidates"| Rerank["Cross-Encoder Reranker<br/>ms-marco-MiniLM-L-6-v2"]
Rerank -->|"Top-5 Final"| Ctx[Context Formatter]
Ctx --> Gen["Synthesis Chain<br/>gpt-4o-mini + Prompt"]
Q -.-> Gen
Gen --> Out[Streamed Grounded Response]
end
%% EVALUATION COMPONENT
subgraph Evaluation["3. Quality & Benchmark Layer"]
S["28-Query Test Suite<br/>(20 ST + 2 OOS + 6 MT)"] --> T["Retrieval Eval:<br/>Hit Rate & MRR"]
S --> U["LLM-as-a-Judge:<br/>Faithfulness & Relevance"]
T --> V["5 Experiment Configs<br/>(Dense / BM25 / Hybrid / Rerank)"]
end
- Deduplication Check (SHA-256): Computes the SHA-256 hash of the uploaded PDF to verify whether the document is already ingested. If indexed, redundant embedding generation is bypassed.
- Document Loading & Cleaning: Reads pages using
PyMuPDFLoader, strips extraneous whitespaces, and extracts structured page-level text. - Chunking Strategy: Splits documents using
RecursiveCharacterTextSplitterinto structured chunks with attached metadata (source_doc,page_number). - Dual Indexing: Generates dense embeddings via OpenAI
text-embedding-3-small(persisted in ChromaDB) and builds a BM25 sparse index (persisted viabm25s).
- User Query & Context Ingestion: Captures the raw user prompt alongside conversation history stored in Redis via session state.
- History-Aware Query Rewriting: Passes the chat history and the current prompt to the query rewriter chain, resolving conversational dependencies into an unambiguous, standalone search query.
-
Hybrid Retrieval (Dense + BM25 + RRF): Queries both ChromaDB (dense) and BM25 (sparse) indexes, then merges results using Reciprocal Rank Fusion (
$\text{RRF}_k = 60$ ) to produce a unified candidate list. -
Cross-Encoder Reranking: Scores the top-15 RRF candidates using
cross-encoder/ms-marco-MiniLM-L-6-v2and returns the top-5 most relevant chunks. - Context Formatting & Synthesis: Formats retrieved document chunks with source metadata and injects them into the synthesis prompt.
- Response Generation: The primary LLM generates the grounded response, which is streamed back to the user and appended to the conversation history.
The evaluation suite benchmarks retrieval accuracy and generation quality across 28 structured test queries:
- 20 Single-Turn (In-Scope): Factual, definitional, and reasoning queries mapped to specific document chunks to measure baseline retrieval accuracy.
- 2 Single-Turn (Out-of-Scope): Queries unsupported by the source documents, used to verify hallucination resistance and context boundary enforcement.
- 6 Multi-Turn Queries: Conversational follow-up questions containing pronouns and implicit context references to evaluate query rewriting effectiveness.
- Hit Rate @ k: Binary indicator of whether the ground-truth document chunk appears within the top-$k$ retrieved candidates.
-
MRR (Mean Reciprocal Rank): Evaluates the rank position (
$1/\text{rank}$ ) of the first relevant chunk, measuring how close the correct information is to the top. -
Faithfulness & Relevance (LLM-as-a-Judge): Graded on a 1–5 scale using
gpt-4o-miniwith structured Pydantic outputs. Out-of-scope queries that correctly return explicit refusal ("I do not have enough information") receive a score of 5.
| Experiment | Search Type | Fetch |
Top |
Reranker | Overall Hit Rate | Overall MRR | MT Baseline HR | MT History-Aware HR |
|---|---|---|---|---|---|---|---|---|
| A — Dense ( |
Similarity | 3 | 3 | ✗ | 73.08% (19/26) | 0.5833 | 66.67% | 83.33% |
| A — Dense ( |
Similarity | 5 | 5 | ✗ | 88.46% (23/26) | 0.6179 | 66.67% | 83.33% |
| B — BM25 ( |
BM25 | 3 | 3 | ✗ | 80.77% (21/26) | 0.6859 | 100.0% | 83.33% |
| C — Hybrid ( |
Dense+BM25 (RRF) | 20 | 5 | ✗ | 88.46% (23/26) | 0.6603 | 83.33% | 83.33% |
| D — Hybrid+Rerank (Top 3) | Dense+BM25+CE | 20 | 3 | ✓ | 84.62% (22/26) | 0.6923 | 83.33% | 83.33% |
| E — Hybrid+Rerank (Top 5) | Dense+BM25+CE | 20 | 5 | ✓ | 92.31% (24/26) | 0.7115 | 100.0% | 100.0% |
Selected Configuration: Exp E — Hybrid + Cross-Encoder Rerank (Top 5)
| Experiment | Multi-Turn Baseline MRR | Multi-Turn History-Aware MRR | MRR Δ |
|---|---|---|---|
| A — Dense ( |
0.5000 | 0.5556 | +0.0556 |
| A — Dense ( |
0.5000 | 0.5556 | +0.0556 |
| B — BM25 ( |
0.5556 | 0.5556 | 0.0000 |
| C — Hybrid ( |
0.5833 | 0.6667 | +0.0834 |
| D — Hybrid+Rerank (Top 3) | 0.6667 | 0.6111 | −0.0556 |
| E — Hybrid+Rerank (Top 5) | 0.7083 | 0.6528 | −0.0555 |
| Evaluation Metric | Score (1–5 Scale) | Questions Evaluated | Evaluation Criteria |
|---|---|---|---|
| Faithfulness | 5.0 / 5.0 | 28 / 28 | Zero hallucination; statements strictly grounded in context. |
| Answer Relevance | 5.0 / 5.0 | 28 / 28 | Directly addresses user intent; handles out-of-scope gracefully. |
| Multi-Turn Faithfulness | 5.0 / 5.0 | 6 / 6 | Contextual accuracy maintained across follow-ups. |
| Multi-Turn Relevance | 5.0 / 5.0 | 6 / 6 | Correct intent resolution despite pronoun ambiguity. |
-
Dense-Only Ceiling & Hybrid Breakthrough:
- Pure dense retrieval hit a ceiling at 88.46% Hit Rate across all
$k$ values and chunk configurations. - BM25 alone achieved 80.77% with stronger MRR (0.6859) than Dense k=5 (0.6179), proving that lexical matching outperforms semantics for exact term lookups.
- Hybrid search (RRF fusion) matched Dense Hit Rate while improving MRR to 0.6603, and adding Cross-Encoder Reranking pushed Hit Rate to 92.31% with MRR 0.7115 — a +15% relative improvement over baseline Dense.
- Pure dense retrieval hit a ceiling at 88.46% Hit Rate across all
-
Cross-Encoder Reranking — Precision at the Cost of Recall:
- Reranking with Top 3 (
Exp D) increased MRR to 0.6923 but decreased Hit Rate to 84.62% — the reranker pushed some correctly retrieved chunks below the cutoff. - Expanding to Top 5 (
Exp E) resolved this, achieving best-in-class 92.31% Hit Rate while maintaining 0.7115 MRR, proving Top 5 is the optimal post-rerank window.
- Reranking with Top 3 (
-
History-Aware Query Rewriting:
- Multi-Turn Baseline Hit Rate improved from 66.67% → 83.33% with query rewriting in Dense mode.
- In the optimal configuration (Exp E), both Baseline and History-Aware Hit Rates reached 100.0%, indicating the reranker compensates for some pronoun ambiguity independently.
-
Root-Cause Analysis — 2 Persistent Misses (False Negatives):
-
tc_012("RAG-Sequence vs RAG-Token"): Expected string"different passages per token"exists only in the abstract; the relevant Section 2.1 chunks use paraphrased wording ("predict each target token based on a different document"). The retriever correctly returns the relevant chunk at rank #1 — this is a string-matching evaluation artifact, not a retrieval failure. -
tc_017("626M trainable parameters"): The string"626M trainable parameters"is split across a page boundary ("...626M trainable\n"on page 17,"parameters..."on page 18). The chunk containing"626M trainable"is retrieved at rank #1 — this is a chunking boundary artifact, not a retrieval failure. - Effective Hit Rate (correcting for false negatives): 100% (26/26).
-
multidoc_rag_assistant/
├── app_gui.py # Streamlit conversational UI
├── requirements.txt # Pinned Python dependencies
├── .env.example # Environment variable template
│
├── src/
│ ├── config.py # Centralized configuration & env loading
│ ├── utils.py # History formatting & index management
│ ├── ingestion/
│ │ └── ingestion.py # PDF → Chunk → Embed → ChromaDB + BM25
│ ├── retrieval/
│ │ ├── retriever.py # Unified retriever factory (Dense/BM25/Hybrid)
│ │ ├── bm25.py # BM25s wrapper as LangChain BaseRetriever
│ │ ├── hybrid_search.py # RRF-based HybridRetriever
│ │ └── reranker.py # Cross-Encoder reranking module
│ └── generation/
│ ├── chain.py # LCEL chains (QA, query rewriting, history)
│ └── prompt.py # System prompts for generation & rewriting
│
├── eval/
│ ├── retrieval_evaluation.py # Hit Rate & MRR benchmarking (5 experiments)
│ ├── generation_evaluation.py # LLM-as-a-Judge faithfulness & relevance
│ └── eval_prompt.py # Judge prompt template
│
├── tests/
│ ├── test_chain.py # Unit tests for LCEL chain construction
│ ├── test_ingestion.py # Unit tests for ingestion pipeline
│ ├── test_retriever.py # Unit tests for retriever factory
│ └── test_integration.py # End-to-end integration tests
│
└── data/
├── arxiv1.pdf # Game Theory & CS survey paper
├── arxiv2.pdf # RAG (Lewis et al. 2020) paper
└── ground_truth.json # 28-query evaluation dataset
- Python 3.10+
- OpenAI API Key
- Redis instance (local or remote)
# 1. Clone the repository
git clone https://github.com/denizzozupek/multi-doc-rag-assistant.git
cd multi-doc-rag-assistant
# 2. Create and activate a virtual environment
python -m venv .venv
# On Windows:
.venv\Scripts\activate
# On Linux/macOS:
source .venv/bin/activate
# 3. Install dependencies
pip install -r requirements.txt
# 4. Configure environment variables
cp .env.example .env
# Edit .env and add your keys:
# OPENAI_API_KEY=your-openai-api-key
# REDIS_URL=redis://localhost:6379streamlit run app_gui.py# Run Retrieval Benchmarks (Hit Rate & MRR across all 5 experiments)
python -m eval.retrieval_evaluation
# Run Generation Evaluation (LLM-as-a-Judge)
python -m eval.generation_evaluation-
Evaluation Robustness: Current retrieval evaluation uses strict substring matching, which produces false negatives when expected text is paraphrased or split across chunk boundaries. Transitioning to embedding-based semantic matching would eliminate these artifacts.
-
Scalability Testing: The current benchmark uses 2 academic papers (28 queries). Validating on a larger corpus (10+ documents, 100+ queries) would provide stronger statistical significance for retrieval strategy comparisons.
-
Async & Batch Processing: Converting sequential evaluation and ingestion loops to asynchronous calls (
asyncio) to reduce evaluation latency. -
StateGraph Architecture Migration: Migrating conversation state management from
RunnableWithMessageHistoryto LangGraph checkpointers for advanced multi-agent branching and persistence.
