axiom-coding-agent-setup 1.0.12 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/CONTEXT-MANAGEMENT.md +155 -0
- package/.agents/DEBUGGING.md +124 -0
- package/.agents/{engineering.md → ENGINEERING.md} +180 -174
- package/.agents/PERFORMANCE.md +164 -0
- package/.agents/SECURITY.md +109 -0
- package/.agents/{workflow.md → WORKFLOW.md} +143 -137
- package/.agents/skills/agent-browser/SKILL.md +55 -55
- package/.agents/skills/project-design/SKILL.md +207 -207
- package/.agents/skills/project-design/references/ARCHITECTURE.md +641 -641
- package/.agents/skills/project-design/references/PROJECT_PLAN.md +315 -315
- package/.env.axiom +8 -8
- package/AGENTS.md +104 -40
- package/README.md +145 -110
- package/bin/cli.js +14 -7
- package/error/error.md +57 -0
- package/opencode.json +64 -64
- package/package.json +1 -1
- package/plugin/oh-my-openagent.json +198 -198
- package/skills-lock.json +57 -57
- /package/.agents/{stack.md → STACK.md} +0 -0
|
@@ -1,641 +1,641 @@
|
|
|
1
|
-
# AdaptiveRAG — Architecture
|
|
2
|
-
|
|
3
|
-
A hybrid Adaptive RAG system. Each query is classified at runtime into one of five execution strategies (`no_retrieval`, `vector_only`, `sql_only`, `hybrid`, `clarify`) and dispatched to the right backend(s). Documents flow through a markdown-first ingestion pipeline; the retrieval layer is hybrid (dense + BM25 + cross-encoder rerank); the SQL layer is read-only with defense in depth.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Table of Contents
|
|
8
|
-
|
|
9
|
-
1. [Goals & Non-Goals](#1-goals--non-goals)
|
|
10
|
-
2. [Core Principles](#2-core-principles)
|
|
11
|
-
3. [System Overview](#3-system-overview)
|
|
12
|
-
4. [Tech Stack](#4-tech-stack)
|
|
13
|
-
5. [Project Structure](#5-project-structure)
|
|
14
|
-
6. [Ingestion Pipeline](#6-ingestion-pipeline)
|
|
15
|
-
7. [Chunking Strategy](#7-chunking-strategy)
|
|
16
|
-
8. [Retrieval Layer](#8-retrieval-layer)
|
|
17
|
-
9. [Adaptive Query Router](#9-adaptive-query-router)
|
|
18
|
-
10. [Tool Layer (Read-Only SQL)](#10-tool-layer-read-only-sql)
|
|
19
|
-
11. [Synthesis & Citations](#11-synthesis--citations)
|
|
20
|
-
12. [Caching & Cost Control](#12-caching--cost-control)
|
|
21
|
-
13. [Observability & Cost Tracking](#13-observability--cost-tracking)
|
|
22
|
-
14. [Evaluation Framework](#14-evaluation-framework)
|
|
23
|
-
15. [Configuration](#15-configuration)
|
|
24
|
-
16. [Implementation Status](#16-implementation-status)
|
|
25
|
-
17. [Decision Log](#17-decision-log)
|
|
26
|
-
18. [Future Work](#18-future-work)
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## 1. Goals & Non-Goals
|
|
31
|
-
|
|
32
|
-
### Goals
|
|
33
|
-
|
|
34
|
-
- **Adaptive retrieval** — pick the right strategy per-query, not per-file-extension.
|
|
35
|
-
- **Markdown-first ingestion** — convert every input format to markdown so chunks are header-aware.
|
|
36
|
-
- **Hybrid retrieval at the index layer** — dense embeddings + BM25 + reciprocal-rank fusion + cross-encoder reranker.
|
|
37
|
-
- **Grounded answers with citations** — inline `[n]` markers for chunks, `[DB]` for SQL data, parsed back into structured citations for the UI.
|
|
38
|
-
- **Cost-bounded** — content-hash caches for OCR and embeddings; cheap models for routing, frontier models only for synthesis.
|
|
39
|
-
- **Portfolio-presentable** — clean code, working demo, real metrics in Phase 6.
|
|
40
|
-
|
|
41
|
-
### Non-Goals
|
|
42
|
-
|
|
43
|
-
- Multi-tenant SaaS with RBAC.
|
|
44
|
-
- Real-time CDC / database mirroring into vectors. (See decision log: never embed structured data.)
|
|
45
|
-
- Distributed task queue. `FastAPI BackgroundTasks` is enough until proven otherwise.
|
|
46
|
-
- A constellation of MCP servers. One optional MCP surface that wraps the same tools is enough.
|
|
47
|
-
- Production monitoring stack (Prometheus / Grafana / Jaeger). Langfuse for traces is enough.
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## 2. Core Principles
|
|
52
|
-
|
|
53
|
-
1. **If the answer is a sentence, embed it. If the answer is a number, query it.** Free text goes to vectors; structured data stays in SQL.
|
|
54
|
-
2. **Decide adaptively at query time, not at ingest time.** A PDF can contain prose *and* tables; a SQL row can have a free-text comment. Routing has to see the question, not just the file.
|
|
55
|
-
3. **Markdown is the universal intermediate format.** Every parser output normalizes to markdown before chunking.
|
|
56
|
-
4. **Quality of parsing beats quantity of features.** One excellent ingestion path is better than five mediocre ones.
|
|
57
|
-
5. **Measure before optimizing.** No reranker, no advanced chunking, no MCP — until eval scores justify each addition.
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## 3. System Overview
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
┌──────────────────────────────────────────────────────────────────────┐
|
|
65
|
-
│ INGESTION (offline) │
|
|
66
|
-
│ │
|
|
67
|
-
│ File ─► FileTypeDetector ─► ParserRouter ─► Markdown ─► Chunker │
|
|
68
|
-
│ │ │
|
|
69
|
-
│ ├─ Docling (default) │
|
|
70
|
-
│ ├─ Qwen3-VL (image / scan) │
|
|
71
|
-
│ └─ passthrough (.md, .txt) │
|
|
72
|
-
│ │
|
|
73
|
-
│ Markdown ─► MarkdownHeaderSplitter ─► Embedder ─► Qdrant │
|
|
74
|
-
│ │ │
|
|
75
|
-
│ ├─ dense (text-emb-3) │
|
|
76
|
-
│ └─ sparse (BM25 / IDF) │
|
|
77
|
-
└──────────────────────────────────────────────────────────────────────┘
|
|
78
|
-
|
|
79
|
-
┌──────────────────────────────────────────────────────────────────────┐
|
|
80
|
-
│ QUERY-TIME (online) │
|
|
81
|
-
│ │
|
|
82
|
-
│ user query │
|
|
83
|
-
│ │ │
|
|
84
|
-
│ ▼ │
|
|
85
|
-
│ ┌──────────────────────┐ │
|
|
86
|
-
│ │ AdaptiveRouter │ cheap LLM classifier │
|
|
87
|
-
│ │ → strategy + intent │ (gpt-4.1-nano default) │
|
|
88
|
-
│ └────────┬─────────────┘ │
|
|
89
|
-
│ │ │
|
|
90
|
-
│ ┌───────┼───────────┬─────────────┬────────────────┐ │
|
|
91
|
-
│ ▼ ▼ ▼ ▼ ▼ │
|
|
92
|
-
│ no_retr. vector sql_only hybrid clarify │
|
|
93
|
-
│ (LLM) ↓ Qdrant ↓ NL→SQL ↓ both (ask user) │
|
|
94
|
-
│ ↓ + rerank ↓ + execute ↓ + merge │
|
|
95
|
-
│ └──────────┴────────────┘ │
|
|
96
|
-
│ │ │
|
|
97
|
-
│ ▼ │
|
|
98
|
-
│ GroundedAnswerer (LLM) │
|
|
99
|
-
│ │ │
|
|
100
|
-
│ ▼ │
|
|
101
|
-
│ answer with [n] / [DB] citations │
|
|
102
|
-
└──────────────────────────────────────────────────────────────────────┘
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
---
|
|
106
|
-
|
|
107
|
-
## 4. Tech Stack
|
|
108
|
-
|
|
109
|
-
| Component | Library | Role |
|
|
110
|
-
|---|---|---|
|
|
111
|
-
| Document parsing | `docling>=2.92` | Born-digital PDFs, DOCX, PPTX, XLSX, HTML, CSV |
|
|
112
|
-
| OCR | `openai>=2.33` against DashScope OpenAI-compat endpoint | Qwen3-VL-Plus for images and scanned PDFs |
|
|
113
|
-
| LLM framework | `langchain>=1.2`, `langchain-core>=1.3` | Message types, prompts, structured output |
|
|
114
|
-
| LLM client | `langchain-openai>=1.2` | Chat synthesis, router, NL→SQL |
|
|
115
|
-
| Embeddings — dense | `langchain-openai` + `text-embedding-3-small` | 1536-dim, with SHA256 disk cache |
|
|
116
|
-
| Embeddings — sparse | `fastembed>=0.4` (`Qdrant/bm25`) | Local BM25 with IDF, no GPU |
|
|
117
|
-
| Vector DB | `qdrant-client>=1.12` + `langchain-qdrant>=1.1` | Hybrid collection (dense + sparse named vectors) |
|
|
118
|
-
| Reranker | `flashrank>=0.2.9` | Pure-ONNX cross-encoder (`ms-marco-MiniLM-L-12-v2`, ~34 MB) |
|
|
119
|
-
| Splitters | `langchain-text-splitters>=1.1` | Header-aware + recursive fallback |
|
|
120
|
-
| PDF inspection | `pypdfium2>=4.30` | Born-digital heuristic + page rendering |
|
|
121
|
-
| Schema validation | `pydantic>=2.9` | Structured router output, structured NL→SQL |
|
|
122
|
-
| SQL | `sqlalchemy>=2.0.36` + `psycopg[binary]>=3.2.3` | Read-only Postgres tool |
|
|
123
|
-
| Retry | `tenacity>=9.x` | Qwen API resilience |
|
|
124
|
-
| UI | `gradio>=6.13` | Tabbed Chat / Ingest / Convert / Admin demo |
|
|
125
|
-
| Tracing | `langfuse>=4.0` | Per-turn spans, token counts, USD cost |
|
|
126
|
-
| Eval | `deepeval>=3.0` | Faithfulness / answer relevancy / contextual relevancy |
|
|
127
|
-
| Env | `python-dotenv>=1.2` | `.env` config loader |
|
|
128
|
-
|
|
129
|
-
### Explicitly avoided
|
|
130
|
-
|
|
131
|
-
- `celery`, `redis` — not needed; `FastAPI BackgroundTasks` is sufficient.
|
|
132
|
-
- `transformers`, `torch`, `accelerate` — Qwen is API-based and FlashRank uses ONNX. Keeps the install lean and dodges Python-3.14 native-extension instability.
|
|
133
|
-
- `watchdog` — explicit upload via UI/API is fine.
|
|
134
|
-
- CDC tooling (Debezium et al.) — query DB live via tool, never sync.
|
|
135
|
-
- Prometheus / Grafana — Langfuse covers it for v1.
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## 5. Project Structure
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
adaptive-rag/
|
|
143
|
-
├── app.py Gradio entry point
|
|
144
|
-
├── pyproject.toml
|
|
145
|
-
├── docker-compose.yml Qdrant + Postgres
|
|
146
|
-
├── .env.example
|
|
147
|
-
├── ARCHITECTURE.md (this file)
|
|
148
|
-
├── README.md
|
|
149
|
-
├── PROJECT_PLAN.md Phase-by-phase status
|
|
150
|
-
├── scripts/
|
|
151
|
-
│ ├── init_qdrant.py Create / recreate the Qdrant collection
|
|
152
|
-
│ └── seed_demo_data.py Seed Postgres with demo e-commerce data
|
|
153
|
-
│
|
|
154
|
-
├── src/
|
|
155
|
-
│ ├── config/
|
|
156
|
-
│ │ └── settings.py Single source of truth for all tunables
|
|
157
|
-
│ │
|
|
158
|
-
│ ├── core/ Document → markdown
|
|
159
|
-
│ │ ├── file_detector.py Format detection + validation
|
|
160
|
-
│ │ ├── docling_parser.py Docling-backed parser
|
|
161
|
-
│ │ ├── qwen_parser.py Qwen3-VL OCR with retry + cache
|
|
162
|
-
│ │ ├── parser_router.py Picks Docling vs Qwen per file
|
|
163
|
-
│ │ └── converter.py Public conversion API
|
|
164
|
-
│ │
|
|
165
|
-
│ ├── chunking/ Markdown → header-aware chunks
|
|
166
|
-
│ │ ├── markdown_chunker.py Header splitter + recursive fallback
|
|
167
|
-
│ │ └── metadata.py doc_id (SHA256), chunk_uuid (UUID5)
|
|
168
|
-
│ │
|
|
169
|
-
│ ├── indexing/ Chunks → Qdrant
|
|
170
|
-
│ │ ├── embeddings.py Dense (cached) + BM25 sparse
|
|
171
|
-
│ │ ├── qdrant_store.py Hybrid collection + dedup + library
|
|
172
|
-
│ │ └── pipeline.py Convert → chunk → upsert
|
|
173
|
-
│ │
|
|
174
|
-
│ ├── retrieval/ Query → ranked chunks
|
|
175
|
-
│ │ ├── hybrid_search.py HybridRetriever + RetrievalPipeline
|
|
176
|
-
│ │ └── reranker.py FlashRank ONNX cross-encoder
|
|
177
|
-
│ │
|
|
178
|
-
│ ├── routing/ Adaptive router + dispatcher
|
|
179
|
-
│ │ ├── strategies.py Strategy StrEnum + capability sets
|
|
180
|
-
│ │ ├── prompts.py Router system prompt + few-shots
|
|
181
|
-
│ │ ├── adaptive_router.py LLM classifier (structured output)
|
|
182
|
-
│ │ └── dispatcher.py Compose router + retrieval + SQL + synthesis
|
|
183
|
-
│ │
|
|
184
|
-
│ ├── tools/ External tools the dispatcher can call
|
|
185
|
-
│ │ └── sql_tool.py Read-only NL→SQL with safety guards
|
|
186
|
-
│ │
|
|
187
|
-
│ ├── synthesis/ Chunks (+ SQL) → grounded answer
|
|
188
|
-
│ │ └── response.py GroundedAnswerer + Citation parsing
|
|
189
|
-
│ │
|
|
190
|
-
│ ├── observability/ Tracing + cost tracking
|
|
191
|
-
│ │ ├── langfuse_client.py Singleton Langfuse + no-op span() ctx mgr
|
|
192
|
-
│ │ └── cost_tracker.py Pulls daily metrics from Langfuse REST API
|
|
193
|
-
│ │
|
|
194
|
-
│ ├── eval/ Golden set + accuracy / DeepEval runners
|
|
195
|
-
│ │ ├── golden.jsonl Tiny smoke golden set (~one row per strategy)
|
|
196
|
-
│ │ ├── run_routing_eval.py Router-only accuracy gate (CI-friendly)
|
|
197
|
-
│ │ └── run_deepeval.py DeepEval runner + JSON + HTML report
|
|
198
|
-
│ │
|
|
199
|
-
│ ├── cache/ Content-hash caches
|
|
200
|
-
│ │ ├── ocr_cache.py SHA256-keyed disk cache for OCR markdown
|
|
201
|
-
│ │ └── embedding_cache.py SHA256-keyed disk cache for vectors
|
|
202
|
-
│ │
|
|
203
|
-
│ ├── utils/
|
|
204
|
-
│ │ └── pdf_inspector.py Born-digital heuristic + page rendering
|
|
205
|
-
│ │
|
|
206
|
-
│ └── ui/ Gradio interface
|
|
207
|
-
│ ├── main_ui.py Tab composition
|
|
208
|
-
│ ├── chat_ui.py Chat tab (calls AdaptiveDispatcher)
|
|
209
|
-
│ ├── ingest_ui.py Ingest tab (multi-file upload + library)
|
|
210
|
-
│ ├── markdown_converter_ui.py Convert tab (single-document preview)
|
|
211
|
-
│ └── admin_ui.py Admin tab — Langfuse cost dashboard
|
|
212
|
-
│
|
|
213
|
-
└── docs/
|
|
214
|
-
├── check_postgres.md DB inspection cheatsheet
|
|
215
|
-
└── check_qdrant.md Vector DB inspection cheatsheet
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
---
|
|
219
|
-
|
|
220
|
-
## 6. Ingestion Pipeline
|
|
221
|
-
|
|
222
|
-
### Parser routing
|
|
223
|
-
|
|
224
|
-
```
|
|
225
|
-
file_type ──┐
|
|
226
|
-
├── .md / .txt ──────────────────► passthrough
|
|
227
|
-
│
|
|
228
|
-
├── .pdf ──┬─ "born-digital" ───► Docling (fast, text layer)
|
|
229
|
-
│ └─ "scanned" ────────► Qwen3-VL (vision)
|
|
230
|
-
│
|
|
231
|
-
├── .docx / .pptx / .xlsx / .html / .csv ──► Docling
|
|
232
|
-
│
|
|
233
|
-
└── .png / .jpg / .webp ──► Qwen3-VL (better than Tesseract on layout)
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
The "born-digital vs scanned" decision for PDFs is a cheap heuristic: render the text layer of the first three pages and treat the file as scanned if the total extracted character count is below a small threshold. Docling has its own internal OCR fallback (EasyOCR / Tesseract); the heuristic lets us skip that path and use Qwen3-VL when accuracy matters.
|
|
237
|
-
|
|
238
|
-
The user can override this routing per-file with a "Force Qwen3-VL OCR for PDFs" toggle in the Convert tab.
|
|
239
|
-
|
|
240
|
-
### Qwen3-VL OCR
|
|
241
|
-
|
|
242
|
-
Calls the DashScope OpenAI-compatible endpoint. The OCR prompt is intentionally deterministic:
|
|
243
|
-
|
|
244
|
-
> Extract all text from this image into clean GitHub-flavored Markdown. Preserve table structure with pipe syntax. Preserve heading hierarchy. Do not summarize, do not add commentary. If text is illegible, write `[illegible]`.
|
|
245
|
-
|
|
246
|
-
`tenacity` handles transient API failures with exponential backoff. The result is content-hash cached so re-uploading the same file (or re-rendering the same page from a multi-page PDF) never spends a second API call.
|
|
247
|
-
|
|
248
|
-
### Content-hash everything
|
|
249
|
-
|
|
250
|
-
Three things use SHA256 as a primary key:
|
|
251
|
-
|
|
252
|
-
| Cache | Key | Stored |
|
|
253
|
-
|---|---|---|
|
|
254
|
-
| OCR | `sha256(image_bytes)` | Markdown text on disk |
|
|
255
|
-
| Embeddings | `sha256(model_name + text)` | Raw `float32` vector bytes on disk |
|
|
256
|
-
| Documents | `sha256(file_bytes)[:16]` | `doc_id` for dedup + library listing |
|
|
257
|
-
|
|
258
|
-
Re-ingesting the same file replaces its prior chunks in Qdrant atomically (delete-by-`doc_id` then upsert).
|
|
259
|
-
|
|
260
|
-
---
|
|
261
|
-
|
|
262
|
-
## 7. Chunking Strategy
|
|
263
|
-
|
|
264
|
-
Two-pass:
|
|
265
|
-
|
|
266
|
-
1. **`MarkdownHeaderTextSplitter`** splits by `#`, `##`, `###`. Headers are kept in the chunk content and the header path is also written to chunk metadata.
|
|
267
|
-
2. **`RecursiveCharacterTextSplitter`** splits any header-section that exceeds `CHUNK_SIZE` (default 1500 chars). Each sub-chunk inherits the parent's header path.
|
|
268
|
-
|
|
269
|
-
### Per-chunk metadata
|
|
270
|
-
|
|
271
|
-
```json
|
|
272
|
-
{
|
|
273
|
-
"doc_id": "dc7c3912cd0b003d",
|
|
274
|
-
"source": "data/policy.pdf",
|
|
275
|
-
"filename": "policy.pdf",
|
|
276
|
-
"header_path": "Refund Policy > Eligibility",
|
|
277
|
-
"chunk_index": 7,
|
|
278
|
-
"total_chunks": 23,
|
|
279
|
-
"ingested_at": "2026-05-09T04:52:24+00:00",
|
|
280
|
-
"parser": "docling" // or "qwen3-vl" or "passthrough"
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
`chunk_uuid` is generated deterministically via `UUID5(doc_id, chunk_index)` so re-upserts are idempotent.
|
|
285
|
-
|
|
286
|
-
No `access_level` / `department` / `tags` in v1 — those go in only when a feature actually consumes them.
|
|
287
|
-
|
|
288
|
-
---
|
|
289
|
-
|
|
290
|
-
## 8. Retrieval Layer
|
|
291
|
-
|
|
292
|
-
### Qdrant collection: hybrid by default
|
|
293
|
-
|
|
294
|
-
```python
|
|
295
|
-
client.create_collection(
|
|
296
|
-
collection_name="adaptive_rag",
|
|
297
|
-
vectors_config={
|
|
298
|
-
"dense": models.VectorParams(size=1536, distance=models.Distance.COSINE),
|
|
299
|
-
},
|
|
300
|
-
sparse_vectors_config={
|
|
301
|
-
"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF),
|
|
302
|
-
},
|
|
303
|
-
)
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
Both vectors are populated for every chunk at ingest time; queries hit both.
|
|
307
|
-
|
|
308
|
-
### Query flow
|
|
309
|
-
|
|
310
|
-
```
|
|
311
|
-
query
|
|
312
|
-
│
|
|
313
|
-
├─► dense embedding (text-embedding-3-small, cached)
|
|
314
|
-
├─► sparse encoding (FastEmbed BM25, IDF on server)
|
|
315
|
-
│
|
|
316
|
-
▼
|
|
317
|
-
Qdrant `query_points` with prefetch:
|
|
318
|
-
- prefetch dense (top RETRIEVAL_PREFETCH_K = 25)
|
|
319
|
-
- prefetch sparse (top RETRIEVAL_PREFETCH_K = 25)
|
|
320
|
-
- fusion: server-side RRF
|
|
321
|
-
│
|
|
322
|
-
▼
|
|
323
|
-
FlashRank cross-encoder rerank
|
|
324
|
-
→ top RERANK_TOP_K = 5
|
|
325
|
-
│
|
|
326
|
-
▼
|
|
327
|
-
Pass to GroundedAnswerer with header_path context
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
The fusion is server-side RRF (Qdrant native), not client-side merging — single round-trip per query.
|
|
331
|
-
|
|
332
|
-
The reranker uses `ms-marco-MiniLM-L-12-v2` by default (~34 MB ONNX). Lazy first-use download to `CACHE_DIR/flashrank/`. If model loading or scoring fails, the pipeline gracefully falls back to the hybrid-fusion order. Alternatives configurable via `RERANKER_MODEL`:
|
|
333
|
-
|
|
334
|
-
| Model | Size | Notes |
|
|
335
|
-
|---|---|---|
|
|
336
|
-
| `ms-marco-TinyBERT-L-2-v2` | ~4 MB | Fastest |
|
|
337
|
-
| `ms-marco-MiniLM-L-12-v2` | ~34 MB | **Default** — balanced |
|
|
338
|
-
| `ms-marco-MultiBERT-L-12` | ~150 MB | Multilingual |
|
|
339
|
-
| `rank-T5-flan` | ~110 MB | Best quality |
|
|
340
|
-
|
|
341
|
-
Hybrid search typically yields **+5–15% retrieval recall** over pure dense; reranking adds another **+10–20% context precision** on top. Numbers will be re-validated against the golden set in Phase 6.
|
|
342
|
-
|
|
343
|
-
---
|
|
344
|
-
|
|
345
|
-
## 9. Adaptive Query Router
|
|
346
|
-
|
|
347
|
-
The router is what makes this *Adaptive RAG* (per Jeong et al., 2024 — query-complexity-aware strategy selection) rather than static dispatch.
|
|
348
|
-
|
|
349
|
-
### Strategies
|
|
350
|
-
|
|
351
|
-
| Strategy | When | Touches |
|
|
352
|
-
|---|---|---|
|
|
353
|
-
| `no_retrieval` | Greeting, chitchat, generic knowledge, math | LLM only |
|
|
354
|
-
| `vector_only` | Conceptual / "what does our doc say about X" | Qdrant + reranker + LLM with `[n]` citations |
|
|
355
|
-
| `sql_only` | Quantitative / "how many" / "top N" / aggregates | NL→SQL → execute → LLM with `[DB]` citation |
|
|
356
|
-
| `hybrid` | Question needs both narrative AND a number | Vector AND SQL → blended answer |
|
|
357
|
-
| `clarify` | Genuinely ambiguous | One focused follow-up question, no retrieval cost |
|
|
358
|
-
|
|
359
|
-
### How a decision is made
|
|
360
|
-
|
|
361
|
-
A single LLM call with structured output. The classifier model is intentionally cheap (`gpt-4.1-nano` by default) — classification doesn't need frontier reasoning, and we want this on the hot path of every chat turn.
|
|
362
|
-
|
|
363
|
-
```python
|
|
364
|
-
class RouterDecision(BaseModel):
|
|
365
|
-
strategy: Strategy # one of the five above
|
|
366
|
-
reasoning: str # one-sentence justification
|
|
367
|
-
vector_query: str | None # optional rephrased search query
|
|
368
|
-
sql_intent: str | None # NL description for the SQL tool
|
|
369
|
-
clarification_question: str | None # only set when strategy == clarify
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
The router prompt includes:
|
|
373
|
-
1. Strategy descriptions + few-shot examples (anchors classification).
|
|
374
|
-
2. The actual chat history (so follow-ups like "what about last quarter?" can resolve to a real referent instead of always falling to `clarify`).
|
|
375
|
-
3. A one-line summary of available SQL tables — fetched via `inspect()` once at startup. ~80 tokens. Lets the router decide "this is a database question" vs "this is a docs question."
|
|
376
|
-
|
|
377
|
-
If `SQL_DATABASE_URL` is unset, the prompt is told "no SQL backend" and a sanitizer downgrades any leaked `sql_only`/`hybrid` decision to `vector_only`. The router can never pick a strategy it can't fulfill.
|
|
378
|
-
|
|
379
|
-
### Dispatch
|
|
380
|
-
|
|
381
|
-
`AdaptiveDispatcher.answer(query, history)` is the single entry point the chat UI calls. It:
|
|
382
|
-
|
|
383
|
-
1. Classifies the query.
|
|
384
|
-
2. For `clarify` / `no_retrieval`: short-circuits without touching retrieval or SQL.
|
|
385
|
-
3. For `vector_only` / `hybrid`: runs the retrieval pipeline.
|
|
386
|
-
4. For `sql_only` / `hybrid`: runs the SQL tool. If the tool fails (DB down, query rejected), records a note and continues with whatever else it has.
|
|
387
|
-
5. Calls the appropriate `GroundedAnswerer` method.
|
|
388
|
-
6. Returns an `AdaptiveAnswer` with strategy, decision, citations, executed SQL, and per-stage timings.
|
|
389
|
-
|
|
390
|
-
Backends are initialized lazily on first use so the app starts fast even when SQL or OpenAI aren't configured.
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
|
-
## 10. Tool Layer (Read-Only SQL)
|
|
395
|
-
|
|
396
|
-
### Defense in depth
|
|
397
|
-
|
|
398
|
-
The SQL tool is *not* an agent. It runs once per turn, with five layers of safety:
|
|
399
|
-
|
|
400
|
-
1. **Dedicated read-only Postgres role.** `seed_demo_data.py` creates `adaptive_rag_ro` with `SELECT`-only grants. The app connects as that role.
|
|
401
|
-
2. **Statement-level allowlist.** Only `SELECT` and `WITH` (CTE-resolved-to-SELECT) statements are accepted.
|
|
402
|
-
3. **Forbidden-keyword regex.** Catches `INSERT|UPDATE|DELETE|MERGE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|COPY|VACUUM|...` even if the role grants would already block them.
|
|
403
|
-
4. **Per-session statement timeout.** Default 5 seconds (`SQL_QUERY_TIMEOUT_SEC`); runaway plans die fast.
|
|
404
|
-
5. **Implicit row cap.** A `LIMIT N` (default 200, `SQL_ROW_LIMIT`) is appended if the SQL doesn't already cap rows.
|
|
405
|
-
|
|
406
|
-
Plus `SET TRANSACTION READ ONLY` on every connection — belt + suspenders + bungee.
|
|
407
|
-
|
|
408
|
-
### NL → SQL
|
|
409
|
-
|
|
410
|
-
A second LLM call (`gpt-4.1-mini` by default, `SQL_MODEL`) translates the router's `sql_intent` into a single `SELECT`. Schema is included in the prompt — generated once via SQLAlchemy `inspect()` at startup, formatted as DDL-style table descriptions with column types, primary keys, and foreign keys.
|
|
411
|
-
|
|
412
|
-
Structured output forces a `_SqlOutput { sql: str }` schema, so the LLM can't dump prose around the query.
|
|
413
|
-
|
|
414
|
-
### Demo dataset
|
|
415
|
-
|
|
416
|
-
`scripts/seed_demo_data.py` builds a deterministic e-commerce schema:
|
|
417
|
-
|
|
418
|
-
```
|
|
419
|
-
customers (100 rows) — id, name, email, country, signup_date
|
|
420
|
-
products (50 rows) — id, name, category, price, stock
|
|
421
|
-
orders (500 rows) — id, customer_id, status, total, created_at
|
|
422
|
-
order_items (~1300 rows) — id, order_id, product_id, quantity, unit_price
|
|
423
|
-
refunds (~35 rows) — id, order_id, amount, reason, created_at
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
Idempotent (skips if already populated) with `--recreate` to wipe and reseed. The fixed RNG seed gives the same data every run, so demos and golden-set evals are reproducible.
|
|
427
|
-
|
|
428
|
-
### Optional MCP surface (Phase 7)
|
|
429
|
-
|
|
430
|
-
The same `SqlTool` and retrieval pipeline can be exposed as an MCP server so Cursor / Claude Desktop can use them without the Gradio UI. This is the *correct* use of MCP — making the same capabilities reusable across LLM clients — not as a microservice framework.
|
|
431
|
-
|
|
432
|
-
---
|
|
433
|
-
|
|
434
|
-
## 11. Synthesis & Citations
|
|
435
|
-
|
|
436
|
-
`GroundedAnswerer` has three modes that the dispatcher selects:
|
|
437
|
-
|
|
438
|
-
| Method | Used for | Context shape |
|
|
439
|
-
|---|---|---|
|
|
440
|
-
| `answer_direct` | `no_retrieval` | Just the user query + chat history |
|
|
441
|
-
| `answer` | `vector_only` | Numbered passage block `[1]..[N]` |
|
|
442
|
-
| `answer_with_sql` | `sql_only` / `hybrid` | Passages **plus** a "Database query results" section with the executed SQL and a markdown-rendered preview of rows |
|
|
443
|
-
|
|
444
|
-
### Citation contract
|
|
445
|
-
|
|
446
|
-
The system prompt requires inline brackets for every claim:
|
|
447
|
-
|
|
448
|
-
- `[1]`, `[2, 3]` — refer to chunk numbers in the passage block.
|
|
449
|
-
- `[DB]` — refers to the SQL results block (only valid when SQL data is present).
|
|
450
|
-
|
|
451
|
-
The chat layer parses the LLM's output back into structured citations:
|
|
452
|
-
|
|
453
|
-
```python
|
|
454
|
-
@dataclass
|
|
455
|
-
class Citation:
|
|
456
|
-
index: int # 1-based chunk number
|
|
457
|
-
label: str # "policy.pdf > Refunds"
|
|
458
|
-
snippet: str # short preview
|
|
459
|
-
source: str | None
|
|
460
|
-
doc_id: str | None
|
|
461
|
-
rank: int
|
|
462
|
-
rerank_score: float | None
|
|
463
|
-
hybrid_score: float
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
Only chunks the LLM actually cited end up in the right-hand Sources panel — clean UI by default, with a debug mode that surfaces all retrieved chunks if the LLM cites none. The `cited_db` boolean flag drives a separate "SQL executed" block in the panel.
|
|
467
|
-
|
|
468
|
-
---
|
|
469
|
-
|
|
470
|
-
## 12. Caching & Cost Control
|
|
471
|
-
|
|
472
|
-
Three caches on disk, all SHA256-keyed:
|
|
473
|
-
|
|
474
|
-
| Cache | Key | Rationale |
|
|
475
|
-
|---|---|---|
|
|
476
|
-
| OCR | `sha256(image_bytes)` | Qwen3-VL is paid per-call. Re-ingest of the same image must never re-OCR. |
|
|
477
|
-
| Dense embeddings | `sha256(model_name + text)` | OpenAI is paid per-token. Re-chunking with the same content must not re-embed. |
|
|
478
|
-
| Reranker model | FlashRank's own | First-use download (~34 MB), reused thereafter. |
|
|
479
|
-
|
|
480
|
-
Default location is `./.cache/`, override with `CACHE_DIR`.
|
|
481
|
-
|
|
482
|
-
Cost-per-query in the current configuration is dominated by the synthesis LLM (`gpt-4.1-mini`). Routing (`gpt-4.1-nano`) is roughly 1/10th the cost; SQL translation is one extra mini-call only when needed. The live numbers are tracked by Langfuse — see the next section.
|
|
483
|
-
|
|
484
|
-
---
|
|
485
|
-
|
|
486
|
-
## 13. Observability & Cost Tracking
|
|
487
|
-
|
|
488
|
-
Tracing is fully optional. When `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` are unset every helper in `src/observability/` is a no-op stub, so the rest of the application code is not branched on "is tracing on?".
|
|
489
|
-
|
|
490
|
-
### Span tree per chat turn
|
|
491
|
-
|
|
492
|
-
`AdaptiveDispatcher.answer()` opens one parent span and the helpers open children:
|
|
493
|
-
|
|
494
|
-
```
|
|
495
|
-
chat.turn (input=query, history_len)
|
|
496
|
-
├── router.classify (input=query; output={strategy, reasoning, vector_query, sql_intent})
|
|
497
|
-
├── retrieval.hybrid_search (input=query; metadata={fused_count, rerank_ms, prefetch_k, top_k, model})
|
|
498
|
-
├── tool.sql_execute (input=intent; output={sql, row_count, columns, rows_preview})
|
|
499
|
-
└── synthesis.{direct|grounded} (input=ctx_summary; output={answer, cited_indices, cited_db})
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
The LangChain `CallbackHandler` is also passed into every `ChatOpenAI.invoke(...)` (router, NL→SQL, both synthesis modes), so each LLM call appears as a `GENERATION` observation with token counts + the model price → USD cost computed server-side by Langfuse. No price table is duplicated client-side.
|
|
503
|
-
|
|
504
|
-
`flush_traces()` is called at the end of each turn so spans appear in the dashboard within a second; we don't rely on the background flush thread to survive a Gradio request boundary.
|
|
505
|
-
|
|
506
|
-
### Cost tracker
|
|
507
|
-
|
|
508
|
-
`src/observability/cost_tracker.py` is a tiny `httpx`-based reader for Langfuse's `/api/public/metrics/daily` endpoint. It returns a `CostSummary` dataclass with:
|
|
509
|
-
|
|
510
|
-
- Total traces / observations / tokens / USD cost in the window
|
|
511
|
-
- Per-model breakdown (calls, in/out tokens, cost)
|
|
512
|
-
- Per-day breakdown
|
|
513
|
-
|
|
514
|
-
The Admin tab renders the same summary as a Gradio markdown view with a 24h / 7d / 30d window selector. CLI:
|
|
515
|
-
|
|
516
|
-
```bash
|
|
517
|
-
uv run python -m src.observability.cost_tracker --days 7
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
---
|
|
521
|
-
|
|
522
|
-
## 14. Evaluation Framework
|
|
523
|
-
|
|
524
|
-
Two complementary scripts. Both consume the same golden set and write reports under `src/eval/reports/`.
|
|
525
|
-
|
|
526
|
-
### Golden set — `src/eval/golden.jsonl`
|
|
527
|
-
|
|
528
|
-
The default file ships as a **token-cheap smoke set**: about five rows (one per strategy). Add more rows to `golden.jsonl` anytime you want deeper coverage — there is no required size.
|
|
529
|
-
|
|
530
|
-
Each row has `id`, `query`, `expected_strategy`, optional `answer_must_contain` / `expected_sql_keywords`, and `notes`.
|
|
531
|
-
|
|
532
|
-
### Router-only eval — `run_routing_eval.py`
|
|
533
|
-
|
|
534
|
-
Hits the `AdaptiveRouter` against every example without running retrieval / SQL / synthesis. Reports overall accuracy, per-strategy breakdown, latency, and a confusion matrix. Exits non-zero if accuracy drops below `--threshold` (default `0.85`) — drop into CI to catch router-prompt regressions cheaply.
|
|
535
|
-
|
|
536
|
-
### Full pipeline + DeepEval — `run_deepeval.py`
|
|
537
|
-
|
|
538
|
-
For every example whose `expected_strategy` is `vector_only` or `hybrid`, runs the full dispatcher and feeds the (query, answer, retrieved chunks) triple into DeepEval:
|
|
539
|
-
|
|
540
|
-
- `FaithfulnessMetric` — claims in the answer are grounded in retrieved context
|
|
541
|
-
- `AnswerRelevancyMetric` — the answer addresses the actual question
|
|
542
|
-
- `ContextualRelevancyMetric` — the retrieved chunks were relevant to the query
|
|
543
|
-
|
|
544
|
-
None of these metrics require a hand-written reference answer — they work with just the `input`, `actual_output`, and `retrieval_context`. Adding `expected_output` later unlocks `ContextualPrecisionMetric` and `ContextualRecallMetric`.
|
|
545
|
-
|
|
546
|
-
Outputs:
|
|
547
|
-
|
|
548
|
-
- `src/eval/reports/deepeval_<timestamp>.json` — raw scores per row
|
|
549
|
-
- `src/eval/reports/deepeval_<timestamp>.html` — self-contained summary report
|
|
550
|
-
|
|
551
|
-
---
|
|
552
|
-
|
|
553
|
-
## 15. Configuration
|
|
554
|
-
|
|
555
|
-
Every tunable lives in `src/config/settings.py` — a frozen `Settings` dataclass loaded once from `.env` via `python-dotenv`. Some highlights:
|
|
556
|
-
|
|
557
|
-
| Setting | Default | What |
|
|
558
|
-
|---|---|---|
|
|
559
|
-
| `OPENAI_API_KEY` | (required) | Embeddings, router, SQL gen, synthesis |
|
|
560
|
-
| `QWEN_API_KEY` | (required for OCR) | DashScope endpoint |
|
|
561
|
-
| `QDRANT_URL` | `http://localhost:6333` (or unset → embedded mode) | Vector DB |
|
|
562
|
-
| `QDRANT_COLLECTION` | `adaptive_rag` | Collection name |
|
|
563
|
-
| `DENSE_MODEL` | `text-embedding-3-small` | Must match `DENSE_SIZE` |
|
|
564
|
-
| `DENSE_SIZE` | `1536` | Vector dimensions |
|
|
565
|
-
| `SPARSE_MODEL` | `Qdrant/bm25` | FastEmbed BM25 |
|
|
566
|
-
| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `1500` / `200` | Recursive splitter |
|
|
567
|
-
| `RETRIEVAL_PREFETCH_K` | `25` | Candidates fetched before rerank |
|
|
568
|
-
| `RERANK_TOP_K` | `5` | Final chunks shown to the LLM |
|
|
569
|
-
| `RERANKER_MODEL` | `ms-marco-MiniLM-L-12-v2` | FlashRank model |
|
|
570
|
-
| `LLM_MODEL` | `gpt-4.1-mini` | Synthesis |
|
|
571
|
-
| `LLM_TEMPERATURE` | `0.2` | |
|
|
572
|
-
| `ROUTER_MODEL` | `gpt-4.1-nano` | Cheap classifier |
|
|
573
|
-
| `SQL_MODEL` | `gpt-4.1-mini` | NL→SQL translator |
|
|
574
|
-
| `SQL_DATABASE_URL` | (unset) | Leave unset to disable `sql_only` / `hybrid` strategies |
|
|
575
|
-
| `SQL_QUERY_TIMEOUT_SEC` | `5` | Per-query Postgres timeout |
|
|
576
|
-
| `SQL_ROW_LIMIT` | `200` | Implicit `LIMIT N` injection |
|
|
577
|
-
| `LANGFUSE_PUBLIC_KEY` | (unset) | Set both Langfuse keys to enable tracing — app is a no-op tracer when missing |
|
|
578
|
-
| `LANGFUSE_SECRET_KEY` | (unset) | — |
|
|
579
|
-
| `LANGFUSE_HOST` | `https://cloud.langfuse.com` | Override for the US region or self-hosted |
|
|
580
|
-
| `CACHE_DIR` | `./.cache` | OCR + embedding caches |
|
|
581
|
-
|
|
582
|
-
---
|
|
583
|
-
|
|
584
|
-
## 16. Implementation Status
|
|
585
|
-
|
|
586
|
-
| Phase | Status | Description |
|
|
587
|
-
|---|---|---|
|
|
588
|
-
| 0. Setup & infra | ✅ | Repo, deps, Docker compose, settings |
|
|
589
|
-
| 1. Docling baseline | ✅ | Document → markdown for native formats |
|
|
590
|
-
| 2. Parser router + Qwen | ✅ | Born-digital vs scanned heuristic, Qwen3-VL OCR with cache |
|
|
591
|
-
| 3. Chunking + indexing | ✅ | Header-aware chunks, hybrid Qdrant collection, dedup |
|
|
592
|
-
| 4. Retrieval + chat | ✅ | Hybrid search + RRF + FlashRank + grounded answers with `[n]` citations |
|
|
593
|
-
| 5. Adaptive router + SQL | ✅ | Five-strategy router, read-only SQL tool, `[DB]` citations |
|
|
594
|
-
| 6. Eval + tracing + polish | ✅ | Langfuse spans, cost dashboard, routing-accuracy gate, DeepEval runner with HTML reports |
|
|
595
|
-
| 7. Stretch | ⏸️ | C-RAG self-reflection, multi-hop, MCP server, web fallback |
|
|
596
|
-
|
|
597
|
-
See `PROJECT_PLAN.md` for the full phase-by-phase task list and acceptance criteria.
|
|
598
|
-
|
|
599
|
-
---
|
|
600
|
-
|
|
601
|
-
## 17. Decision Log
|
|
602
|
-
|
|
603
|
-
| Decision | Chosen | Rejected | Reason |
|
|
604
|
-
|---|---|---|---|
|
|
605
|
-
| Routing layer | Query-time LLM classifier | Ingest-time extension matching | A PDF can have prose AND tables; routing must see the question |
|
|
606
|
-
| Document parser | Docling | LangChain native loaders, LlamaParse, Marker | Free, local, table-aware, multi-format |
|
|
607
|
-
| OCR | Qwen3-VL-Plus (API) | GLM-OCR (self-hosted), EasyOCR, Tesseract | No GPU, better quality on complex layouts, cheap per-image |
|
|
608
|
-
| Vector DB | Qdrant | pgvector, Weaviate, Chroma | Best hybrid (dense + sparse) support, server-side RRF |
|
|
609
|
-
| Sparse encoder | BM25 via FastEmbed | SPLADE, no sparse | Free, fast, no GPU |
|
|
610
|
-
| Reranker | FlashRank `ms-marco-MiniLM-L-12-v2` | BGE-reranker-v2-m3 (Torch), Cohere Rerank (paid), ColBERT | Pure ONNX via `onnxruntime` (already pulled by `fastembed`); no Torch / Transformers; sidesteps Python-3.14 native-extension instability we hit with `py-rust-stemmers` |
|
|
611
|
-
| Router LLM | `gpt-4.1-nano` | `gpt-4.1-mini`, `claude-haiku` | Cheapest model that classifies reliably; classification doesn't need frontier reasoning |
|
|
612
|
-
| Synthesis LLM | `gpt-4.1-mini` | `gpt-4.1`, `gpt-4o-mini` | Strong enough to follow citation rules, cheap enough for hot path |
|
|
613
|
-
| Tool protocol | Native Python class with explicit dispatch | OpenAI function calling registry, MCP for everything | We're not an agent loop — the router *picks* a strategy, then we run it. Registry abstraction is dead weight. MCP only when external clients consume. |
|
|
614
|
-
| SQL backend | Postgres in Docker bound to host port 5433 | Default 5432, SQLite, Neon | 5433 sidesteps collisions with host-installed Postgres on 5432; users can swap to Neon by changing `SQL_DATABASE_URL` |
|
|
615
|
-
| Read-only enforcement | Dedicated DB role + statement allowlist + keyword regex + statement_timeout + LIMIT injection + READ ONLY transaction | Just one of those | Defense in depth — any one layer might be misconfigured |
|
|
616
|
-
| Task queue | `BackgroundTasks` | Celery + Redis | YAGNI; add when measured queue depth justifies it |
|
|
617
|
-
| File watching | Manual upload | watchdog filesystem watcher | UI-driven flow is enough; watcher is feature creep |
|
|
618
|
-
| DB sync to vectors | Live SQL tool, query at runtime | CDC (Debezium et al.) | Core principle: never embed structured data |
|
|
619
|
-
| Eval | DeepEval + custom routing-accuracy metric | Ragas (async compat issues on Python 3.14), vibes-based | Portfolio projects without metrics look unfinished; DeepEval has no `nest_asyncio` / `sniffio` issues |
|
|
620
|
-
| Tracing | Langfuse | LangSmith, Prometheus + Grafana + Jaeger | LLM-native, model-/framework-agnostic, MIT-licensed core, generous free tier (50k events/month), self-hosting available — sidesteps the LangSmith vendor lock-in concern |
|
|
621
|
-
| Eval reference answers | Skipped for v1 | Hand-written gold answers per row | DeepEval Faithfulness / Answer Relevancy / Contextual Relevancy don't need them; contextual precision and recall do. Add an `expected_output` field to the golden set when the synthesis prompt is stable enough that recall scores are trustworthy. |
|
|
622
|
-
| Cost table source | Langfuse server-side computation | Maintain price table in repo | Model prices change; Langfuse keeps theirs current. We just read totals back via REST. |
|
|
623
|
-
| Tracing default | Disabled (no-op stubs) | Always-on with warnings | Cleaner OSS UX — works without signup; opt-in by setting two env vars |
|
|
624
|
-
|
|
625
|
-
---
|
|
626
|
-
|
|
627
|
-
## 18. Future Work
|
|
628
|
-
|
|
629
|
-
### Phase 7 — Stretch
|
|
630
|
-
|
|
631
|
-
- **C-RAG self-reflection.** Grade retrieved context for relevance; on low scores, re-route or fall back to web search.
|
|
632
|
-
- **Multi-hop retrieval.** When `clarify` would have been picked, escalate to step-by-step iterative search instead of asking the user.
|
|
633
|
-
- **MCP server.** Expose `search_docs` and `query_sql` so Cursor / Claude Desktop can use the same tools.
|
|
634
|
-
- **Web search fallback.** Tavily / Exa when local context is insufficient.
|
|
635
|
-
- **Streaming responses.** Wire LLM streaming through Gradio for snappier UX.
|
|
636
|
-
- **Multi-collection Qdrant.** Split per-domain (`policies`, `finance`, `technical`) once ingest volume justifies the operational cost.
|
|
637
|
-
- **Image-in-markdown OCR.** Extract images from Docling output, OCR them with Qwen, inline the text into the parent markdown.
|
|
638
|
-
|
|
639
|
-
---
|
|
640
|
-
|
|
641
|
-
**End of architecture document.**
|
|
1
|
+
# AdaptiveRAG — Architecture
|
|
2
|
+
|
|
3
|
+
A hybrid Adaptive RAG system. Each query is classified at runtime into one of five execution strategies (`no_retrieval`, `vector_only`, `sql_only`, `hybrid`, `clarify`) and dispatched to the right backend(s). Documents flow through a markdown-first ingestion pipeline; the retrieval layer is hybrid (dense + BM25 + cross-encoder rerank); the SQL layer is read-only with defense in depth.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Goals & Non-Goals](#1-goals--non-goals)
|
|
10
|
+
2. [Core Principles](#2-core-principles)
|
|
11
|
+
3. [System Overview](#3-system-overview)
|
|
12
|
+
4. [Tech Stack](#4-tech-stack)
|
|
13
|
+
5. [Project Structure](#5-project-structure)
|
|
14
|
+
6. [Ingestion Pipeline](#6-ingestion-pipeline)
|
|
15
|
+
7. [Chunking Strategy](#7-chunking-strategy)
|
|
16
|
+
8. [Retrieval Layer](#8-retrieval-layer)
|
|
17
|
+
9. [Adaptive Query Router](#9-adaptive-query-router)
|
|
18
|
+
10. [Tool Layer (Read-Only SQL)](#10-tool-layer-read-only-sql)
|
|
19
|
+
11. [Synthesis & Citations](#11-synthesis--citations)
|
|
20
|
+
12. [Caching & Cost Control](#12-caching--cost-control)
|
|
21
|
+
13. [Observability & Cost Tracking](#13-observability--cost-tracking)
|
|
22
|
+
14. [Evaluation Framework](#14-evaluation-framework)
|
|
23
|
+
15. [Configuration](#15-configuration)
|
|
24
|
+
16. [Implementation Status](#16-implementation-status)
|
|
25
|
+
17. [Decision Log](#17-decision-log)
|
|
26
|
+
18. [Future Work](#18-future-work)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Goals & Non-Goals
|
|
31
|
+
|
|
32
|
+
### Goals
|
|
33
|
+
|
|
34
|
+
- **Adaptive retrieval** — pick the right strategy per-query, not per-file-extension.
|
|
35
|
+
- **Markdown-first ingestion** — convert every input format to markdown so chunks are header-aware.
|
|
36
|
+
- **Hybrid retrieval at the index layer** — dense embeddings + BM25 + reciprocal-rank fusion + cross-encoder reranker.
|
|
37
|
+
- **Grounded answers with citations** — inline `[n]` markers for chunks, `[DB]` for SQL data, parsed back into structured citations for the UI.
|
|
38
|
+
- **Cost-bounded** — content-hash caches for OCR and embeddings; cheap models for routing, frontier models only for synthesis.
|
|
39
|
+
- **Portfolio-presentable** — clean code, working demo, real metrics in Phase 6.
|
|
40
|
+
|
|
41
|
+
### Non-Goals
|
|
42
|
+
|
|
43
|
+
- Multi-tenant SaaS with RBAC.
|
|
44
|
+
- Real-time CDC / database mirroring into vectors. (See decision log: never embed structured data.)
|
|
45
|
+
- Distributed task queue. `FastAPI BackgroundTasks` is enough until proven otherwise.
|
|
46
|
+
- A constellation of MCP servers. One optional MCP surface that wraps the same tools is enough.
|
|
47
|
+
- Production monitoring stack (Prometheus / Grafana / Jaeger). Langfuse for traces is enough.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 2. Core Principles
|
|
52
|
+
|
|
53
|
+
1. **If the answer is a sentence, embed it. If the answer is a number, query it.** Free text goes to vectors; structured data stays in SQL.
|
|
54
|
+
2. **Decide adaptively at query time, not at ingest time.** A PDF can contain prose *and* tables; a SQL row can have a free-text comment. Routing has to see the question, not just the file.
|
|
55
|
+
3. **Markdown is the universal intermediate format.** Every parser output normalizes to markdown before chunking.
|
|
56
|
+
4. **Quality of parsing beats quantity of features.** One excellent ingestion path is better than five mediocre ones.
|
|
57
|
+
5. **Measure before optimizing.** No reranker, no advanced chunking, no MCP — until eval scores justify each addition.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 3. System Overview
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
65
|
+
│ INGESTION (offline) │
|
|
66
|
+
│ │
|
|
67
|
+
│ File ─► FileTypeDetector ─► ParserRouter ─► Markdown ─► Chunker │
|
|
68
|
+
│ │ │
|
|
69
|
+
│ ├─ Docling (default) │
|
|
70
|
+
│ ├─ Qwen3-VL (image / scan) │
|
|
71
|
+
│ └─ passthrough (.md, .txt) │
|
|
72
|
+
│ │
|
|
73
|
+
│ Markdown ─► MarkdownHeaderSplitter ─► Embedder ─► Qdrant │
|
|
74
|
+
│ │ │
|
|
75
|
+
│ ├─ dense (text-emb-3) │
|
|
76
|
+
│ └─ sparse (BM25 / IDF) │
|
|
77
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
78
|
+
|
|
79
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
80
|
+
│ QUERY-TIME (online) │
|
|
81
|
+
│ │
|
|
82
|
+
│ user query │
|
|
83
|
+
│ │ │
|
|
84
|
+
│ ▼ │
|
|
85
|
+
│ ┌──────────────────────┐ │
|
|
86
|
+
│ │ AdaptiveRouter │ cheap LLM classifier │
|
|
87
|
+
│ │ → strategy + intent │ (gpt-4.1-nano default) │
|
|
88
|
+
│ └────────┬─────────────┘ │
|
|
89
|
+
│ │ │
|
|
90
|
+
│ ┌───────┼───────────┬─────────────┬────────────────┐ │
|
|
91
|
+
│ ▼ ▼ ▼ ▼ ▼ │
|
|
92
|
+
│ no_retr. vector sql_only hybrid clarify │
|
|
93
|
+
│ (LLM) ↓ Qdrant ↓ NL→SQL ↓ both (ask user) │
|
|
94
|
+
│ ↓ + rerank ↓ + execute ↓ + merge │
|
|
95
|
+
│ └──────────┴────────────┘ │
|
|
96
|
+
│ │ │
|
|
97
|
+
│ ▼ │
|
|
98
|
+
│ GroundedAnswerer (LLM) │
|
|
99
|
+
│ │ │
|
|
100
|
+
│ ▼ │
|
|
101
|
+
│ answer with [n] / [DB] citations │
|
|
102
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 4. Tech Stack
|
|
108
|
+
|
|
109
|
+
| Component | Library | Role |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Document parsing | `docling>=2.92` | Born-digital PDFs, DOCX, PPTX, XLSX, HTML, CSV |
|
|
112
|
+
| OCR | `openai>=2.33` against DashScope OpenAI-compat endpoint | Qwen3-VL-Plus for images and scanned PDFs |
|
|
113
|
+
| LLM framework | `langchain>=1.2`, `langchain-core>=1.3` | Message types, prompts, structured output |
|
|
114
|
+
| LLM client | `langchain-openai>=1.2` | Chat synthesis, router, NL→SQL |
|
|
115
|
+
| Embeddings — dense | `langchain-openai` + `text-embedding-3-small` | 1536-dim, with SHA256 disk cache |
|
|
116
|
+
| Embeddings — sparse | `fastembed>=0.4` (`Qdrant/bm25`) | Local BM25 with IDF, no GPU |
|
|
117
|
+
| Vector DB | `qdrant-client>=1.12` + `langchain-qdrant>=1.1` | Hybrid collection (dense + sparse named vectors) |
|
|
118
|
+
| Reranker | `flashrank>=0.2.9` | Pure-ONNX cross-encoder (`ms-marco-MiniLM-L-12-v2`, ~34 MB) |
|
|
119
|
+
| Splitters | `langchain-text-splitters>=1.1` | Header-aware + recursive fallback |
|
|
120
|
+
| PDF inspection | `pypdfium2>=4.30` | Born-digital heuristic + page rendering |
|
|
121
|
+
| Schema validation | `pydantic>=2.9` | Structured router output, structured NL→SQL |
|
|
122
|
+
| SQL | `sqlalchemy>=2.0.36` + `psycopg[binary]>=3.2.3` | Read-only Postgres tool |
|
|
123
|
+
| Retry | `tenacity>=9.x` | Qwen API resilience |
|
|
124
|
+
| UI | `gradio>=6.13` | Tabbed Chat / Ingest / Convert / Admin demo |
|
|
125
|
+
| Tracing | `langfuse>=4.0` | Per-turn spans, token counts, USD cost |
|
|
126
|
+
| Eval | `deepeval>=3.0` | Faithfulness / answer relevancy / contextual relevancy |
|
|
127
|
+
| Env | `python-dotenv>=1.2` | `.env` config loader |
|
|
128
|
+
|
|
129
|
+
### Explicitly avoided
|
|
130
|
+
|
|
131
|
+
- `celery`, `redis` — not needed; `FastAPI BackgroundTasks` is sufficient.
|
|
132
|
+
- `transformers`, `torch`, `accelerate` — Qwen is API-based and FlashRank uses ONNX. Keeps the install lean and dodges Python-3.14 native-extension instability.
|
|
133
|
+
- `watchdog` — explicit upload via UI/API is fine.
|
|
134
|
+
- CDC tooling (Debezium et al.) — query DB live via tool, never sync.
|
|
135
|
+
- Prometheus / Grafana — Langfuse covers it for v1.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 5. Project Structure
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
adaptive-rag/
|
|
143
|
+
├── app.py Gradio entry point
|
|
144
|
+
├── pyproject.toml
|
|
145
|
+
├── docker-compose.yml Qdrant + Postgres
|
|
146
|
+
├── .env.example
|
|
147
|
+
├── ARCHITECTURE.md (this file)
|
|
148
|
+
├── README.md
|
|
149
|
+
├── PROJECT_PLAN.md Phase-by-phase status
|
|
150
|
+
├── scripts/
|
|
151
|
+
│ ├── init_qdrant.py Create / recreate the Qdrant collection
|
|
152
|
+
│ └── seed_demo_data.py Seed Postgres with demo e-commerce data
|
|
153
|
+
│
|
|
154
|
+
├── src/
|
|
155
|
+
│ ├── config/
|
|
156
|
+
│ │ └── settings.py Single source of truth for all tunables
|
|
157
|
+
│ │
|
|
158
|
+
│ ├── core/ Document → markdown
|
|
159
|
+
│ │ ├── file_detector.py Format detection + validation
|
|
160
|
+
│ │ ├── docling_parser.py Docling-backed parser
|
|
161
|
+
│ │ ├── qwen_parser.py Qwen3-VL OCR with retry + cache
|
|
162
|
+
│ │ ├── parser_router.py Picks Docling vs Qwen per file
|
|
163
|
+
│ │ └── converter.py Public conversion API
|
|
164
|
+
│ │
|
|
165
|
+
│ ├── chunking/ Markdown → header-aware chunks
|
|
166
|
+
│ │ ├── markdown_chunker.py Header splitter + recursive fallback
|
|
167
|
+
│ │ └── metadata.py doc_id (SHA256), chunk_uuid (UUID5)
|
|
168
|
+
│ │
|
|
169
|
+
│ ├── indexing/ Chunks → Qdrant
|
|
170
|
+
│ │ ├── embeddings.py Dense (cached) + BM25 sparse
|
|
171
|
+
│ │ ├── qdrant_store.py Hybrid collection + dedup + library
|
|
172
|
+
│ │ └── pipeline.py Convert → chunk → upsert
|
|
173
|
+
│ │
|
|
174
|
+
│ ├── retrieval/ Query → ranked chunks
|
|
175
|
+
│ │ ├── hybrid_search.py HybridRetriever + RetrievalPipeline
|
|
176
|
+
│ │ └── reranker.py FlashRank ONNX cross-encoder
|
|
177
|
+
│ │
|
|
178
|
+
│ ├── routing/ Adaptive router + dispatcher
|
|
179
|
+
│ │ ├── strategies.py Strategy StrEnum + capability sets
|
|
180
|
+
│ │ ├── prompts.py Router system prompt + few-shots
|
|
181
|
+
│ │ ├── adaptive_router.py LLM classifier (structured output)
|
|
182
|
+
│ │ └── dispatcher.py Compose router + retrieval + SQL + synthesis
|
|
183
|
+
│ │
|
|
184
|
+
│ ├── tools/ External tools the dispatcher can call
|
|
185
|
+
│ │ └── sql_tool.py Read-only NL→SQL with safety guards
|
|
186
|
+
│ │
|
|
187
|
+
│ ├── synthesis/ Chunks (+ SQL) → grounded answer
|
|
188
|
+
│ │ └── response.py GroundedAnswerer + Citation parsing
|
|
189
|
+
│ │
|
|
190
|
+
│ ├── observability/ Tracing + cost tracking
|
|
191
|
+
│ │ ├── langfuse_client.py Singleton Langfuse + no-op span() ctx mgr
|
|
192
|
+
│ │ └── cost_tracker.py Pulls daily metrics from Langfuse REST API
|
|
193
|
+
│ │
|
|
194
|
+
│ ├── eval/ Golden set + accuracy / DeepEval runners
|
|
195
|
+
│ │ ├── golden.jsonl Tiny smoke golden set (~one row per strategy)
|
|
196
|
+
│ │ ├── run_routing_eval.py Router-only accuracy gate (CI-friendly)
|
|
197
|
+
│ │ └── run_deepeval.py DeepEval runner + JSON + HTML report
|
|
198
|
+
│ │
|
|
199
|
+
│ ├── cache/ Content-hash caches
|
|
200
|
+
│ │ ├── ocr_cache.py SHA256-keyed disk cache for OCR markdown
|
|
201
|
+
│ │ └── embedding_cache.py SHA256-keyed disk cache for vectors
|
|
202
|
+
│ │
|
|
203
|
+
│ ├── utils/
|
|
204
|
+
│ │ └── pdf_inspector.py Born-digital heuristic + page rendering
|
|
205
|
+
│ │
|
|
206
|
+
│ └── ui/ Gradio interface
|
|
207
|
+
│ ├── main_ui.py Tab composition
|
|
208
|
+
│ ├── chat_ui.py Chat tab (calls AdaptiveDispatcher)
|
|
209
|
+
│ ├── ingest_ui.py Ingest tab (multi-file upload + library)
|
|
210
|
+
│ ├── markdown_converter_ui.py Convert tab (single-document preview)
|
|
211
|
+
│ └── admin_ui.py Admin tab — Langfuse cost dashboard
|
|
212
|
+
│
|
|
213
|
+
└── docs/
|
|
214
|
+
├── check_postgres.md DB inspection cheatsheet
|
|
215
|
+
└── check_qdrant.md Vector DB inspection cheatsheet
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 6. Ingestion Pipeline
|
|
221
|
+
|
|
222
|
+
### Parser routing
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
file_type ──┐
|
|
226
|
+
├── .md / .txt ──────────────────► passthrough
|
|
227
|
+
│
|
|
228
|
+
├── .pdf ──┬─ "born-digital" ───► Docling (fast, text layer)
|
|
229
|
+
│ └─ "scanned" ────────► Qwen3-VL (vision)
|
|
230
|
+
│
|
|
231
|
+
├── .docx / .pptx / .xlsx / .html / .csv ──► Docling
|
|
232
|
+
│
|
|
233
|
+
└── .png / .jpg / .webp ──► Qwen3-VL (better than Tesseract on layout)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The "born-digital vs scanned" decision for PDFs is a cheap heuristic: render the text layer of the first three pages and treat the file as scanned if the total extracted character count is below a small threshold. Docling has its own internal OCR fallback (EasyOCR / Tesseract); the heuristic lets us skip that path and use Qwen3-VL when accuracy matters.
|
|
237
|
+
|
|
238
|
+
The user can override this routing per-file with a "Force Qwen3-VL OCR for PDFs" toggle in the Convert tab.
|
|
239
|
+
|
|
240
|
+
### Qwen3-VL OCR
|
|
241
|
+
|
|
242
|
+
Calls the DashScope OpenAI-compatible endpoint. The OCR prompt is intentionally deterministic:
|
|
243
|
+
|
|
244
|
+
> Extract all text from this image into clean GitHub-flavored Markdown. Preserve table structure with pipe syntax. Preserve heading hierarchy. Do not summarize, do not add commentary. If text is illegible, write `[illegible]`.
|
|
245
|
+
|
|
246
|
+
`tenacity` handles transient API failures with exponential backoff. The result is content-hash cached so re-uploading the same file (or re-rendering the same page from a multi-page PDF) never spends a second API call.
|
|
247
|
+
|
|
248
|
+
### Content-hash everything
|
|
249
|
+
|
|
250
|
+
Three things use SHA256 as a primary key:
|
|
251
|
+
|
|
252
|
+
| Cache | Key | Stored |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| OCR | `sha256(image_bytes)` | Markdown text on disk |
|
|
255
|
+
| Embeddings | `sha256(model_name + text)` | Raw `float32` vector bytes on disk |
|
|
256
|
+
| Documents | `sha256(file_bytes)[:16]` | `doc_id` for dedup + library listing |
|
|
257
|
+
|
|
258
|
+
Re-ingesting the same file replaces its prior chunks in Qdrant atomically (delete-by-`doc_id` then upsert).
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 7. Chunking Strategy
|
|
263
|
+
|
|
264
|
+
Two-pass:
|
|
265
|
+
|
|
266
|
+
1. **`MarkdownHeaderTextSplitter`** splits by `#`, `##`, `###`. Headers are kept in the chunk content and the header path is also written to chunk metadata.
|
|
267
|
+
2. **`RecursiveCharacterTextSplitter`** splits any header-section that exceeds `CHUNK_SIZE` (default 1500 chars). Each sub-chunk inherits the parent's header path.
|
|
268
|
+
|
|
269
|
+
### Per-chunk metadata
|
|
270
|
+
|
|
271
|
+
```json
|
|
272
|
+
{
|
|
273
|
+
"doc_id": "dc7c3912cd0b003d",
|
|
274
|
+
"source": "data/policy.pdf",
|
|
275
|
+
"filename": "policy.pdf",
|
|
276
|
+
"header_path": "Refund Policy > Eligibility",
|
|
277
|
+
"chunk_index": 7,
|
|
278
|
+
"total_chunks": 23,
|
|
279
|
+
"ingested_at": "2026-05-09T04:52:24+00:00",
|
|
280
|
+
"parser": "docling" // or "qwen3-vl" or "passthrough"
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`chunk_uuid` is generated deterministically via `UUID5(doc_id, chunk_index)` so re-upserts are idempotent.
|
|
285
|
+
|
|
286
|
+
No `access_level` / `department` / `tags` in v1 — those go in only when a feature actually consumes them.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 8. Retrieval Layer
|
|
291
|
+
|
|
292
|
+
### Qdrant collection: hybrid by default
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
client.create_collection(
|
|
296
|
+
collection_name="adaptive_rag",
|
|
297
|
+
vectors_config={
|
|
298
|
+
"dense": models.VectorParams(size=1536, distance=models.Distance.COSINE),
|
|
299
|
+
},
|
|
300
|
+
sparse_vectors_config={
|
|
301
|
+
"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF),
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Both vectors are populated for every chunk at ingest time; queries hit both.
|
|
307
|
+
|
|
308
|
+
### Query flow
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
query
|
|
312
|
+
│
|
|
313
|
+
├─► dense embedding (text-embedding-3-small, cached)
|
|
314
|
+
├─► sparse encoding (FastEmbed BM25, IDF on server)
|
|
315
|
+
│
|
|
316
|
+
▼
|
|
317
|
+
Qdrant `query_points` with prefetch:
|
|
318
|
+
- prefetch dense (top RETRIEVAL_PREFETCH_K = 25)
|
|
319
|
+
- prefetch sparse (top RETRIEVAL_PREFETCH_K = 25)
|
|
320
|
+
- fusion: server-side RRF
|
|
321
|
+
│
|
|
322
|
+
▼
|
|
323
|
+
FlashRank cross-encoder rerank
|
|
324
|
+
→ top RERANK_TOP_K = 5
|
|
325
|
+
│
|
|
326
|
+
▼
|
|
327
|
+
Pass to GroundedAnswerer with header_path context
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The fusion is server-side RRF (Qdrant native), not client-side merging — single round-trip per query.
|
|
331
|
+
|
|
332
|
+
The reranker uses `ms-marco-MiniLM-L-12-v2` by default (~34 MB ONNX). Lazy first-use download to `CACHE_DIR/flashrank/`. If model loading or scoring fails, the pipeline gracefully falls back to the hybrid-fusion order. Alternatives configurable via `RERANKER_MODEL`:
|
|
333
|
+
|
|
334
|
+
| Model | Size | Notes |
|
|
335
|
+
|---|---|---|
|
|
336
|
+
| `ms-marco-TinyBERT-L-2-v2` | ~4 MB | Fastest |
|
|
337
|
+
| `ms-marco-MiniLM-L-12-v2` | ~34 MB | **Default** — balanced |
|
|
338
|
+
| `ms-marco-MultiBERT-L-12` | ~150 MB | Multilingual |
|
|
339
|
+
| `rank-T5-flan` | ~110 MB | Best quality |
|
|
340
|
+
|
|
341
|
+
Hybrid search typically yields **+5–15% retrieval recall** over pure dense; reranking adds another **+10–20% context precision** on top. Numbers will be re-validated against the golden set in Phase 6.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## 9. Adaptive Query Router
|
|
346
|
+
|
|
347
|
+
The router is what makes this *Adaptive RAG* (per Jeong et al., 2024 — query-complexity-aware strategy selection) rather than static dispatch.
|
|
348
|
+
|
|
349
|
+
### Strategies
|
|
350
|
+
|
|
351
|
+
| Strategy | When | Touches |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| `no_retrieval` | Greeting, chitchat, generic knowledge, math | LLM only |
|
|
354
|
+
| `vector_only` | Conceptual / "what does our doc say about X" | Qdrant + reranker + LLM with `[n]` citations |
|
|
355
|
+
| `sql_only` | Quantitative / "how many" / "top N" / aggregates | NL→SQL → execute → LLM with `[DB]` citation |
|
|
356
|
+
| `hybrid` | Question needs both narrative AND a number | Vector AND SQL → blended answer |
|
|
357
|
+
| `clarify` | Genuinely ambiguous | One focused follow-up question, no retrieval cost |
|
|
358
|
+
|
|
359
|
+
### How a decision is made
|
|
360
|
+
|
|
361
|
+
A single LLM call with structured output. The classifier model is intentionally cheap (`gpt-4.1-nano` by default) — classification doesn't need frontier reasoning, and we want this on the hot path of every chat turn.
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
class RouterDecision(BaseModel):
|
|
365
|
+
strategy: Strategy # one of the five above
|
|
366
|
+
reasoning: str # one-sentence justification
|
|
367
|
+
vector_query: str | None # optional rephrased search query
|
|
368
|
+
sql_intent: str | None # NL description for the SQL tool
|
|
369
|
+
clarification_question: str | None # only set when strategy == clarify
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The router prompt includes:
|
|
373
|
+
1. Strategy descriptions + few-shot examples (anchors classification).
|
|
374
|
+
2. The actual chat history (so follow-ups like "what about last quarter?" can resolve to a real referent instead of always falling to `clarify`).
|
|
375
|
+
3. A one-line summary of available SQL tables — fetched via `inspect()` once at startup. ~80 tokens. Lets the router decide "this is a database question" vs "this is a docs question."
|
|
376
|
+
|
|
377
|
+
If `SQL_DATABASE_URL` is unset, the prompt is told "no SQL backend" and a sanitizer downgrades any leaked `sql_only`/`hybrid` decision to `vector_only`. The router can never pick a strategy it can't fulfill.
|
|
378
|
+
|
|
379
|
+
### Dispatch
|
|
380
|
+
|
|
381
|
+
`AdaptiveDispatcher.answer(query, history)` is the single entry point the chat UI calls. It:
|
|
382
|
+
|
|
383
|
+
1. Classifies the query.
|
|
384
|
+
2. For `clarify` / `no_retrieval`: short-circuits without touching retrieval or SQL.
|
|
385
|
+
3. For `vector_only` / `hybrid`: runs the retrieval pipeline.
|
|
386
|
+
4. For `sql_only` / `hybrid`: runs the SQL tool. If the tool fails (DB down, query rejected), records a note and continues with whatever else it has.
|
|
387
|
+
5. Calls the appropriate `GroundedAnswerer` method.
|
|
388
|
+
6. Returns an `AdaptiveAnswer` with strategy, decision, citations, executed SQL, and per-stage timings.
|
|
389
|
+
|
|
390
|
+
Backends are initialized lazily on first use so the app starts fast even when SQL or OpenAI aren't configured.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## 10. Tool Layer (Read-Only SQL)
|
|
395
|
+
|
|
396
|
+
### Defense in depth
|
|
397
|
+
|
|
398
|
+
The SQL tool is *not* an agent. It runs once per turn, with five layers of safety:
|
|
399
|
+
|
|
400
|
+
1. **Dedicated read-only Postgres role.** `seed_demo_data.py` creates `adaptive_rag_ro` with `SELECT`-only grants. The app connects as that role.
|
|
401
|
+
2. **Statement-level allowlist.** Only `SELECT` and `WITH` (CTE-resolved-to-SELECT) statements are accepted.
|
|
402
|
+
3. **Forbidden-keyword regex.** Catches `INSERT|UPDATE|DELETE|MERGE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|COPY|VACUUM|...` even if the role grants would already block them.
|
|
403
|
+
4. **Per-session statement timeout.** Default 5 seconds (`SQL_QUERY_TIMEOUT_SEC`); runaway plans die fast.
|
|
404
|
+
5. **Implicit row cap.** A `LIMIT N` (default 200, `SQL_ROW_LIMIT`) is appended if the SQL doesn't already cap rows.
|
|
405
|
+
|
|
406
|
+
Plus `SET TRANSACTION READ ONLY` on every connection — belt + suspenders + bungee.
|
|
407
|
+
|
|
408
|
+
### NL → SQL
|
|
409
|
+
|
|
410
|
+
A second LLM call (`gpt-4.1-mini` by default, `SQL_MODEL`) translates the router's `sql_intent` into a single `SELECT`. Schema is included in the prompt — generated once via SQLAlchemy `inspect()` at startup, formatted as DDL-style table descriptions with column types, primary keys, and foreign keys.
|
|
411
|
+
|
|
412
|
+
Structured output forces a `_SqlOutput { sql: str }` schema, so the LLM can't dump prose around the query.
|
|
413
|
+
|
|
414
|
+
### Demo dataset
|
|
415
|
+
|
|
416
|
+
`scripts/seed_demo_data.py` builds a deterministic e-commerce schema:
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
customers (100 rows) — id, name, email, country, signup_date
|
|
420
|
+
products (50 rows) — id, name, category, price, stock
|
|
421
|
+
orders (500 rows) — id, customer_id, status, total, created_at
|
|
422
|
+
order_items (~1300 rows) — id, order_id, product_id, quantity, unit_price
|
|
423
|
+
refunds (~35 rows) — id, order_id, amount, reason, created_at
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Idempotent (skips if already populated) with `--recreate` to wipe and reseed. The fixed RNG seed gives the same data every run, so demos and golden-set evals are reproducible.
|
|
427
|
+
|
|
428
|
+
### Optional MCP surface (Phase 7)
|
|
429
|
+
|
|
430
|
+
The same `SqlTool` and retrieval pipeline can be exposed as an MCP server so Cursor / Claude Desktop can use them without the Gradio UI. This is the *correct* use of MCP — making the same capabilities reusable across LLM clients — not as a microservice framework.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## 11. Synthesis & Citations
|
|
435
|
+
|
|
436
|
+
`GroundedAnswerer` has three modes that the dispatcher selects:
|
|
437
|
+
|
|
438
|
+
| Method | Used for | Context shape |
|
|
439
|
+
|---|---|---|
|
|
440
|
+
| `answer_direct` | `no_retrieval` | Just the user query + chat history |
|
|
441
|
+
| `answer` | `vector_only` | Numbered passage block `[1]..[N]` |
|
|
442
|
+
| `answer_with_sql` | `sql_only` / `hybrid` | Passages **plus** a "Database query results" section with the executed SQL and a markdown-rendered preview of rows |
|
|
443
|
+
|
|
444
|
+
### Citation contract
|
|
445
|
+
|
|
446
|
+
The system prompt requires inline brackets for every claim:
|
|
447
|
+
|
|
448
|
+
- `[1]`, `[2, 3]` — refer to chunk numbers in the passage block.
|
|
449
|
+
- `[DB]` — refers to the SQL results block (only valid when SQL data is present).
|
|
450
|
+
|
|
451
|
+
The chat layer parses the LLM's output back into structured citations:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
@dataclass
|
|
455
|
+
class Citation:
|
|
456
|
+
index: int # 1-based chunk number
|
|
457
|
+
label: str # "policy.pdf > Refunds"
|
|
458
|
+
snippet: str # short preview
|
|
459
|
+
source: str | None
|
|
460
|
+
doc_id: str | None
|
|
461
|
+
rank: int
|
|
462
|
+
rerank_score: float | None
|
|
463
|
+
hybrid_score: float
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Only chunks the LLM actually cited end up in the right-hand Sources panel — clean UI by default, with a debug mode that surfaces all retrieved chunks if the LLM cites none. The `cited_db` boolean flag drives a separate "SQL executed" block in the panel.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## 12. Caching & Cost Control
|
|
471
|
+
|
|
472
|
+
Three caches on disk, all SHA256-keyed:
|
|
473
|
+
|
|
474
|
+
| Cache | Key | Rationale |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| OCR | `sha256(image_bytes)` | Qwen3-VL is paid per-call. Re-ingest of the same image must never re-OCR. |
|
|
477
|
+
| Dense embeddings | `sha256(model_name + text)` | OpenAI is paid per-token. Re-chunking with the same content must not re-embed. |
|
|
478
|
+
| Reranker model | FlashRank's own | First-use download (~34 MB), reused thereafter. |
|
|
479
|
+
|
|
480
|
+
Default location is `./.cache/`, override with `CACHE_DIR`.
|
|
481
|
+
|
|
482
|
+
Cost-per-query in the current configuration is dominated by the synthesis LLM (`gpt-4.1-mini`). Routing (`gpt-4.1-nano`) is roughly 1/10th the cost; SQL translation is one extra mini-call only when needed. The live numbers are tracked by Langfuse — see the next section.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## 13. Observability & Cost Tracking
|
|
487
|
+
|
|
488
|
+
Tracing is fully optional. When `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` are unset every helper in `src/observability/` is a no-op stub, so the rest of the application code is not branched on "is tracing on?".
|
|
489
|
+
|
|
490
|
+
### Span tree per chat turn
|
|
491
|
+
|
|
492
|
+
`AdaptiveDispatcher.answer()` opens one parent span and the helpers open children:
|
|
493
|
+
|
|
494
|
+
```
|
|
495
|
+
chat.turn (input=query, history_len)
|
|
496
|
+
├── router.classify (input=query; output={strategy, reasoning, vector_query, sql_intent})
|
|
497
|
+
├── retrieval.hybrid_search (input=query; metadata={fused_count, rerank_ms, prefetch_k, top_k, model})
|
|
498
|
+
├── tool.sql_execute (input=intent; output={sql, row_count, columns, rows_preview})
|
|
499
|
+
└── synthesis.{direct|grounded} (input=ctx_summary; output={answer, cited_indices, cited_db})
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The LangChain `CallbackHandler` is also passed into every `ChatOpenAI.invoke(...)` (router, NL→SQL, both synthesis modes), so each LLM call appears as a `GENERATION` observation with token counts + the model price → USD cost computed server-side by Langfuse. No price table is duplicated client-side.
|
|
503
|
+
|
|
504
|
+
`flush_traces()` is called at the end of each turn so spans appear in the dashboard within a second; we don't rely on the background flush thread to survive a Gradio request boundary.
|
|
505
|
+
|
|
506
|
+
### Cost tracker
|
|
507
|
+
|
|
508
|
+
`src/observability/cost_tracker.py` is a tiny `httpx`-based reader for Langfuse's `/api/public/metrics/daily` endpoint. It returns a `CostSummary` dataclass with:
|
|
509
|
+
|
|
510
|
+
- Total traces / observations / tokens / USD cost in the window
|
|
511
|
+
- Per-model breakdown (calls, in/out tokens, cost)
|
|
512
|
+
- Per-day breakdown
|
|
513
|
+
|
|
514
|
+
The Admin tab renders the same summary as a Gradio markdown view with a 24h / 7d / 30d window selector. CLI:
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
uv run python -m src.observability.cost_tracker --days 7
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## 14. Evaluation Framework
|
|
523
|
+
|
|
524
|
+
Two complementary scripts. Both consume the same golden set and write reports under `src/eval/reports/`.
|
|
525
|
+
|
|
526
|
+
### Golden set — `src/eval/golden.jsonl`
|
|
527
|
+
|
|
528
|
+
The default file ships as a **token-cheap smoke set**: about five rows (one per strategy). Add more rows to `golden.jsonl` anytime you want deeper coverage — there is no required size.
|
|
529
|
+
|
|
530
|
+
Each row has `id`, `query`, `expected_strategy`, optional `answer_must_contain` / `expected_sql_keywords`, and `notes`.
|
|
531
|
+
|
|
532
|
+
### Router-only eval — `run_routing_eval.py`
|
|
533
|
+
|
|
534
|
+
Hits the `AdaptiveRouter` against every example without running retrieval / SQL / synthesis. Reports overall accuracy, per-strategy breakdown, latency, and a confusion matrix. Exits non-zero if accuracy drops below `--threshold` (default `0.85`) — drop into CI to catch router-prompt regressions cheaply.
|
|
535
|
+
|
|
536
|
+
### Full pipeline + DeepEval — `run_deepeval.py`
|
|
537
|
+
|
|
538
|
+
For every example whose `expected_strategy` is `vector_only` or `hybrid`, runs the full dispatcher and feeds the (query, answer, retrieved chunks) triple into DeepEval:
|
|
539
|
+
|
|
540
|
+
- `FaithfulnessMetric` — claims in the answer are grounded in retrieved context
|
|
541
|
+
- `AnswerRelevancyMetric` — the answer addresses the actual question
|
|
542
|
+
- `ContextualRelevancyMetric` — the retrieved chunks were relevant to the query
|
|
543
|
+
|
|
544
|
+
None of these metrics require a hand-written reference answer — they work with just the `input`, `actual_output`, and `retrieval_context`. Adding `expected_output` later unlocks `ContextualPrecisionMetric` and `ContextualRecallMetric`.
|
|
545
|
+
|
|
546
|
+
Outputs:
|
|
547
|
+
|
|
548
|
+
- `src/eval/reports/deepeval_<timestamp>.json` — raw scores per row
|
|
549
|
+
- `src/eval/reports/deepeval_<timestamp>.html` — self-contained summary report
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## 15. Configuration
|
|
554
|
+
|
|
555
|
+
Every tunable lives in `src/config/settings.py` — a frozen `Settings` dataclass loaded once from `.env` via `python-dotenv`. Some highlights:
|
|
556
|
+
|
|
557
|
+
| Setting | Default | What |
|
|
558
|
+
|---|---|---|
|
|
559
|
+
| `OPENAI_API_KEY` | (required) | Embeddings, router, SQL gen, synthesis |
|
|
560
|
+
| `QWEN_API_KEY` | (required for OCR) | DashScope endpoint |
|
|
561
|
+
| `QDRANT_URL` | `http://localhost:6333` (or unset → embedded mode) | Vector DB |
|
|
562
|
+
| `QDRANT_COLLECTION` | `adaptive_rag` | Collection name |
|
|
563
|
+
| `DENSE_MODEL` | `text-embedding-3-small` | Must match `DENSE_SIZE` |
|
|
564
|
+
| `DENSE_SIZE` | `1536` | Vector dimensions |
|
|
565
|
+
| `SPARSE_MODEL` | `Qdrant/bm25` | FastEmbed BM25 |
|
|
566
|
+
| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `1500` / `200` | Recursive splitter |
|
|
567
|
+
| `RETRIEVAL_PREFETCH_K` | `25` | Candidates fetched before rerank |
|
|
568
|
+
| `RERANK_TOP_K` | `5` | Final chunks shown to the LLM |
|
|
569
|
+
| `RERANKER_MODEL` | `ms-marco-MiniLM-L-12-v2` | FlashRank model |
|
|
570
|
+
| `LLM_MODEL` | `gpt-4.1-mini` | Synthesis |
|
|
571
|
+
| `LLM_TEMPERATURE` | `0.2` | |
|
|
572
|
+
| `ROUTER_MODEL` | `gpt-4.1-nano` | Cheap classifier |
|
|
573
|
+
| `SQL_MODEL` | `gpt-4.1-mini` | NL→SQL translator |
|
|
574
|
+
| `SQL_DATABASE_URL` | (unset) | Leave unset to disable `sql_only` / `hybrid` strategies |
|
|
575
|
+
| `SQL_QUERY_TIMEOUT_SEC` | `5` | Per-query Postgres timeout |
|
|
576
|
+
| `SQL_ROW_LIMIT` | `200` | Implicit `LIMIT N` injection |
|
|
577
|
+
| `LANGFUSE_PUBLIC_KEY` | (unset) | Set both Langfuse keys to enable tracing — app is a no-op tracer when missing |
|
|
578
|
+
| `LANGFUSE_SECRET_KEY` | (unset) | — |
|
|
579
|
+
| `LANGFUSE_HOST` | `https://cloud.langfuse.com` | Override for the US region or self-hosted |
|
|
580
|
+
| `CACHE_DIR` | `./.cache` | OCR + embedding caches |
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## 16. Implementation Status
|
|
585
|
+
|
|
586
|
+
| Phase | Status | Description |
|
|
587
|
+
|---|---|---|
|
|
588
|
+
| 0. Setup & infra | ✅ | Repo, deps, Docker compose, settings |
|
|
589
|
+
| 1. Docling baseline | ✅ | Document → markdown for native formats |
|
|
590
|
+
| 2. Parser router + Qwen | ✅ | Born-digital vs scanned heuristic, Qwen3-VL OCR with cache |
|
|
591
|
+
| 3. Chunking + indexing | ✅ | Header-aware chunks, hybrid Qdrant collection, dedup |
|
|
592
|
+
| 4. Retrieval + chat | ✅ | Hybrid search + RRF + FlashRank + grounded answers with `[n]` citations |
|
|
593
|
+
| 5. Adaptive router + SQL | ✅ | Five-strategy router, read-only SQL tool, `[DB]` citations |
|
|
594
|
+
| 6. Eval + tracing + polish | ✅ | Langfuse spans, cost dashboard, routing-accuracy gate, DeepEval runner with HTML reports |
|
|
595
|
+
| 7. Stretch | ⏸️ | C-RAG self-reflection, multi-hop, MCP server, web fallback |
|
|
596
|
+
|
|
597
|
+
See `PROJECT_PLAN.md` for the full phase-by-phase task list and acceptance criteria.
|
|
598
|
+
|
|
599
|
+
---
|
|
600
|
+
|
|
601
|
+
## 17. Decision Log
|
|
602
|
+
|
|
603
|
+
| Decision | Chosen | Rejected | Reason |
|
|
604
|
+
|---|---|---|---|
|
|
605
|
+
| Routing layer | Query-time LLM classifier | Ingest-time extension matching | A PDF can have prose AND tables; routing must see the question |
|
|
606
|
+
| Document parser | Docling | LangChain native loaders, LlamaParse, Marker | Free, local, table-aware, multi-format |
|
|
607
|
+
| OCR | Qwen3-VL-Plus (API) | GLM-OCR (self-hosted), EasyOCR, Tesseract | No GPU, better quality on complex layouts, cheap per-image |
|
|
608
|
+
| Vector DB | Qdrant | pgvector, Weaviate, Chroma | Best hybrid (dense + sparse) support, server-side RRF |
|
|
609
|
+
| Sparse encoder | BM25 via FastEmbed | SPLADE, no sparse | Free, fast, no GPU |
|
|
610
|
+
| Reranker | FlashRank `ms-marco-MiniLM-L-12-v2` | BGE-reranker-v2-m3 (Torch), Cohere Rerank (paid), ColBERT | Pure ONNX via `onnxruntime` (already pulled by `fastembed`); no Torch / Transformers; sidesteps Python-3.14 native-extension instability we hit with `py-rust-stemmers` |
|
|
611
|
+
| Router LLM | `gpt-4.1-nano` | `gpt-4.1-mini`, `claude-haiku` | Cheapest model that classifies reliably; classification doesn't need frontier reasoning |
|
|
612
|
+
| Synthesis LLM | `gpt-4.1-mini` | `gpt-4.1`, `gpt-4o-mini` | Strong enough to follow citation rules, cheap enough for hot path |
|
|
613
|
+
| Tool protocol | Native Python class with explicit dispatch | OpenAI function calling registry, MCP for everything | We're not an agent loop — the router *picks* a strategy, then we run it. Registry abstraction is dead weight. MCP only when external clients consume. |
|
|
614
|
+
| SQL backend | Postgres in Docker bound to host port 5433 | Default 5432, SQLite, Neon | 5433 sidesteps collisions with host-installed Postgres on 5432; users can swap to Neon by changing `SQL_DATABASE_URL` |
|
|
615
|
+
| Read-only enforcement | Dedicated DB role + statement allowlist + keyword regex + statement_timeout + LIMIT injection + READ ONLY transaction | Just one of those | Defense in depth — any one layer might be misconfigured |
|
|
616
|
+
| Task queue | `BackgroundTasks` | Celery + Redis | YAGNI; add when measured queue depth justifies it |
|
|
617
|
+
| File watching | Manual upload | watchdog filesystem watcher | UI-driven flow is enough; watcher is feature creep |
|
|
618
|
+
| DB sync to vectors | Live SQL tool, query at runtime | CDC (Debezium et al.) | Core principle: never embed structured data |
|
|
619
|
+
| Eval | DeepEval + custom routing-accuracy metric | Ragas (async compat issues on Python 3.14), vibes-based | Portfolio projects without metrics look unfinished; DeepEval has no `nest_asyncio` / `sniffio` issues |
|
|
620
|
+
| Tracing | Langfuse | LangSmith, Prometheus + Grafana + Jaeger | LLM-native, model-/framework-agnostic, MIT-licensed core, generous free tier (50k events/month), self-hosting available — sidesteps the LangSmith vendor lock-in concern |
|
|
621
|
+
| Eval reference answers | Skipped for v1 | Hand-written gold answers per row | DeepEval Faithfulness / Answer Relevancy / Contextual Relevancy don't need them; contextual precision and recall do. Add an `expected_output` field to the golden set when the synthesis prompt is stable enough that recall scores are trustworthy. |
|
|
622
|
+
| Cost table source | Langfuse server-side computation | Maintain price table in repo | Model prices change; Langfuse keeps theirs current. We just read totals back via REST. |
|
|
623
|
+
| Tracing default | Disabled (no-op stubs) | Always-on with warnings | Cleaner OSS UX — works without signup; opt-in by setting two env vars |
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## 18. Future Work
|
|
628
|
+
|
|
629
|
+
### Phase 7 — Stretch
|
|
630
|
+
|
|
631
|
+
- **C-RAG self-reflection.** Grade retrieved context for relevance; on low scores, re-route or fall back to web search.
|
|
632
|
+
- **Multi-hop retrieval.** When `clarify` would have been picked, escalate to step-by-step iterative search instead of asking the user.
|
|
633
|
+
- **MCP server.** Expose `search_docs` and `query_sql` so Cursor / Claude Desktop can use the same tools.
|
|
634
|
+
- **Web search fallback.** Tavily / Exa when local context is insufficient.
|
|
635
|
+
- **Streaming responses.** Wire LLM streaming through Gradio for snappier UX.
|
|
636
|
+
- **Multi-collection Qdrant.** Split per-domain (`policies`, `finance`, `technical`) once ingest volume justifies the operational cost.
|
|
637
|
+
- **Image-in-markdown OCR.** Extract images from Docling output, OCR them with Qwen, inline the text into the parent markdown.
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
**End of architecture document.**
|