Vamshi Pokala commited on
Commit
ce71763
Β·
1 Parent(s): 7d14dfa

Redesigned the README and added Citiation and truthfullness in responses

Browse files
.coverage ADDED
Binary file (53.2 kB). View file
 
.github/workflows/ci.yml CHANGED
@@ -108,3 +108,37 @@ jobs:
108
  with:
109
  name: eval-report
110
  path: evals/reports/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  with:
109
  name: eval-report
110
  path: evals/reports/
111
+
112
+ evals-golden:
113
+ name: Golden evals (Anthropic Haiku)
114
+ if: ${{ secrets.ANTHROPIC_API_KEY != '' }}
115
+ runs-on: ubuntu-latest
116
+ env:
117
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
118
+ steps:
119
+ - uses: actions/checkout@v4
120
+
121
+ - uses: actions/setup-python@v5
122
+ with:
123
+ python-version: "3.13"
124
+ cache: pip
125
+
126
+ - name: Install dependencies
127
+ run: pip install -r requirements/base.txt
128
+
129
+ - name: Run golden evals (live pipeline, Anthropic Haiku)
130
+ run: |
131
+ PYTHONPATH=. python -m evals.run_evals \
132
+ --dataset evals/datasets/golden_ci.jsonl \
133
+ --judge-provider anthropic \
134
+ --judge-model claude-haiku-4-5 \
135
+ --output evals/reports/ \
136
+ --faithfulness-threshold 0.7 \
137
+ --correctness-threshold 0.2
138
+
139
+ - name: Upload golden eval report
140
+ if: always()
141
+ uses: actions/upload-artifact@v4
142
+ with:
143
+ name: eval-report-golden
144
+ path: evals/reports/
README.md CHANGED
@@ -12,60 +12,171 @@ license: mit
12
 
13
  # Doc-Ingestion
14
 
15
- Citation-aware RAG system for ingesting documents and generating grounded answers with truthfulness scores β€” CLI, API, and Streamlit UI.
16
 
17
- > **[Try the live demo on Hugging Face Spaces](https://huggingface.co/spaces/vampokala/doc-ingestion)** β€” no install required.
18
 
19
- ## What it does
20
 
21
- - Ingests `.pdf`, `.docx`, `.txt`, `.md`, `.html` files into a hybrid BM25 + vector index
22
- - Retrieves using Reciprocal Rank Fusion across sparse and dense search, with optional cross-encoder reranking
23
- - Generates answers via any LLM provider (Ollama, OpenAI, Anthropic, Gemini) with citation tracking
24
- - Scores every response for **truthfulness** (NLI faithfulness + citation groundedness)
25
- - Exposes a FastAPI backend and a Streamlit UI
26
 
27
- ---
28
 
29
- ## Quickstart
30
 
31
- ### Option 1 β€” Try online (no install)
32
 
33
- Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are pre-loaded. Paste your OpenAI, Anthropic, or Gemini key in the sidebar.
34
- In hosted demo mode (`DOC_PROFILE=demo`), Streamlit executes queries in-process through the shared orchestrator so demo usage is not blocked by localhost API startup races.
35
 
36
- ---
 
 
 
 
 
 
 
37
 
38
- ### Option 2 β€” Run locally with Docker (one command)
39
 
40
- ```bash
41
- git clone https://github.com/vampokala/Doc-Ingestion
42
- cd Doc-Ingestion
43
- cp docker/.env.example docker/.env
44
- # Edit docker/.env to add your API keys (OPENAI_API_KEY etc.)
45
- docker compose -f docker/docker-compose.yml up
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  ```
47
 
48
- Open http://localhost:8501 (Streamlit UI) or http://localhost:8000 (API).
49
 
50
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- ### Option 3 β€” Run from source (Python venv)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  ```bash
55
  git clone https://github.com/vampokala/Doc-Ingestion
56
  cd Doc-Ingestion
57
- bash scripts/bootstrap_demo.sh # creates venv, installs deps, ingests sample docs
 
 
58
  ```
59
 
60
- The script pulls Ollama models automatically if Ollama is installed.
61
- To use a cloud provider instead, skip Ollama and set:
 
62
 
63
  ```bash
64
- export OPENAI_API_KEY=...
65
- # or ANTHROPIC_API_KEY / GEMINI_API_KEY
 
66
  ```
67
 
68
- Then start:
69
 
70
  ```bash
71
  source .venv/bin/activate
@@ -73,80 +184,16 @@ source .venv/bin/activate
73
  # API server
74
  PYTHONPATH=. uvicorn src.api.main:app --reload --port 8000
75
 
76
- # Streamlit UI (second terminal)
77
  PYTHONPATH=. streamlit run src/web/streamlit_app.py
78
 
79
- # Or query from CLI
80
  PYTHONPATH=. python -m src.query "What is RAG?"
81
  ```
82
 
83
- Note: non-demo local mode keeps the standard split architecture (Streamlit calls FastAPI over HTTP), so running both API and UI processes is still required.
84
 
85
- ---
86
-
87
- ## Features
88
-
89
- - Multi-format ingestion (PDF, DOCX, TXT, MD, HTML)
90
- - Hybrid retrieval β€” BM25 + vector with weighted RRF fusion
91
- - Optional cross-encoder reranking (`cross-encoder/ms-marco-MiniLM-L-6-v2`)
92
- - Multi-provider LLM routing: Ollama (local), OpenAI, Anthropic, Gemini β€” switchable per request
93
- - Citation tracking and per-citation verification
94
- - **Inline truthfulness scoring** on every response (NLI faithfulness + citation groundedness)
95
- - **Offline eval harness** β€” RAGAS-style metrics over a golden dataset
96
- - FastAPI with auth, rate limiting (Redis or in-memory), streaming SSE
97
- - Streamlit UI with per-request provider/model switching and truthfulness badge
98
-
99
- ---
100
-
101
- ## Architecture
102
-
103
- <details>
104
- <summary>System diagram</summary>
105
-
106
- ```mermaid
107
- flowchart LR
108
- userClient[UserClient] --> cliLayer[CLI]
109
- userClient --> apiLayer[FastAPI]
110
- userClient --> streamlitUi[StreamlitUI]
111
- cliLayer --> orchestrator[RAGOrchestrator]
112
- apiLayer --> orchestrator
113
- streamlitUi --> orchestrator
114
- orchestrator --> hybridRetriever[HybridRetriever]
115
- hybridRetriever --> reranker[CrossEncoderReranker]
116
- reranker --> contextOptimizer[ContextOptimizer]
117
- contextOptimizer --> generator[RAGGenerator]
118
- generator --> citationTracker[CitationTracker]
119
- citationTracker --> citationVerifier[CitationVerifier]
120
- citationVerifier --> truthfulness[TruthfulnessScorer]
121
- generator --> llmRouter[LLMProviderRouter]
122
- llmRouter --> ollamaProvider[Ollama]
123
- llmRouter --> openaiProvider[OpenAI]
124
- llmRouter --> anthropicProvider[Claude]
125
- llmRouter --> geminiProvider[Gemini]
126
- orchestrator --> bm25Store[BM25Index]
127
- orchestrator --> vectorStore[ChromaOrQdrant]
128
- ```
129
- </details>
130
-
131
- <details>
132
- <summary>Query flow</summary>
133
-
134
- ```mermaid
135
- flowchart TD
136
- q[UserQuery] --> retrieve[HybridRetrieve]
137
- retrieve --> fuse[RRFWeightedFusion]
138
- fuse --> rerank[RerankOptional]
139
- rerank --> prompt[BuildPrompt]
140
- prompt --> llm[ProviderModelSelectedPerRequest]
141
- llm --> cite[ExtractAndVerifyCitations]
142
- cite --> truth[TruthfulnessScore]
143
- truth --> response[APIorCLIorUIResponse]
144
- ```
145
- </details>
146
-
147
- ---
148
-
149
- ## API usage
150
 
151
  ```bash
152
  uvicorn src.api.main:app --reload --port 8000
@@ -159,7 +206,7 @@ curl -X POST http://127.0.0.1:8000/query \
159
  -d '{"query": "What is hybrid retrieval?", "provider": "ollama", "model": "qwen2.5:7b"}'
160
  ```
161
 
162
- Response includes a `truthfulness` block:
163
 
164
  ```json
165
  {
@@ -170,45 +217,34 @@ Response includes a `truthfulness` block:
170
  "uncited_claims": 1,
171
  "score": 0.89
172
  },
173
- "citations": [...]
174
  }
175
  ```
176
 
177
  Endpoints: `GET /health`, `GET /metrics`, `POST /query`, `POST /query/stream` (SSE).
178
 
179
- ---
180
-
181
  ## Evaluation
182
 
183
- ### Inline (every response)
184
-
185
- Every `/query` response includes a `truthfulness` object with:
186
 
187
  | Field | What it measures |
188
- |-------|-----------------|
189
- | `nli_faithfulness` | Fraction of response sentences entailed by the retrieved chunks (NLI model) |
190
  | `citation_groundedness` | Mean citation verification score |
191
- | `uncited_claims` | Count of sentences without a citation marker |
192
- | `score` | Weighted aggregate (60% NLI + 40% groundedness) |
193
-
194
- The Streamlit UI renders a coloured badge: 🟒 β‰₯ 0.8 / 🟑 0.5–0.8 / πŸ”΄ < 0.5.
195
 
196
- ### Offline batch evaluation
197
-
198
- Run the RAGAS-style harness against the included golden dataset:
199
 
200
  ```bash
201
- # Install eval extras
202
  pip install -r requirements/eval.txt
203
 
204
- # Run against full dataset (needs a running LLM)
205
  PYTHONPATH=. python -m evals.run_evals \
206
- --dataset evals/datasets/sample.jsonl \
207
- --judge-provider ollama \
208
- --judge-model qwen2.5:7b \
209
  --output evals/reports/
210
 
211
- # Smoke test (no LLM required β€” for CI / quick check)
212
  PYTHONPATH=. python -m evals.run_evals \
213
  --dataset evals/datasets/smoke.jsonl \
214
  --mock \
@@ -216,26 +252,30 @@ PYTHONPATH=. python -m evals.run_evals \
216
  --output evals/reports/
217
  ```
218
 
219
- Reports are written to `evals/reports/` as JSON + Markdown.
220
 
221
- ---
222
 
223
- ## Project map
224
-
225
- | Path | Purpose |
226
- |------|---------|
227
- | `src/core/` | Retrieval, reranking, generation, citations, orchestration |
228
- | `src/api/` | FastAPI models and routes |
229
- | `src/web/` | Streamlit UI and ingestion service |
230
- | `src/evaluation/` | Truthfulness scorer, generation and retrieval metrics |
231
- | `src/utils/` | Config and vector database integrations |
232
- | `evals/` | Offline eval harness, golden datasets, RAGAS adapter |
233
- | `data/sample/` | Pre-ingested sample documents for demos |
234
- | `spaces/` | Hugging Face Spaces deployment files |
235
- | `docker/` | Docker Compose stack (API + Streamlit + Redis + Qdrant) |
236
- | `Docs/` | Roadmap, runbook, and phase documentation |
237
 
238
- ---
 
 
 
 
 
 
 
 
239
 
240
  ## Development
241
 
@@ -250,22 +290,13 @@ Multi-provider API key environment variables:
250
  export OPENAI_API_KEY=...
251
  export ANTHROPIC_API_KEY=...
252
  export GEMINI_API_KEY=...
 
253
  ```
254
 
255
- ---
256
-
257
  ## Troubleshooting
258
 
259
  - **Empty results after ingest:** Run `python -m src.ingest --docs data/documents` and verify `data/embeddings/` exists.
260
  - **Embedding model error:** Ensure Ollama is running and `nomic-embed-text` is pulled, or switch to a different embedding provider in `config.yaml`.
261
  - **Dimension mismatch after model change:** Re-ingest all documents to rebuild the vector index.
262
  - **Cloud provider fails:** Check the relevant `*_API_KEY` env var is set.
263
- - **Truthfulness score always 0:** The NLI model (`cross-encoder/nli-deberta-v3-small`) downloads on first use (~140 MB). Check internet access or set `evaluation.inline_enabled: false` in `config.yaml` to disable.
264
-
265
- ---
266
-
267
- ## Documentation
268
-
269
- - [Production Runbook](Docs/RUNBOOK.md)
270
- - [Roadmap](Docs/ROADMAP.md)
271
- - [Project overview](Docs/PROJECT_OVERVIEW.md)
 
12
 
13
  # Doc-Ingestion
14
 
15
+ Doc-Ingestion is a citation-aware RAG system that turns private document collections into grounded question-answering experiences. It demonstrates how to ingest documents, retrieve the right evidence, generate answers from that evidence, and return citations plus truthfulness signals through a Streamlit app, FastAPI service, and CLI.
16
 
17
+ > **[Try the live demo on Hugging Face Spaces](https://huggingface.co/spaces/vampokala/doc-ingestion)** - no install required.
18
 
19
+ ## Why This Project Exists
20
 
21
+ Most teams have knowledge scattered across PDFs, Word docs, markdown notes, text files, and HTML exports. Traditional search can find matching words, but it does not synthesize answers. Generic LLMs can synthesize answers, but they may not know what is inside your documents and can hallucinate without evidence.
 
 
 
 
22
 
23
+ This project solves that gap: ingest your documents, ask natural-language questions, and receive answers grounded in retrieved source chunks with citations and quality signals.
24
 
25
+ ## What It Showcases
26
 
27
+ For non-technical reviewers, this is a working document Q&A product: load documents, ask questions, inspect answers, and verify sources.
28
 
29
+ For technical reviewers, this is an end-to-end RAG reference implementation with:
 
30
 
31
+ - Multi-format ingestion for `.pdf`, `.docx`, `.txt`, `.md`, and `.html`
32
+ - Token-aware chunking and persistent document indexes
33
+ - Hybrid retrieval using BM25 keyword search plus vector search
34
+ - Weighted Reciprocal Rank Fusion (RRF) across sparse and dense results
35
+ - Optional cross-encoder reranking for stronger final context
36
+ - Multi-provider LLM routing across Ollama, OpenAI, Anthropic, and Gemini
37
+ - Citation tracking, citation verification, and inline truthfulness scoring
38
+ - FastAPI, Streamlit, CLI, Docker, Redis-backed rate limiting, and offline evals
39
 
40
+ ## Product Capabilities
41
 
42
+ This is the user-facing flow: documents become a searchable knowledge base, and users ask questions against that knowledge base instead of relying on ungrounded model memory.
43
+
44
+ ```mermaid
45
+ flowchart LR
46
+ subgraph userLayer [User Experience]
47
+ upload[Upload Or Select Documents]
48
+ ask[Ask Natural Language Questions]
49
+ review[Review Answer With Citations]
50
+ end
51
+
52
+ subgraph knowledgeLayer [Knowledge Base]
53
+ ingest[Ingest Documents]
54
+ stored[Documents Stored And Indexed]
55
+ end
56
+
57
+ subgraph outcomeLayer [Business Outcome]
58
+ grounded[Grounded RAG Answer]
59
+ citations[Source Citations]
60
+ trust[Truthfulness Signal]
61
+ end
62
+
63
+ upload --> ingest --> stored
64
+ stored --> ask
65
+ ask --> grounded
66
+ grounded --> citations
67
+ grounded --> trust
68
+ citations --> review
69
+ trust --> review
70
  ```
71
 
72
+ ## Under The Hood
73
 
74
+ The technical pipeline combines ingestion, sparse retrieval, semantic retrieval, rank fusion, reranking, model routing, citation verification, and answer scoring.
75
+
76
+ ```mermaid
77
+ flowchart TB
78
+ subgraph ingestionLayer [Ingestion Layer]
79
+ direction LR
80
+ documents[Documents]
81
+ ingest[Ingest]
82
+ chunk[Chunk]
83
+ embed[Create Embeddings]
84
+ vectorStore[Chroma Or Qdrant]
85
+ keywordIndex[BM25 Keyword Index]
86
+ end
87
+
88
+ subgraph retrievalLayer [Retrieval Layer]
89
+ direction LR
90
+ query[User Query]
91
+ keyword[Keyword Retrieval]
92
+ semantic[Semantic Retrieval]
93
+ rrf[Weighted RRF Fusion]
94
+ rerank[Cross Encoder Rerank]
95
+ end
96
+
97
+ subgraph generationLayer [Generation And Trust Layer]
98
+ direction LR
99
+ context[Context Optimizer]
100
+ aggregator[LLM Provider Aggregator]
101
+ answer[Answer]
102
+ citations[Citation Verification]
103
+ truth[Truthfulness Score]
104
+ end
105
+
106
+ documents --> ingest
107
+ ingest --> chunk
108
+ chunk --> embed --> vectorStore
109
+ chunk --> keywordIndex
110
+
111
+ query --> keyword
112
+ query --> semantic
113
+ keywordIndex --> keyword
114
+ vectorStore --> semantic
115
+ keyword --> rrf
116
+ semantic --> rrf
117
+
118
+ rrf --> rerank --> context --> aggregator --> answer
119
+ answer --> citations --> truth
120
+ ```
121
+
122
+ ## How Answer Quality Is Protected
123
+
124
+ Doc-Ingestion is designed around a grounding contract: retrieve evidence first, generate from that evidence, then report how well the answer is supported.
125
 
126
+ - **Hybrid retrieval:** BM25 catches exact terms, acronyms, and names; vector search catches semantic matches. The results are fused with weighted RRF in [`src/core/hybrid_retriever.py`](src/core/hybrid_retriever.py).
127
+ - **Reranking:** A cross-encoder reranker narrows the final context before generation in [`src/core/reranker.py`](src/core/reranker.py).
128
+ - **Context control:** Retrieved chunks are packed into the prompt within a configured token budget in [`src/core/context_optimizer.py`](src/core/context_optimizer.py).
129
+ - **Provider routing:** The same query path can route to Ollama, OpenAI, Anthropic, or Gemini through [`src/core/llm_provider.py`](src/core/llm_provider.py).
130
+ - **Citations:** Generated citation markers are mapped back to retrieved chunks by [`src/core/citation_tracker.py`](src/core/citation_tracker.py) and verified by [`src/core/citation_verifier.py`](src/core/citation_verifier.py).
131
+ - **Truthfulness:** Each response can include NLI faithfulness and citation groundedness from [`src/evaluation/truthfulness.py`](src/evaluation/truthfulness.py).
132
+
133
+ ## What You Can Try
134
+
135
+ - Use the hosted [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion) with preloaded sample documents.
136
+ - Upload or ingest your own files locally.
137
+ - Ask questions through Streamlit, FastAPI, or the CLI.
138
+ - Inspect answers, citations, source evidence, and truthfulness scores.
139
+ - Switch LLM providers and models per request when credentials are configured.
140
+
141
+ In hosted demo mode (`DOC_PROFILE=demo`), Streamlit executes queries in-process through the shared orchestrator so the demo is not blocked by localhost API startup races. Local non-demo mode uses the standard split architecture where Streamlit calls FastAPI over HTTP.
142
+
143
+ ## Tech Stack Snapshot
144
+
145
+ - **App and API:** Streamlit, FastAPI, Pydantic, Uvicorn
146
+ - **Document processing:** PyPDF2, python-docx, BeautifulSoup, markdown parsing, token-aware chunking
147
+ - **Retrieval:** BM25, Chroma, Qdrant, sentence-transformers, Ollama embeddings
148
+ - **Ranking:** weighted RRF fusion, `cross-encoder/ms-marco-MiniLM-L-6-v2`
149
+ - **Generation:** Ollama, OpenAI, Anthropic, Gemini
150
+ - **Evaluation:** NLI faithfulness, citation groundedness, golden datasets, RAGAS-style offline harness
151
+ - **Operations:** Docker Compose, Redis-backed rate limiting with in-memory fallback, Hugging Face Spaces deployment
152
+
153
+ ## Quickstart
154
+
155
+ ### Try Online
156
+
157
+ Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are preloaded. Paste your OpenAI, Anthropic, or Gemini key in the sidebar if you want to use a cloud provider.
158
+
159
+ ### Run Locally With Docker
160
 
161
  ```bash
162
  git clone https://github.com/vampokala/Doc-Ingestion
163
  cd Doc-Ingestion
164
+ cp docker/.env.example docker/.env
165
+ # Edit docker/.env to add your API keys if needed.
166
+ docker compose -f docker/docker-compose.yml up
167
  ```
168
 
169
+ Open `http://localhost:8501` for Streamlit or `http://localhost:8000` for the API.
170
+
171
+ ### Run From Source
172
 
173
  ```bash
174
+ git clone https://github.com/vampokala/Doc-Ingestion
175
+ cd Doc-Ingestion
176
+ bash scripts/bootstrap_demo.sh
177
  ```
178
 
179
+ The bootstrap script creates a virtual environment, installs dependencies, ingests sample documents, and pulls Ollama models when Ollama is installed.
180
 
181
  ```bash
182
  source .venv/bin/activate
 
184
  # API server
185
  PYTHONPATH=. uvicorn src.api.main:app --reload --port 8000
186
 
187
+ # Streamlit UI in a second terminal
188
  PYTHONPATH=. streamlit run src/web/streamlit_app.py
189
 
190
+ # CLI query
191
  PYTHONPATH=. python -m src.query "What is RAG?"
192
  ```
193
 
194
+ For a full local and Docker runbook, see [`Docs/RUNBOOK.md`](Docs/RUNBOOK.md).
195
 
196
+ ## API Usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  ```bash
199
  uvicorn src.api.main:app --reload --port 8000
 
206
  -d '{"query": "What is hybrid retrieval?", "provider": "ollama", "model": "qwen2.5:7b"}'
207
  ```
208
 
209
+ Response includes answer text, citations, retrieved evidence, and a `truthfulness` block:
210
 
211
  ```json
212
  {
 
217
  "uncited_claims": 1,
218
  "score": 0.89
219
  },
220
+ "citations": []
221
  }
222
  ```
223
 
224
  Endpoints: `GET /health`, `GET /metrics`, `POST /query`, `POST /query/stream` (SSE).
225
 
 
 
226
  ## Evaluation
227
 
228
+ Every `/query` response can include a `truthfulness` object:
 
 
229
 
230
  | Field | What it measures |
231
+ |-------|------------------|
232
+ | `nli_faithfulness` | Fraction of response sentences entailed by retrieved chunks |
233
  | `citation_groundedness` | Mean citation verification score |
234
+ | `uncited_claims` | Count of answer sentences without citation markers |
235
+ | `score` | Weighted aggregate of faithfulness and groundedness |
 
 
236
 
237
+ Run the offline harness against the included datasets:
 
 
238
 
239
  ```bash
 
240
  pip install -r requirements/eval.txt
241
 
 
242
  PYTHONPATH=. python -m evals.run_evals \
243
+ --dataset evals/datasets/golden.jsonl \
244
+ --judge-provider anthropic \
245
+ --judge-model claude-haiku-4-5 \
246
  --output evals/reports/
247
 
 
248
  PYTHONPATH=. python -m evals.run_evals \
249
  --dataset evals/datasets/smoke.jsonl \
250
  --mock \
 
252
  --output evals/reports/
253
  ```
254
 
255
+ Reports are written to `evals/reports/` as JSON and Markdown.
256
 
257
+ ## Project Map
258
 
259
+ - [`src/core/`](src/core/) - retrieval, reranking, generation, citations, orchestration
260
+ - [`src/api/`](src/api/) - FastAPI models and routes
261
+ - [`src/web/`](src/web/) - Streamlit UI and ingestion service
262
+ - [`src/evaluation/`](src/evaluation/) - truthfulness scorer, generation metrics, retrieval metrics
263
+ - [`src/utils/`](src/utils/) - config, logging, and vector database integrations
264
+ - [`evals/`](evals/) - offline eval harness, golden datasets, RAGAS adapter
265
+ - [`data/sample/`](data/sample/) - preloaded sample documents for demos
266
+ - [`spaces/`](spaces/) - Hugging Face Spaces deployment files
267
+ - [`docker/`](docker/) - Docker Compose stack for API, Streamlit, Redis, and Qdrant
268
+ - [`Docs/`](Docs/) - architecture notes, runbook, roadmap, phase documentation
 
 
 
 
269
 
270
+ ## Where To Go Deeper
271
+
272
+ - [`Docs/PROJECT_OVERVIEW.md`](Docs/PROJECT_OVERVIEW.md) - system architecture and reader-friendly project overview
273
+ - [`Docs/RUNBOOK.md`](Docs/RUNBOOK.md) - local setup, Docker setup, API keys, rate limiting, troubleshooting
274
+ - [`Docs/phase2_hybrid_retrieval.md`](Docs/phase2_hybrid_retrieval.md) - hybrid retrieval and RRF design
275
+ - [`Docs/phase3_reranking_generation.md`](Docs/phase3_reranking_generation.md) - reranking, generation, and context optimization
276
+ - [`Docs/phase4_citation_api.md`](Docs/phase4_citation_api.md) - citation and API design
277
+ - [`Docs/performance_baseline.md`](Docs/performance_baseline.md) - FastAPI overhead baseline
278
+ - [`Docs/ROADMAP.md`](Docs/ROADMAP.md) - delivery status and planned improvements
279
 
280
  ## Development
281
 
 
290
  export OPENAI_API_KEY=...
291
  export ANTHROPIC_API_KEY=...
292
  export GEMINI_API_KEY=...
293
+ export DOC_API_KEYS=dev-key-1
294
  ```
295
 
 
 
296
  ## Troubleshooting
297
 
298
  - **Empty results after ingest:** Run `python -m src.ingest --docs data/documents` and verify `data/embeddings/` exists.
299
  - **Embedding model error:** Ensure Ollama is running and `nomic-embed-text` is pulled, or switch to a different embedding provider in `config.yaml`.
300
  - **Dimension mismatch after model change:** Re-ingest all documents to rebuild the vector index.
301
  - **Cloud provider fails:** Check the relevant `*_API_KEY` env var is set.
302
+ - **Truthfulness score always 0:** The NLI model (`cross-encoder/nli-deberta-v3-small`) downloads on first use. Check internet access or set `evaluation.inline_enabled: false` in `config.yaml` to disable.
 
 
 
 
 
 
 
 
config.yaml CHANGED
@@ -1,6 +1,7 @@
1
  # Document processing
2
- chunk_size: 1000
3
- overlap: 200
 
4
 
5
  # File paths
6
  data_dir: data/documents
 
1
  # Document processing
2
+ chunk_size: 600
3
+ overlap: 100
4
+ chunk_tokenizer: gpt2
5
 
6
  # File paths
7
  data_dir: data/documents
docker/.env.example CHANGED
@@ -10,7 +10,7 @@ OLLAMA_BASE_URL=http://host.docker.internal:11434
10
 
11
  # Optional cloud provider keys
12
  OPENAI_API_KEY=
13
- ANTHROPIC_API_KEY=
14
  GEMINI_API_KEY=
15
 
16
  # Hugging Face cache/offline toggles
 
10
 
11
  # Optional cloud provider keys
12
  OPENAI_API_KEY=
13
+ ANTHROPIC_API_KEY=replace-with-anthropic-api-key
14
  GEMINI_API_KEY=
15
 
16
  # Hugging Face cache/offline toggles
evals/datasets/golden.jsonl ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"user_input": "What is Retrieval-Augmented Generation?", "reference": "Retrieval-Augmented Generation (RAG) grounds model answers by retrieving relevant external context before generation.", "reference_contexts": ["Retrieval-Augmented Generation (RAG) enhances LLM answers by retrieving relevant context before generating a response."]}
2
+ {"user_input": "What are the two main phases in RAG?", "reference": "RAG has ingestion and query phases: documents are indexed first, then relevant chunks are retrieved at query time.", "reference_contexts": ["Ingestion prepares chunks and embeddings.", "Query-time retrieval fetches relevant chunks for generation."]}
3
+ {"user_input": "What is BM25 used for?", "reference": "BM25 is a sparse retrieval ranking function that scores lexical relevance between query terms and documents.", "reference_contexts": ["BM25 ranks documents using term frequency and document length normalization."]}
4
+ {"user_input": "What does BM25 k1 control?", "reference": "The k1 parameter controls how quickly BM25 term-frequency gains saturate.", "reference_contexts": ["k1 controls term frequency saturation in BM25."]}
5
+ {"user_input": "What is BM25 b parameter?", "reference": "The b parameter controls document length normalization strength in BM25.", "reference_contexts": ["b controls how strongly BM25 normalizes for document length."]}
6
+ {"user_input": "What is a common BM25 weakness?", "reference": "BM25 struggles with synonym and paraphrase matching because it depends on lexical overlap.", "reference_contexts": ["BM25 cannot reliably match semantic synonyms without shared keywords."]}
7
+ {"user_input": "What is dense retrieval?", "reference": "Dense retrieval compares embedding vectors to find semantically similar chunks.", "reference_contexts": ["Dense retrieval uses vector similarity between query and document embeddings."]}
8
+ {"user_input": "What is hybrid retrieval?", "reference": "Hybrid retrieval combines sparse BM25 and dense vector retrieval to improve robustness across query types.", "reference_contexts": ["BM25 and dense retrieval are complementary and often fused together."]}
9
+ {"user_input": "What is Reciprocal Rank Fusion?", "reference": "Reciprocal Rank Fusion combines rankings by summing 1/(k+rank) contributions from each retriever.", "reference_contexts": ["RRF score is computed as the sum of reciprocal rank terms across retrieval methods."]}
10
+ {"user_input": "Why rerank after retrieval?", "reference": "Reranking improves final top-k quality by re-scoring candidate chunks with a stronger cross-encoder model.", "reference_contexts": ["Cross-encoder reranking refines candidate ordering after initial retrieval."]}
11
+ {"user_input": "What is a cross-encoder reranker?", "reference": "A cross-encoder reranker jointly encodes query and chunk text to score relevance more precisely.", "reference_contexts": ["Cross-encoders evaluate query-document pairs directly for relevance."]}
12
+ {"user_input": "Why keep citations in RAG answers?", "reference": "Citations make answers auditable and help detect unsupported claims.", "reference_contexts": ["Citation tracking is used to verify whether claims are grounded in retrieved evidence."]}
13
+ {"user_input": "What does faithfulness measure?", "reference": "Faithfulness measures whether answer statements are supported by retrieved evidence.", "reference_contexts": ["Faithfulness checks if generated claims are entailed by retrieval context."]}
14
+ {"user_input": "What is NLI-based faithfulness?", "reference": "NLI-based faithfulness uses entailment models to estimate if answer statements are supported by context.", "reference_contexts": ["Natural Language Inference can score support versus contradiction for generated statements."]}
15
+ {"user_input": "What is citation groundedness?", "reference": "Citation groundedness reflects how well cited evidence supports the associated answer statements.", "reference_contexts": ["Groundedness scores estimate support strength from cited chunks."]}
16
+ {"user_input": "Why chunk documents before indexing?", "reference": "Chunking creates retrieval-sized units that improve recall and context precision.", "reference_contexts": ["Large documents are split into smaller chunks for better retrieval granularity."]}
17
+ {"user_input": "What is token-based chunking?", "reference": "Token-based chunking splits text by model token counts instead of characters.", "reference_contexts": ["Token-aware chunking aligns chunk boundaries with LLM/embedding tokenization."]}
18
+ {"user_input": "Why use chunk overlap?", "reference": "Overlap preserves context continuity across adjacent chunks and reduces boundary loss.", "reference_contexts": ["Chunk overlap keeps neighboring context that might otherwise be truncated."]}
19
+ {"user_input": "What is a vector database?", "reference": "A vector database stores embeddings and supports nearest-neighbor similarity search.", "reference_contexts": ["Vector databases index high-dimensional vectors for semantic retrieval."]}
20
+ {"user_input": "What is HNSW in vector search?", "reference": "HNSW is a graph-based approximate nearest-neighbor index optimized for fast high-recall search.", "reference_contexts": ["HNSW provides efficient ANN search for vector retrieval systems."]}
21
+ {"user_input": "How does ingestion use metadata?", "reference": "Ingestion metadata like title and file type can improve filtering, ranking, and diagnostics.", "reference_contexts": ["Metadata is attached to chunks for filtering, ranking features, and traceability."]}
22
+ {"user_input": "What does duplicate detection prevent?", "reference": "Duplicate detection prevents re-indexing identical content and reduces noisy retrieval redundancy.", "reference_contexts": ["Content hashing can skip duplicate documents during ingestion."]}
23
+ {"user_input": "Why combine BM25 and vectors instead of one?", "reference": "Combining both captures exact keywords and semantic intent, reducing each method's blind spots.", "reference_contexts": ["Sparse retrieval handles lexical precision, dense retrieval handles semantic similarity."]}
24
+ {"user_input": "What is context precision?", "reference": "Context precision measures how many retrieved chunks are relevant among the returned set.", "reference_contexts": ["Precision evaluates the fraction of retrieved context that is relevant."]}
25
+ {"user_input": "What is context recall?", "reference": "Context recall measures how much of the relevant reference context appears in retrieval results.", "reference_contexts": ["Recall evaluates whether needed supporting context was retrieved."]}
26
+ {"user_input": "What does answer relevancy evaluate?", "reference": "Answer relevancy evaluates semantic alignment between the user question and generated answer.", "reference_contexts": ["Relevancy checks if the answer addresses the asked question."]}
27
+ {"user_input": "What does ROUGE-L indicate in evals?", "reference": "ROUGE-L estimates overlap with a reference answer using longest common subsequence matching.", "reference_contexts": ["ROUGE-L is commonly used to compare generated and reference answer similarity."]}
28
+ {"user_input": "Why have a smoke eval dataset?", "reference": "A smoke dataset validates evaluation harness plumbing quickly with low runtime.", "reference_contexts": ["Smoke tests check that the eval pipeline executes end to end."]}
29
+ {"user_input": "Why maintain a golden dataset?", "reference": "A golden dataset provides stable regression coverage for retrieval and generation quality over time.", "reference_contexts": ["Golden eval sets are used to detect quality regressions across releases."]}
30
+ {"user_input": "Why gate CI on quality thresholds?", "reference": "Quality gates prevent merges that degrade faithfulness or answer correctness below acceptable levels.", "reference_contexts": ["CI thresholds enforce minimum evaluation quality before merge."]}
31
+ {"user_input": "What is an API key auth pattern in this app?", "reference": "Protected endpoints require an X-API-Key that matches configured server keys.", "reference_contexts": ["API key middleware validates incoming keys against configured allowed keys."]}
32
+ {"user_input": "Why add rate limiting to RAG APIs?", "reference": "Rate limiting protects service stability and controls abuse under high request bursts.", "reference_contexts": ["Rate limits are applied per client to reduce overload and misuse."]}
33
+ {"user_input": "What is SSE in query streaming?", "reference": "Server-Sent Events stream partial response chunks over a long-lived HTTP response.", "reference_contexts": ["SSE enables token-by-token or chunk-by-chunk response streaming."]}
34
+ {"user_input": "What is provider routing in generation?", "reference": "Provider routing allows selecting Ollama, OpenAI, Anthropic, or Gemini per request.", "reference_contexts": ["A provider router dispatches generation calls to configured LLM backends."]}
35
+ {"user_input": "Why use fallback caching?", "reference": "Fallback caching preserves responsiveness when external dependencies are slow or unavailable.", "reference_contexts": ["Response cache layers can reduce repeated generation latency."]}
36
+ {"user_input": "What does deterministic retrieval behavior mean?", "reference": "Deterministic retrieval returns stable rankings for identical inputs and corpus state.", "reference_contexts": ["Stable ranking behavior is important for reproducible evaluation."]}
37
+ {"user_input": "Why include health endpoints?", "reference": "Health endpoints expose service readiness and dependency status for operations monitoring.", "reference_contexts": ["/health endpoints are used by orchestrators and uptime checks."]}
38
+ {"user_input": "What is metrics endpoint purpose?", "reference": "Metrics endpoints expose counters and latency series for observability systems.", "reference_contexts": ["/metrics provides telemetry for monitoring and alerting."]}
39
+ {"user_input": "What is citation verification?", "reference": "Citation verification checks whether cited chunks actually support the linked answer claims.", "reference_contexts": ["Verification distinguishes supported citations from weak or unsupported ones."]}
40
+ {"user_input": "Why track uncited claims?", "reference": "Uncited claims indicate potential hallucination risk and lower answer trustworthiness.", "reference_contexts": ["Claims lacking citations are higher-risk for unsupported output."]}
41
+ {"user_input": "What is context window optimization?", "reference": "Context window optimization packs the most relevant chunks within token budget limits.", "reference_contexts": ["Retrieved chunks are selected and trimmed to fit model max context tokens."]}
42
+ {"user_input": "Why use configurable models?", "reference": "Configurable models let teams balance quality, cost, and latency per environment.", "reference_contexts": ["Model selection by config supports portability and controlled rollouts."]}
43
+ {"user_input": "Why keep cloud providers optional?", "reference": "Optional cloud providers preserve local-first operation while enabling production elasticity.", "reference_contexts": ["The system can run locally without mandatory external APIs."]}
44
+ {"user_input": "What does local-first RAG imply?", "reference": "Local-first RAG prioritizes local indexing/retrieval and optional external services.", "reference_contexts": ["Core retrieval can run on local infrastructure without cloud lock-in."]}
45
+ {"user_input": "Why test integration in addition to unit tests?", "reference": "Integration tests validate end-to-end behavior across components and contracts.", "reference_contexts": ["Unit tests check isolated logic, integration tests validate composed workflows."]}
46
+ {"user_input": "Why use markdown and JSON eval reports?", "reference": "JSON enables automation while markdown improves human review in CI artifacts.", "reference_contexts": ["Dual report formats support machine processing and stakeholder readability."]}
47
+ {"user_input": "How does CI skip missing optional secrets?", "reference": "Conditional job execution can skip provider-specific evaluations when required secrets are absent.", "reference_contexts": ["GitHub Actions job conditions can check secret presence before running."]}
48
+ {"user_input": "What does a faithfulness threshold represent?", "reference": "A faithfulness threshold is the minimum acceptable average support score for generated answers.", "reference_contexts": ["Thresholds define pass/fail criteria for evaluation gating."]}
49
+ {"user_input": "Why keep architecture simple here?", "reference": "Simple architecture reduces maintenance overhead and keeps behavior understandable and testable.", "reference_contexts": ["Avoiding unnecessary abstractions improves reliability and delivery speed."]}
50
+ {"user_input": "How does chunk overlap affect retrieval?", "reference": "Appropriate overlap reduces context fragmentation and can improve answer completeness.", "reference_contexts": ["Overlap ensures boundary sentences remain retrievable in adjacent chunks."]}
51
+ {"user_input": "What is the role of orchestrator in RAG?", "reference": "The orchestrator coordinates retrieval, reranking, context building, generation, and citations.", "reference_contexts": ["RAG orchestration composes retrieval and generation stages into one request pipeline."]}
52
+ {"user_input": "Why monitor evaluation latency?", "reference": "Monitoring eval latency helps control CI runtime and detect regressions in model responsiveness.", "reference_contexts": ["Latency tracking complements quality metrics in evaluation operations."]}
evals/datasets/golden_ci.jsonl ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"user_input": "What is Retrieval-Augmented Generation?", "reference": "Retrieval-Augmented Generation (RAG) grounds model answers by retrieving relevant external context before generation.", "reference_contexts": ["Retrieval-Augmented Generation (RAG) enhances LLM answers by retrieving relevant context before generating a response."]}
2
+ {"user_input": "What are the two main phases in RAG?", "reference": "RAG has ingestion and query phases: documents are indexed first, then relevant chunks are retrieved at query time.", "reference_contexts": ["Ingestion prepares chunks and embeddings.", "Query-time retrieval fetches relevant chunks for generation."]}
3
+ {"user_input": "What is BM25 used for?", "reference": "BM25 is a sparse retrieval ranking function that scores lexical relevance between query terms and documents.", "reference_contexts": ["BM25 ranks documents using term frequency and document length normalization."]}
4
+ {"user_input": "What does BM25 k1 control?", "reference": "The k1 parameter controls how quickly BM25 term-frequency gains saturate.", "reference_contexts": ["k1 controls term frequency saturation in BM25."]}
5
+ {"user_input": "What is BM25 b parameter?", "reference": "The b parameter controls document length normalization strength in BM25.", "reference_contexts": ["b controls how strongly BM25 normalizes for document length."]}
6
+ {"user_input": "What is a common BM25 weakness?", "reference": "BM25 struggles with synonym and paraphrase matching because it depends on lexical overlap.", "reference_contexts": ["BM25 cannot reliably match semantic synonyms without shared keywords."]}
7
+ {"user_input": "What is dense retrieval?", "reference": "Dense retrieval compares embedding vectors to find semantically similar chunks.", "reference_contexts": ["Dense retrieval uses vector similarity between query and document embeddings."]}
8
+ {"user_input": "What is hybrid retrieval?", "reference": "Hybrid retrieval combines sparse BM25 and dense vector retrieval to improve robustness across query types.", "reference_contexts": ["BM25 and dense retrieval are complementary and often fused together."]}
9
+ {"user_input": "What is Reciprocal Rank Fusion?", "reference": "Reciprocal Rank Fusion combines rankings by summing 1/(k+rank) contributions from each retriever.", "reference_contexts": ["RRF score is computed as the sum of reciprocal rank terms across retrieval methods."]}
10
+ {"user_input": "Why rerank after retrieval?", "reference": "Reranking improves final top-k quality by re-scoring candidate chunks with a stronger cross-encoder model.", "reference_contexts": ["Cross-encoder reranking refines candidate ordering after initial retrieval."]}
11
+ {"user_input": "What is a cross-encoder reranker?", "reference": "A cross-encoder reranker jointly encodes query and chunk text to score relevance more precisely.", "reference_contexts": ["Cross-encoders evaluate query-document pairs directly for relevance."]}
12
+ {"user_input": "Why keep citations in RAG answers?", "reference": "Citations make answers auditable and help detect unsupported claims.", "reference_contexts": ["Citation tracking is used to verify whether claims are grounded in retrieved evidence."]}
13
+ {"user_input": "What does faithfulness measure?", "reference": "Faithfulness measures whether answer statements are supported by retrieved evidence.", "reference_contexts": ["Faithfulness checks if generated claims are entailed by retrieval context."]}
14
+ {"user_input": "What is NLI-based faithfulness?", "reference": "NLI-based faithfulness uses entailment models to estimate if answer statements are supported by context.", "reference_contexts": ["Natural Language Inference can score support versus contradiction for generated statements."]}
15
+ {"user_input": "What is citation groundedness?", "reference": "Citation groundedness reflects how well cited evidence supports the associated answer statements.", "reference_contexts": ["Groundedness scores estimate support strength from cited chunks."]}
16
+ {"user_input": "Why chunk documents before indexing?", "reference": "Chunking creates retrieval-sized units that improve recall and context precision.", "reference_contexts": ["Large documents are split into smaller chunks for better retrieval granularity."]}
17
+ {"user_input": "What is token-based chunking?", "reference": "Token-based chunking splits text by model token counts instead of characters.", "reference_contexts": ["Token-aware chunking aligns chunk boundaries with LLM/embedding tokenization."]}
18
+ {"user_input": "Why use chunk overlap?", "reference": "Overlap preserves context continuity across adjacent chunks and reduces boundary loss.", "reference_contexts": ["Chunk overlap keeps neighboring context that might otherwise be truncated."]}
19
+ {"user_input": "What is a vector database?", "reference": "A vector database stores embeddings and supports nearest-neighbor similarity search.", "reference_contexts": ["Vector databases index high-dimensional vectors for semantic retrieval."]}
20
+ {"user_input": "What is HNSW in vector search?", "reference": "HNSW is a graph-based approximate nearest-neighbor index optimized for fast high-recall search.", "reference_contexts": ["HNSW provides efficient ANN search for vector retrieval systems."]}
rag-spec-evaluation.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RAG Spec Evaluation Report
2
+
3
+ ## Context
4
+
5
+ This is a gap analysis of the Doc-Ingestion app against a production-grade RAG spec across three phases. The goal is to identify what is built, what partially meets the spec, and what is missing or misaligned.
6
+
7
+ ---
8
+
9
+ ## Spec vs. Implementation: Detailed Evaluation
10
+
11
+ ### Phase 1 β€” Fundamentals
12
+
13
+ | Requirement | Status | Detail |
14
+ |---|---|---|
15
+ | Ingest documents | βœ… Built | PDF, DOCX, TXT, MD, HTML via `src/core/document_processor.py` |
16
+ | 500–800 **token** chunks | ⚠️ Partial / Misaligned | Chunking uses **characters** (default: 1000 chars, 200 overlap), not tokens. Spec requires token-based chunking (500–800 tokens). No tokenizer is applied during chunking. |
17
+ | 100-token overlap | ⚠️ Partial / Misaligned | Overlap is 200 characters, not 100 tokens. Same root issue: characters vs. tokens. |
18
+ | Vector store (Chroma or Qdrant) | βœ… Built | ChromaDB for dev (`data/embeddings/chroma`), Qdrant optional for prod. Both present in `src/utils/database.py`. |
19
+
20
+ **Gap to fix:**
21
+ - `src/core/document_processor.py` `chunk_text()` method uses character sliding window. Needs to be replaced with a tokenizer-aware splitter (e.g., `tiktoken` or `transformers` tokenizer) targeting 500–800 tokens with 100-token overlap.
22
+ - `config.yaml` `chunk_size: 1000` and `overlap: 200` need to change to token units.
23
+
24
+ ---
25
+
26
+ ### Phase 2 β€” Hybrid Retrieval + Re-ranking
27
+
28
+ | Requirement | Status | Detail |
29
+ |---|---|---|
30
+ | BM25 keyword search | βœ… Built | `src/core/bm25_search.py`, `BM25Search` using `rank-bm25` |
31
+ | Vector semantic search | βœ… Built | `src/core/vector_search.py`, `VectorSearch` with ChromaDB |
32
+ | Hybrid retrieval combining both | βœ… Built | `src/core/hybrid_retriever.py`, `HybridRetriever` with Reciprocal Rank Fusion (RRF), parallel execution |
33
+ | Cross-encoder re-ranker | βœ… Built | `src/core/reranker.py`, `CrossEncoderReranker` using `cross-encoder/ms-marco-MiniLM-L-6-v2` |
34
+
35
+ **Phase 2 is fully built and exceeds the spec** (adds RRF fusion, LRU caching, confidence scoring, configurable weights).
36
+
37
+ ---
38
+
39
+ ### Phase 3 β€” Evaluation Dataset + CI/CD
40
+
41
+ | Requirement | Status | Detail |
42
+ |---|---|---|
43
+ | Golden dataset of 50–200 Q&A pairs | ⚠️ Missing | Only `evals/datasets/smoke.jsonl` (~few entries, 1 KB) and `evals/datasets/sample.jsonl` (~6 KB, estimated ~10–15 pairs). Neither meets the 50–200 pair threshold. |
44
+ | Offline evaluation script | βœ… Built | `evals/run_evals.py` (504 lines) with 8+ metrics: answer_relevancy, context_precision, context_recall, ROUGE-L, citation_rate, faithfulness |
45
+ | CI/CD pipeline integration | ⚠️ Partial | `.github/workflows/ci.yml` has `evals-smoke` job, but it runs with `--mock` flag (MockPipeline). It does **not** measure real faithfulness β€” it tests the eval harness, not the RAG pipeline. |
46
+ | Measure faithfulness | ⚠️ Partial | NLI faithfulness via `src/evaluation/truthfulness.py` exists. RAGAS integration exists in `evals/adapters/ragas_llm_adapter.py` but is optional (requires `ragas>=0.2`, `langchain-core` extra deps) and not wired into CI. |
47
+
48
+ ---
49
+
50
+ ### Recommended Tech Stack Alignment
51
+
52
+ | Recommendation | Status | Detail |
53
+ |---|---|---|
54
+ | LangChain or LangGraph | ❌ Not used | Core pipeline uses direct HTTP API calls (`src/core/llm_provider.py`). LangChain only appears as a thin adapter in `evals/adapters/ragas_llm_adapter.py` to satisfy RAGAS interface β€” it is not the orchestration framework. |
55
+ | ChromaDB or Qdrant | βœ… Built | Both present |
56
+ | Ragas for evaluation | ⚠️ Optional / Incomplete | Present as optional adapter, not enforced. CI runs MockPipeline without RAGAS. |
57
+
58
+ ---
59
+
60
+ ## Summary: What Is Missing
61
+
62
+ ### Must-Fix (spec violations)
63
+
64
+ 1. **Token-based chunking** β€” `src/core/document_processor.py:chunk_text()` uses character counts, not tokens. Replace with tokenizer-aware chunking (e.g., `tiktoken`) targeting 500–800 tokens, 100-token overlap. Update `config.yaml` units accordingly.
65
+
66
+ 2. **Golden evaluation dataset (50–200 pairs)** β€” `evals/datasets/` only has smoke (~few entries) and sample (~10–15 pairs). Need to create a curated dataset of at least 50 ground-truth Q&A pairs with reference contexts, authored against real ingested documents.
67
+
68
+ 3. **CI/CD runs real faithfulness evaluation** β€” The `evals-smoke` GitHub Actions job uses `--mock`. A CI job that runs `LivePipeline` against the golden dataset (or a representative subset) and gates on a faithfulness threshold is required by the spec.
69
+
70
+ ### Should-Fix (partial alignment)
71
+
72
+ 4. **RAGAS made non-optional** β€” RAGAS faithfulness should be a hard dependency in `evals/`, not a conditional import behind `try/except`. The eval report should always include RAGAS faithfulness score.
73
+
74
+ 5. **LangChain / LangGraph adoption** β€” The spec recommends LangChain/LangGraph as the orchestration layer. Currently the pipeline is custom HTTP. This is a tech stack deviation, not a functional gap β€” worth noting but lower priority than items 1–3.
75
+
76
+ ---
77
+
78
+ ## What Is Already Production-Grade (exceeds spec)
79
+
80
+ - Full hybrid retrieval with RRF fusion, parallel execution, and LRU caching
81
+ - Cross-encoder reranking with batch scoring and threshold filtering
82
+ - Multi-provider LLM routing (Ollama, OpenAI, Anthropic, Gemini) with streaming
83
+ - NLI-based inline truthfulness scoring in the serving path
84
+ - Citation tracking and verification
85
+ - Response caching (Redis + in-memory fallback)
86
+ - Rate limiting, API key auth, audit logging
87
+ - Comprehensive IR metrics module (P@K, R@K, MRR, MAP, NDCG)
88
+ - Unit + integration test coverage with CI
89
+
90
+ ---
91
+
92
+ ## Verification Steps (after fixes)
93
+
94
+ 1. After token-based chunking: ingest a known document, query it, verify chunk boundaries fall within 500–800 token range using `tiktoken` inspection script.
95
+ 2. After golden dataset: run `python -m evals.run_evals --dataset evals/datasets/golden.jsonl --live` and confirm dataset size β‰₯ 50.
96
+ 3. After CI wiring: confirm GitHub Actions `evals-golden` job fails when faithfulness drops below threshold (e.g., `nli_faithfulness < 0.7`).
requirements/base.txt CHANGED
@@ -24,6 +24,7 @@ transformers>=4.30.0
24
  torch>=2.0.0
25
  accelerate>=0.20.0
26
  tokenizers>=0.13.0
 
27
  rouge-score>=0.1.2
28
  bert-score>=0.3.13
29
  sacrebleu>=2.3.0
 
24
  torch>=2.0.0
25
  accelerate>=0.20.0
26
  tokenizers>=0.13.0
27
+ tiktoken>=0.5.0
28
  rouge-score>=0.1.2
29
  bert-score>=0.3.13
30
  sacrebleu>=2.3.0
src/core/document_processor.py CHANGED
@@ -12,14 +12,40 @@ from datetime import datetime
12
  from typing import Dict, List, Optional
13
 
14
  import PyPDF2
 
15
  from bs4 import BeautifulSoup
16
  from docx import Document
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  class DocumentProcessor:
20
- def __init__(self, chunk_size: int = 1000, overlap: int = 200):
 
 
 
 
 
 
21
  self.chunk_size = chunk_size
22
  self.overlap = overlap
 
 
 
 
 
23
  self._seen_hashes: set = set()
24
 
25
  def process_document(self, file_path: str) -> Optional[Dict]:
@@ -76,17 +102,24 @@ class DocumentProcessor:
76
  return text.strip()
77
 
78
  def chunk_text(self, text: str) -> List[str]:
79
- chunks = []
 
 
 
 
80
  step = self.chunk_size - self.overlap
81
  start = 0
82
- while start < len(text):
83
- end = min(start + self.chunk_size, len(text))
84
- chunk = text[start:end]
85
- if chunk:
86
  chunks.append(chunk)
87
  start += step
88
  return chunks
89
 
 
 
 
90
  # --- private extractors ---
91
 
92
  def _extract_pdf_text(self, file_path: str) -> str:
 
12
  from typing import Dict, List, Optional
13
 
14
  import PyPDF2
15
+ import tiktoken
16
  from bs4 import BeautifulSoup
17
  from docx import Document
18
 
19
 
20
+ class _RegexTokenizer:
21
+ """Offline fallback tokenizer when tiktoken encoding cannot be loaded."""
22
+
23
+ _token_pattern = re.compile(r"\w+|[^\w\s]", re.UNICODE)
24
+
25
+ def encode(self, text: str) -> List[str]:
26
+ return self._token_pattern.findall(text)
27
+
28
+ def decode(self, token_ids: List[str]) -> str:
29
+ if not token_ids:
30
+ return ""
31
+ return " ".join(token_ids)
32
+
33
+
34
  class DocumentProcessor:
35
+ def __init__(self, chunk_size: int = 600, overlap: int = 100, tokenizer_name: str = "gpt2"):
36
+ if chunk_size <= 0:
37
+ raise ValueError("chunk_size must be > 0")
38
+ if overlap < 0:
39
+ raise ValueError("overlap must be >= 0")
40
+ if overlap >= chunk_size:
41
+ raise ValueError("overlap must be smaller than chunk_size")
42
  self.chunk_size = chunk_size
43
  self.overlap = overlap
44
+ self.tokenizer_name = tokenizer_name
45
+ try:
46
+ self._tokenizer = tiktoken.get_encoding(tokenizer_name)
47
+ except Exception:
48
+ self._tokenizer = _RegexTokenizer()
49
  self._seen_hashes: set = set()
50
 
51
  def process_document(self, file_path: str) -> Optional[Dict]:
 
102
  return text.strip()
103
 
104
  def chunk_text(self, text: str) -> List[str]:
105
+ token_ids = self._tokenizer.encode(text)
106
+ if not token_ids:
107
+ return []
108
+
109
+ chunks: List[str] = []
110
  step = self.chunk_size - self.overlap
111
  start = 0
112
+ while start < len(token_ids):
113
+ end = min(start + self.chunk_size, len(token_ids))
114
+ chunk = self._tokenizer.decode(token_ids[start:end])
115
+ if chunk.strip():
116
  chunks.append(chunk)
117
  start += step
118
  return chunks
119
 
120
+ def count_tokens(self, text: str) -> int:
121
+ return len(self._tokenizer.encode(text))
122
+
123
  # --- private extractors ---
124
 
125
  def _extract_pdf_text(self, file_path: str) -> str:
src/ingest.py CHANGED
@@ -37,10 +37,19 @@ def collect_files(path: str) -> list[str]:
37
  def ingest(docs_path: str) -> tuple[BM25Index, VectorDatabase]:
38
  # ── 1. Config ────────────────────────────────────────────────────────────
39
  cfg = load_config("config.yaml")
40
- logger.info("Config loaded: chunk_size=%d overlap=%d", cfg.chunk_size, cfg.overlap)
 
 
 
 
 
41
 
42
  # ── 2. Components ─────────────────────────────────────────────────────────
43
- processor = DocumentProcessor(chunk_size=cfg.chunk_size, overlap=cfg.overlap)
 
 
 
 
44
  index = BM25Index()
45
  db = VectorDatabase(mode="dev", chroma_path="data/embeddings/chroma")
46
  db.create_collection(COLLECTION_NAME)
 
37
  def ingest(docs_path: str) -> tuple[BM25Index, VectorDatabase]:
38
  # ── 1. Config ────────────────────────────────────────────────────────────
39
  cfg = load_config("config.yaml")
40
+ logger.info(
41
+ "Config loaded: chunk_size=%d overlap=%d tokenizer=%s",
42
+ cfg.chunk_size,
43
+ cfg.overlap,
44
+ cfg.chunk_tokenizer,
45
+ )
46
 
47
  # ── 2. Components ─────────────────────────────────────────────────────────
48
+ processor = DocumentProcessor(
49
+ chunk_size=cfg.chunk_size,
50
+ overlap=cfg.overlap,
51
+ tokenizer_name=cfg.chunk_tokenizer,
52
+ )
53
  index = BM25Index()
54
  db = VectorDatabase(mode="dev", chroma_path="data/embeddings/chroma")
55
  db.create_collection(COLLECTION_NAME)
src/utils/config.py CHANGED
@@ -135,8 +135,9 @@ class APISettings(BaseModel):
135
 
136
 
137
  class Config(BaseModel):
138
- chunk_size: int = Field(1000, description="Size of text chunks")
139
- overlap: int = Field(200, description="Overlap between chunks")
 
140
  data_dir: str = Field("data", description="Directory for input files")
141
  output_dir: str = Field("output", description="Directory for processed output")
142
  log_level: str = Field("INFO", description="Logging level")
 
135
 
136
 
137
  class Config(BaseModel):
138
+ chunk_size: int = Field(600, description="Chunk size in tokens")
139
+ overlap: int = Field(100, description="Chunk overlap in tokens")
140
+ chunk_tokenizer: str = Field("gpt2", description="Tokenizer encoding used for ingestion chunking")
141
  data_dir: str = Field("data", description="Directory for input files")
142
  output_dir: str = Field("output", description="Directory for processed output")
143
  log_level: str = Field("INFO", description="Logging level")
tests/unit/test_config.py CHANGED
@@ -16,8 +16,9 @@ def _write_config(data: dict) -> str:
16
  class TestConfigDefaults:
17
  def test_default_values(self):
18
  cfg = Config()
19
- assert cfg.chunk_size == 1000
20
- assert cfg.overlap == 200
 
21
  assert cfg.log_level == "INFO"
22
 
23
  def test_custom_values(self):
@@ -45,7 +46,7 @@ class TestLoadConfig:
45
  path = _write_config({"chunk_size": 256})
46
  try:
47
  cfg = load_config(path)
48
- assert cfg.overlap == 200 # default
49
  finally:
50
  os.unlink(path)
51
 
@@ -53,7 +54,7 @@ class TestLoadConfig:
53
  path = _write_config({})
54
  try:
55
  cfg = load_config(path)
56
- assert cfg.chunk_size == 1000
57
  finally:
58
  os.unlink(path)
59
 
 
16
  class TestConfigDefaults:
17
  def test_default_values(self):
18
  cfg = Config()
19
+ assert cfg.chunk_size == 600
20
+ assert cfg.overlap == 100
21
+ assert cfg.chunk_tokenizer == "gpt2"
22
  assert cfg.log_level == "INFO"
23
 
24
  def test_custom_values(self):
 
46
  path = _write_config({"chunk_size": 256})
47
  try:
48
  cfg = load_config(path)
49
+ assert cfg.overlap == 100 # default
50
  finally:
51
  os.unlink(path)
52
 
 
54
  path = _write_config({})
55
  try:
56
  cfg = load_config(path)
57
+ assert cfg.chunk_size == 600
58
  finally:
59
  os.unlink(path)
60
 
tests/unit/test_document_processor.py CHANGED
@@ -7,7 +7,7 @@ from src.core.document_processor import DocumentProcessor
7
 
8
  @pytest.fixture
9
  def processor():
10
- return DocumentProcessor(chunk_size=100, overlap=20)
11
 
12
 
13
  def _write_temp_file(content: str, suffix: str) -> str:
@@ -69,22 +69,23 @@ class TestChunkText:
69
  assert chunks[0] == "short text"
70
 
71
  def test_long_text_produces_multiple_chunks(self, processor):
72
- text = "a" * 300
73
  chunks = processor.chunk_text(text)
74
  assert len(chunks) > 1
75
 
76
  def test_chunk_size_respected(self, processor):
77
- text = "x" * 250
78
  chunks = processor.chunk_text(text)
79
- assert all(len(c) <= processor.chunk_size for c in chunks)
80
 
81
  def test_overlap_creates_shared_content(self):
82
- proc = DocumentProcessor(chunk_size=50, overlap=10)
83
- text = "a" * 100
84
  chunks = proc.chunk_text(text)
85
- # With overlap, step = 40, so chunks[1] should start at index 40
86
- # meaning the last 10 chars of chunk[0] equal the first 10 of the overlap region
87
- assert len(chunks) >= 3
 
88
 
89
  def test_no_empty_chunks(self, processor):
90
  chunks = processor.chunk_text("hello world " * 20)
 
7
 
8
  @pytest.fixture
9
  def processor():
10
+ return DocumentProcessor(chunk_size=50, overlap=10)
11
 
12
 
13
  def _write_temp_file(content: str, suffix: str) -> str:
 
69
  assert chunks[0] == "short text"
70
 
71
  def test_long_text_produces_multiple_chunks(self, processor):
72
+ text = "token " * 300
73
  chunks = processor.chunk_text(text)
74
  assert len(chunks) > 1
75
 
76
  def test_chunk_size_respected(self, processor):
77
+ text = "word " * 250
78
  chunks = processor.chunk_text(text)
79
+ assert all(processor.count_tokens(c) <= processor.chunk_size for c in chunks)
80
 
81
  def test_overlap_creates_shared_content(self):
82
+ proc = DocumentProcessor(chunk_size=20, overlap=5)
83
+ text = "alpha beta gamma delta epsilon " * 40
84
  chunks = proc.chunk_text(text)
85
+ assert len(chunks) >= 2
86
+ first_tokens = proc._tokenizer.encode(chunks[0])
87
+ second_tokens = proc._tokenizer.encode(chunks[1])
88
+ assert first_tokens[-proc.overlap:] == second_tokens[:proc.overlap]
89
 
90
  def test_no_empty_chunks(self, processor):
91
  chunks = processor.chunk_text("hello world " * 20)