raglens-toolkit 0.1.0__tar.gz

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.
Files changed (96) hide show
  1. raglens_toolkit-0.1.0/LICENSE +21 -0
  2. raglens_toolkit-0.1.0/PKG-INFO +541 -0
  3. raglens_toolkit-0.1.0/README.md +490 -0
  4. raglens_toolkit-0.1.0/pyproject.toml +82 -0
  5. raglens_toolkit-0.1.0/raglens/__init__.py +13 -0
  6. raglens_toolkit-0.1.0/raglens/cache/__init__.py +19 -0
  7. raglens_toolkit-0.1.0/raglens/cache/chunk_cache.py +32 -0
  8. raglens_toolkit-0.1.0/raglens/cache/parser_cache.py +71 -0
  9. raglens_toolkit-0.1.0/raglens/chunking/__init__.py +3 -0
  10. raglens_toolkit-0.1.0/raglens/chunking/section_chunker.py +296 -0
  11. raglens_toolkit-0.1.0/raglens/chunking/structure_preserver.py +121 -0
  12. raglens_toolkit-0.1.0/raglens/cli.py +209 -0
  13. raglens_toolkit-0.1.0/raglens/config/__init__.py +9 -0
  14. raglens_toolkit-0.1.0/raglens/config/retrieval_config.py +37 -0
  15. raglens_toolkit-0.1.0/raglens/embedding/__init__.py +4 -0
  16. raglens_toolkit-0.1.0/raglens/embedding/embedding_generator.py +73 -0
  17. raglens_toolkit-0.1.0/raglens/embedding/providers.py +42 -0
  18. raglens_toolkit-0.1.0/raglens/evaluation/__init__.py +18 -0
  19. raglens_toolkit-0.1.0/raglens/evaluation/benchmark_runner.py +73 -0
  20. raglens_toolkit-0.1.0/raglens/evaluation/benchmark_visualizer.py +247 -0
  21. raglens_toolkit-0.1.0/raglens/evaluation/hierarchical_retrieval_evaluator.py +137 -0
  22. raglens_toolkit-0.1.0/raglens/evaluation/neighbor_hierarchical_retrieval_evaluator.py +64 -0
  23. raglens_toolkit-0.1.0/raglens/evaluation/retrieval_evaluator.py +108 -0
  24. raglens_toolkit-0.1.0/raglens/evaluation/retrieval_metrics.py +53 -0
  25. raglens_toolkit-0.1.0/raglens/llm/__init__.py +4 -0
  26. raglens_toolkit-0.1.0/raglens/llm/base.py +13 -0
  27. raglens_toolkit-0.1.0/raglens/llm/providers.py +86 -0
  28. raglens_toolkit-0.1.0/raglens/models/__init__.py +20 -0
  29. raglens_toolkit-0.1.0/raglens/models/chunk.py +29 -0
  30. raglens_toolkit-0.1.0/raglens/models/chunk_embedding.py +13 -0
  31. raglens_toolkit-0.1.0/raglens/models/document.py +25 -0
  32. raglens_toolkit-0.1.0/raglens/models/equation.py +13 -0
  33. raglens_toolkit-0.1.0/raglens/models/flattened_section.py +21 -0
  34. raglens_toolkit-0.1.0/raglens/models/hierarchical_retrieval_result.py +17 -0
  35. raglens_toolkit-0.1.0/raglens/models/neighbor_retrieval_result.py +15 -0
  36. raglens_toolkit-0.1.0/raglens/models/question_sample.py +22 -0
  37. raglens_toolkit-0.1.0/raglens/models/ragas_sample.py +17 -0
  38. raglens_toolkit-0.1.0/raglens/models/retrieval_result.py +13 -0
  39. raglens_toolkit-0.1.0/raglens/models/section.py +32 -0
  40. raglens_toolkit-0.1.0/raglens/models/table.py +18 -0
  41. raglens_toolkit-0.1.0/raglens/normalization/__init__.py +3 -0
  42. raglens_toolkit-0.1.0/raglens/normalization/section_flattener.py +66 -0
  43. raglens_toolkit-0.1.0/raglens/parsers/__init__.py +11 -0
  44. raglens_toolkit-0.1.0/raglens/parsers/docling_parser.py +42 -0
  45. raglens_toolkit-0.1.0/raglens/parsers/hierarchy_builder.py +45 -0
  46. raglens_toolkit-0.1.0/raglens/parsers/level_inference.py +28 -0
  47. raglens_toolkit-0.1.0/raglens/parsers/markdown_section_parser.py +111 -0
  48. raglens_toolkit-0.1.0/raglens/pipeline.py +237 -0
  49. raglens_toolkit-0.1.0/raglens/preprocessing/__init__.py +3 -0
  50. raglens_toolkit-0.1.0/raglens/preprocessing/formula_cleaner.py +74 -0
  51. raglens_toolkit-0.1.0/raglens/question_generation/__init__.py +25 -0
  52. raglens_toolkit-0.1.0/raglens/question_generation/question_cache.py +154 -0
  53. raglens_toolkit-0.1.0/raglens/question_generation/question_dataset_builder.py +107 -0
  54. raglens_toolkit-0.1.0/raglens/question_generation/question_dataset_loader.py +83 -0
  55. raglens_toolkit-0.1.0/raglens/question_generation/question_generator.py +130 -0
  56. raglens_toolkit-0.1.0/raglens/ragas/__init__.py +32 -0
  57. raglens_toolkit-0.1.0/raglens/ragas/answer_cache.py +115 -0
  58. raglens_toolkit-0.1.0/raglens/ragas/answer_dataset_builder.py +120 -0
  59. raglens_toolkit-0.1.0/raglens/ragas/answer_generator.py +58 -0
  60. raglens_toolkit-0.1.0/raglens/ragas/evaluation_dataset_builder.py +47 -0
  61. raglens_toolkit-0.1.0/raglens/ragas/fast_context_precision.py +31 -0
  62. raglens_toolkit-0.1.0/raglens/ragas/judge.py +75 -0
  63. raglens_toolkit-0.1.0/raglens/ragas/ragas_dataset_loader.py +88 -0
  64. raglens_toolkit-0.1.0/raglens/ragas/ragas_scorer.py +216 -0
  65. raglens_toolkit-0.1.0/raglens/ragas/ragas_visualizer.py +44 -0
  66. raglens_toolkit-0.1.0/raglens/ragas/sampling.py +35 -0
  67. raglens_toolkit-0.1.0/raglens/retrieval/__init__.py +23 -0
  68. raglens_toolkit-0.1.0/raglens/retrieval/base/__init__.py +3 -0
  69. raglens_toolkit-0.1.0/raglens/retrieval/base/base_retriever.py +20 -0
  70. raglens_toolkit-0.1.0/raglens/retrieval/bm25/__init__.py +3 -0
  71. raglens_toolkit-0.1.0/raglens/retrieval/bm25/bm25_retriever.py +42 -0
  72. raglens_toolkit-0.1.0/raglens/retrieval/dense/__init__.py +3 -0
  73. raglens_toolkit-0.1.0/raglens/retrieval/dense/dense_retriever.py +118 -0
  74. raglens_toolkit-0.1.0/raglens/retrieval/hierarchical/__init__.py +3 -0
  75. raglens_toolkit-0.1.0/raglens/retrieval/hierarchical/hierarchical_retriever.py +97 -0
  76. raglens_toolkit-0.1.0/raglens/retrieval/hybrid/__init__.py +3 -0
  77. raglens_toolkit-0.1.0/raglens/retrieval/hybrid/hybrid_retriever.py +67 -0
  78. raglens_toolkit-0.1.0/raglens/retrieval/neighbor/__init__.py +6 -0
  79. raglens_toolkit-0.1.0/raglens/retrieval/neighbor/neighbor_heirarchical_retriever.py +92 -0
  80. raglens_toolkit-0.1.0/raglens/retrieval/neighbor/neighbor_retriever.py +85 -0
  81. raglens_toolkit-0.1.0/raglens/validation/__init__.py +3 -0
  82. raglens_toolkit-0.1.0/raglens/validation/chunk_auditor.py +120 -0
  83. raglens_toolkit-0.1.0/raglens/vectorstore/__init__.py +1 -0
  84. raglens_toolkit-0.1.0/raglens/vectorstore/chroma_store.py +149 -0
  85. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/PKG-INFO +541 -0
  86. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/SOURCES.txt +94 -0
  87. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/dependency_links.txt +1 -0
  88. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/entry_points.txt +2 -0
  89. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/requires.txt +34 -0
  90. raglens_toolkit-0.1.0/raglens_toolkit.egg-info/top_level.txt +1 -0
  91. raglens_toolkit-0.1.0/setup.cfg +4 -0
  92. raglens_toolkit-0.1.0/tests/test_bm25_retriever.py +49 -0
  93. raglens_toolkit-0.1.0/tests/test_ragas_scorer.py +59 -0
  94. raglens_toolkit-0.1.0/tests/test_retrieval_config.py +28 -0
  95. raglens_toolkit-0.1.0/tests/test_retrieval_metrics.py +35 -0
  96. raglens_toolkit-0.1.0/tests/test_sampling.py +44 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arun Kumar Pandey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,541 @@
1
+ Metadata-Version: 2.4
2
+ Name: raglens-toolkit
3
+ Version: 0.1.0
4
+ Summary: A provider-agnostic toolkit for benchmarking retrieval strategies and running RAGAS end-to-end evaluation against your own PDF corpus.
5
+ Author: Arun Kumar Pandey
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/officialarun/RAG-Eval-Frameworks
8
+ Project-URL: Homepage, https://github.com/officialarun/RAG-Eval-Frameworks
9
+ Keywords: rag,retrieval-augmented-generation,ragas,llm-evaluation,retrieval-benchmarking
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: groq>=0.37.1
19
+ Requires-Dist: langchain-community<0.4.2,>=0.4.1
20
+ Requires-Dist: langchain-openai>=1.2.2
21
+ Requires-Dist: matplotlib>=3.10.9
22
+ Requires-Dist: numpy>=2.4.6
23
+ Requires-Dist: openai>=2.41.0
24
+ Requires-Dist: pandas>=3.0.3
25
+ Requires-Dist: python-dotenv>=1.2.2
26
+ Requires-Dist: ragas>=0.4.3
27
+ Requires-Dist: streamlit>=1.58.0
28
+ Requires-Dist: typer>=0.15.0
29
+ Provides-Extra: full
30
+ Requires-Dist: arxiv>=4.0.0; extra == "full"
31
+ Requires-Dist: chromadb>=1.5.9; extra == "full"
32
+ Requires-Dist: docling>=2.99.0; extra == "full"
33
+ Requires-Dist: ipykernel>=7.2.0; extra == "full"
34
+ Requires-Dist: langchain<1.0; extra == "full"
35
+ Requires-Dist: langchain-chroma>=1.1.0; extra == "full"
36
+ Requires-Dist: langchain-ollama>=1.1.0; extra == "full"
37
+ Requires-Dist: langchain-text-splitters>=1.1.2; extra == "full"
38
+ Requires-Dist: mediawikiapi>=1.3; extra == "full"
39
+ Requires-Dist: notebook>=7.5.7; extra == "full"
40
+ Requires-Dist: pylatexenc>=2.10; extra == "full"
41
+ Requires-Dist: pypdf>=6.12.2; extra == "full"
42
+ Requires-Dist: rank-bm25>=0.2.2; extra == "full"
43
+ Requires-Dist: rapidfuzz>=3.14.5; extra == "full"
44
+ Requires-Dist: tqdm>=4.67.3; extra == "full"
45
+ Requires-Dist: wikipedia-api>=0.15.0; extra == "full"
46
+ Provides-Extra: dev
47
+ Requires-Dist: build>=1.2.0; extra == "dev"
48
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
49
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
50
+ Dynamic: license-file
51
+
52
+ ---
53
+ title: raglens
54
+ emoji: 🔍
55
+ colorFrom: blue
56
+ colorTo: green
57
+ sdk: streamlit
58
+ sdk_version: "1.58.0"
59
+ app_file: dashboard/app.py
60
+ pinned: false
61
+ ---
62
+ <!-- The block above is Hugging Face Spaces config metadata (read only when
63
+ this repo is pushed to a Space) -- see docs/deploy-hf-space.md. It's inert
64
+ everywhere else, including on GitHub. -->
65
+
66
+ <p align="center">
67
+ <img src="artifacts/pipeline_animation.svg" alt="Pipeline Workflow Animation" width="1100">
68
+ </p>
69
+
70
+ # raglens
71
+
72
+ > A ground-up, provider-agnostic toolkit for building, benchmarking, and rigorously evaluating Retrieval-Augmented Generation systems — from PDF ingestion through RAGAS evaluation. Point it at your own PDF corpus via the CLI, or explore the reference run in `experiments/pipeline_validation.ipynb`.
73
+
74
+ **Current Stage:** Retrieval Benchmarking Complete &nbsp;|&nbsp; **In Progress:** RAGAS End-to-End Evaluation (2/5 retrievers scored)
75
+
76
+ ---
77
+
78
+ ## What This Project Is
79
+
80
+ This is not a chatbot project. The goal is to rigorously measure every component of a RAG pipeline — from how documents are chunked to whether the final generated answer is actually correct.
81
+
82
+ The approach is deliberately ground-up: build the corpus, build the retrievers, generate the ground truth dataset by hand, benchmark exhaustively, then apply RAGAS. This gives full traceability from source chunk → Q&A pair → retrieval result → end-to-end answer quality — something off-the-shelf frameworks obscure.
83
+
84
+ The current V2 pipeline is built entirely in `raglens/` — a pip-installable, provider-agnostic package (Ollama / OpenAI / Groq) with its own CLI and Streamlit dashboard, not just notebook cells. A previous iteration (V1, using Wikipedia + LangChain, code kept in `src/` for historical reference) revealed fundamental problems that made a rebuild necessary. That transition is explained in [Why We Rebuilt](#why-we-rebuilt-v1--v2).
85
+
86
+ ---
87
+
88
+ ## Quickstart — Run This On Your Own Corpus
89
+
90
+ ```bash
91
+ pip install -e .
92
+ cp .env.example .env # fill in whichever provider(s) you'll use
93
+
94
+ # 1. Parse + structure-aware chunk your PDFs
95
+ raglens ingest --docs ./my_pdfs
96
+
97
+ # 2. Embed + index (local & free via Ollama, or --embedding-provider openai)
98
+ raglens index --docs ./my_pdfs --embedding-provider ollama
99
+
100
+ # 3. Generate Q&A ground truth (resumable — safe to Ctrl-C and re-run)
101
+ raglens questions --docs ./my_pdfs --llm-provider openai
102
+
103
+ # 4. Benchmark retrieval quality (Hit@K, MRR, NDCG across 5 strategies)
104
+ raglens benchmark --docs ./my_pdfs --embedding-provider ollama
105
+
106
+ # 5. RAGAS end-to-end evaluation — judge-LLM-bound, checkpointed every batch
107
+ raglens evaluate --docs ./my_pdfs --embedding-provider ollama --judge-provider openai
108
+
109
+ # 6. Visualize everything
110
+ streamlit run dashboard/app.py
111
+ ```
112
+
113
+ Every long-running stage (`questions`, `evaluate`) is resumable: it's keyed by `(chunk_id, retriever)` and skips whatever's already cached, so it's safe to run for a few hours a night and pick back up exactly where it left off. Supported providers today: **Ollama** (local, free) and **OpenAI** for embeddings; **Ollama, OpenAI, and Groq** for generation/judging.
114
+
115
+ Want a public, zero-install link instead of running it locally? See [docs/deploy-hf-space.md](docs/deploy-hf-space.md) — the dashboard falls back to bundled reference results automatically, no API keys or local corpus needed for a hosted demo.
116
+
117
+ ---
118
+
119
+ ## Project Roadmap
120
+
121
+ ```
122
+ Stage 1: PDF Corpus & Document Ingestion ✅ Complete
123
+ Stage 2: Section-Aware Hierarchical Chunking ✅ Complete
124
+ Stage 3: Embedding Generation & Vector Store ✅ Complete
125
+ Stage 4: Five Retrieval Strategies ✅ Complete
126
+ Stage 5: LLM-Generated Benchmark Dataset (627 Q&As) ✅ Complete
127
+ Stage 6: Retrieval Benchmarking ✅ Complete
128
+ Stage 7: RAGAS End-to-End Evaluation 🔄 In progress (2/5 retrievers scored) ← current
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Why We Rebuilt (V1 → V2)
134
+
135
+ Three root causes forced the rebuild from scratch.
136
+
137
+ **1. Naive chunking broke semantic units.**
138
+ `RecursiveCharacterTextSplitter` with a fixed size and overlap rolled a window across the raw document text, cutting across section boundaries, splitting tables mid-row, and bisecting LaTeX formulas. Chunks had no awareness of document structure — a paragraph could end in one chunk and its concluding sentence begin in the next.
139
+
140
+ **2. Wikipedia was the wrong corpus.**
141
+ Wikipedia articles are noisy: citation markers, redirect stubs, heavily cross-linked prose, and mathematical notation that does not survive plain-text extraction. The corpus introduced irrelevant matches that polluted retrieval results and made evaluation misleading.
142
+
143
+ **3. The evaluation methodology measured the wrong thing.**
144
+ Matching a retrieved chunk ID against one "expected" chunk from hundreds of candidates — when a question about transformers could reasonably be answered by many different chunks — produced near-zero recall that did not reflect actual retrieval quality. The metric was too strict in the wrong way.
145
+
146
+ **V2 addressed all three:** curated PDF corpus + Docling-based structure-aware parsing + section-aware chunking + ground truth generated from the exact source chunk so each question has one verifiable, traceable correct answer.
147
+
148
+ ---
149
+
150
+ ## Pipeline Stages
151
+
152
+ Eight stages transform raw PDFs into benchmark-ready retrieval results.
153
+
154
+ | # | Stage | Tool(s) | Input | Output |
155
+ |---|-------|---------|-------|--------|
156
+ | 1 | PDF Ingestion | Docling | Raw PDFs (13 docs) | Markdown with LaTeX formulae intact |
157
+ | 2 | Section Parsing + Hierarchy | MarkdownSectionParser → LevelInference → HierarchyBuilder | Markdown string | `Document` with nested `Section` tree |
158
+ | 3 | Normalization | SectionFlattener | Nested Section tree | `List[FlattenedSection]` with pre-computed breadcrumb paths |
159
+ | 4 | Section-Aware Chunking | SectionChunker + StructurePreserver | FlattenedSections | `List[Chunk]` — three types: `parent_section`, `table_fragment`, `section_fragment` |
160
+ | 5 | Embedding | EmbeddingGenerator (Ollama `nomic-embed-text` or OpenAI) | Chunks | `List[ChunkEmbedding]` |
161
+ | 6 | Vector Store | ChromaStore (ChromaDB) | ChunkEmbeddings | Persisted similarity index |
162
+ | 7 | Synthetic Q&A Generation | Configurable LLM (Ollama / OpenAI / Groq) | Chunks | 627 Q&A pairs in JSONL cache |
163
+ | 8 | Retrieval + Evaluation | 5 Retrievers + 3 Evaluators | QuestionSamples | Hit@K, MRR, NDCG, Section Hit, Latency |
164
+
165
+ **The data transformation story:** A raw PDF becomes a `Document` (root container with metadata). The document's heading structure is parsed into a nested `Section` tree. That tree is flattened into `List[FlattenedSection]` — each section pre-annotated with its full breadcrumb path (e.g. `"lbdl > Chapter 3 > Optimization"`). The chunker then transforms each section into one or more `Chunk` objects — the atomic unit for embedding and retrieval. Finally, each chunk is embedded into a vector and stored in ChromaDB.
166
+
167
+ ---
168
+
169
+ ## Strategies at Each Stage
170
+
171
+ ### Chunking Strategies
172
+
173
+ The chunker applies a three-tier decision based on section size:
174
+
175
+ ```
176
+ Section content length?
177
+ ├── ≤ 2,500 chars
178
+ │ └── Single parent_section chunk (fragment_index = -1, meaning "atomic, not split")
179
+
180
+ └── > 2,500 chars
181
+ ├── Always create: parent_section chunk (full section text — used by hierarchical retrieval)
182
+
183
+ └── Also create fragment children:
184
+
185
+ ├── Tables (StructurePreserver)
186
+ │ ├── Table ≤ 3,000 chars → single atomic table_fragment chunk
187
+ │ └── Table > 3,000 chars → row-by-row table_fragment chunks
188
+ │ └── Header Duplication: every fragment = header_row + separator_row + data_rows
189
+ │ (a row of "| 0.75 | 0.83 |" is meaningless without its column names)
190
+
191
+ └── Remaining text → RecursiveCharacterTextSplitter (size=1,200, overlap=200)
192
+ └── section_fragment chunks, fragment_index = 0, 1, 2 ...
193
+ └── Sequential fragment_index enables neighbor retrieval later
194
+ ```
195
+
196
+ **Table-aware extraction** — Markdown tables are detected by the presence of `|` pipe characters. Small tables are preserved as a single atomic chunk (schema intact). Large tables are split at row boundaries, never mid-row, into ~1,500-character fragments.
197
+
198
+ **Header duplication** — When a large table is fragmented, every fragment gets the original header row and separator row prepended: `header + "\n" + separator + "\n" + data_rows`. This makes each fragment self-interpreting when retrieved in isolation.
199
+
200
+ **Formula protection** — LaTeX blocks (`$$...$$`) are replaced with `__FORMULA_N__` placeholders before text splitting and restored afterward. This prevents a formula from being cut mid-expression by the character-based splitter.
201
+
202
+ **Why not a sliding window?** — Fixed-size windows roll across the full document, crossing section boundaries. Section-aware chunking treats each section as the atomic semantic unit — small sections are never fragmented, and fragments never contain content from two different sections.
203
+
204
+ ---
205
+
206
+ ### Retrieval Strategies
207
+
208
+ Five strategies are implemented, each building on the previous:
209
+
210
+ | Strategy | Approach | Score | Key Design |
211
+ |----------|----------|-------|------------|
212
+ | BM25 | Lexical (Okapi BM25) | Raw, 0–∞ | In-memory; no vector store dependency |
213
+ | Dense | Semantic (nomic-embed-text) | Cosine similarity, 0–1 | Retrieves k×3 before filtering bad sections |
214
+ | Hybrid | Dense + BM25 fusion | Fused, 0–1 | Min-max normalization per retriever, then 50/50 weighted sum |
215
+ | Hierarchical | Hybrid → parent section context | Fused, 0–1 | Pre-built `(doc_id, section_id) → Chunk` dict for O(1) parent lookup |
216
+ | Hier+Neighbor | Hierarchical + fragment window | — | Expands to ±1 fragment siblings using `fragment_index` (section-local, never crosses boundaries) |
217
+
218
+ **Score normalization in Hybrid** — BM25 scores are unbounded [0, ∞); Dense scores are cosine similarity [0, 1]. Without normalization, BM25 magnitudes would dominate the fusion. Min-Max normalization applied independently per retriever: `(score − min) / (max − min)`, then `fused = 0.5 × dense_norm + 0.5 × bm25_norm`.
219
+
220
+ **Two flavours of neighbor retrieval** — `NeighborRetriever` uses global `chunk_order` (a monotonic counter) to find adjacent chunks; this can cross section boundaries. `NeighborHierarchicalRetriever` uses `fragment_index` (section-local counter) to expand only within the same section — the version used in benchmarking.
221
+
222
+ **Candidate over-retrieval** — All retrievers retrieve more candidates than k before filtering. Reference sections, bibliographies, and indices are excluded via the centralized `BAD_SECTIONS` config. Over-retrieval guarantees k clean results remain after filtering.
223
+
224
+ ---
225
+
226
+ ### Question Generation Strategy
227
+
228
+ - **One Q&A per chunk** — avoids questions where multiple chunks give equivalent answers, which would make ground truth ambiguous.
229
+ - **Prompt engineering** — the generation prompt explicitly bans phrases like "According to the text" or "In this section," forcing standalone natural-language questions a real user might ask.
230
+ - **Skip logic** — chunks with insufficient content (very short fragments, context-free table rows) return `{"status": "skip"}` and are excluded from the evaluation dataset.
231
+ - **Resumable JSONL cache** — generation results are appended one record at a time to a JSONL file. If the process is interrupted, it resumes from the last completed chunk — no LLM calls are repeated, no data is lost.
232
+
233
+ ---
234
+
235
+ ## raglens Folder Stories
236
+
237
+ Every folder in `raglens/` has a specific responsibility. Here is the story of each one.
238
+
239
+ ### `models/` — The Schema Layer
240
+
241
+ Pure dataclasses with no behavior. Defines the full data hierarchy:
242
+
243
+ ```
244
+ Document
245
+ └── Section (nested tree, preserves heading hierarchy)
246
+ └── FlattenedSection (tree → flat list, path pre-computed)
247
+ └── Chunk (atomic unit for embedding + retrieval)
248
+ └── ChunkEmbedding (Chunk + its dense vector)
249
+ ```
250
+
251
+ Three result types exist because three retrieval paradigms return different shapes of data:
252
+ - `RetrievalResult` — flat retrievers (Dense, BM25, Hybrid): `chunk + score + retriever_name`
253
+ - `HierarchicalRetrievalResult` — Hierarchical retriever: `parent_chunk + child_chunk + score`
254
+ - `NeighborRetrievalResult` — NeighborRetriever: `center_chunk + neighbor_chunks + score`
255
+
256
+ Everything between pipeline stages is typed. No raw dicts are passed between components.
257
+
258
+ ---
259
+
260
+ ### `parsers/` — The Document Understanding Layer
261
+
262
+ Four classes form a sequential pipeline:
263
+
264
+ 1. **DoclingParser** — converts a PDF file to a Markdown string using Docling with formula enrichment enabled. This preserves heading levels as `#`/`##` and LaTeX as `$$...$$`.
265
+ 2. **MarkdownSectionParser** — regex-matches heading lines and builds a nested `Section` tree, assigning `parent_section_id` from a stack as it descends through heading levels.
266
+ 3. **LevelInference** — infers section depth from title numbering patterns (e.g. "3.2.1 Introduction" → level 3) for documents that use numbered headings instead of Markdown heading levels.
267
+ 4. **HierarchyBuilder** — validates and rebuilds parent-child relationships from section levels; corrects any inconsistencies introduced by the parser.
268
+
269
+ Together they transform raw PDF bytes into a typed, hierarchical `Document` object.
270
+
271
+ ---
272
+
273
+ ### `normalization/` — The Bridge Layer
274
+
275
+ `SectionFlattener` converts the nested `Section` tree into a flat `List[FlattenedSection]`. For each section it pre-computes the full breadcrumb path (e.g. `"lbdl > Chapter 3 > Stochastic Gradient Descent"`) and records `parent_section_id`.
276
+
277
+ This layer exists so chunking receives a simple flat list. The chunker never needs to traverse the tree — all the ancestry information is already embedded in each `FlattenedSection`.
278
+
279
+ ---
280
+
281
+ ### `chunking/` — The Fragmentation Layer
282
+
283
+ `SectionChunker` orchestrates the three-tier strategy (small section → atomic, large section → parent + fragments). `StructurePreserver` handles everything structure-sensitive: table detection, large-table fragmentation with header duplication, and formula placeholder substitution.
284
+
285
+ The key output fields on every `Chunk`:
286
+ - `chunk_type` — `parent_section`, `table_fragment`, or `section_fragment`
287
+ - `fragment_index` — `-1` for atomic parent sections; `0, 1, 2…` for ordered fragments
288
+ - `parent_section_id` — links every fragment back to its originating section
289
+
290
+ These three fields are what make hierarchical and neighbor retrieval possible downstream.
291
+
292
+ ---
293
+
294
+ ### `embedding/` — The Vectorization Layer
295
+
296
+ Stateless: takes a `Chunk`, returns a vector. `EmbeddingGenerator` wraps whatever LangChain `Embeddings` instance `get_embedding_provider("ollama" | "openai")` returns — the same instance is used for both indexing (`embed_documents`) and query-time retrieval (`embed_query`), so there's exactly one embedding object per run. Ollama is free/local/deterministic with no API keys; OpenAI is available for higher-quality embeddings.
297
+
298
+ ---
299
+
300
+ ### `vectorstore/` — The Index Layer
301
+
302
+ `ChromaStore` wraps ChromaDB. Each entry stores `(chunk_id, embedding, metadata_dict)` where the metadata dict contains all `Chunk` fields. This inline metadata storage means retrieved results can reconstruct full `Chunk` objects without a secondary database lookup.
303
+
304
+ ---
305
+
306
+ ### `config/` — The Settings Layer
307
+
308
+ `RetrievalConfig` is an injectable dataclass (constructor-passed to `BM25Retriever`/`DenseRetriever`, defaulting to a shared `DEFAULT_CONFIG` instance) with three fields:
309
+ - `bad_sections` — section titles to exclude: `references`, `bibliography`, `contents`, `index`, `list of figures`, `list of tables`
310
+ - `exclude_reference_sections` — boolean flag
311
+ - `is_bad_section(title)` — normalizes title to lowercase alphanumeric, checks membership
312
+
313
+ All retrievers accept a `config` parameter. Without this filtering, BM25 retrieves bibliography entries because they contain topic-related keywords; Dense retrieves index pages because they share vocabulary with queries. Passing a custom `RetrievalConfig` lets a different corpus define its own excluded sections.
314
+
315
+ ---
316
+
317
+ ### `retrieval/` — The Strategy Layer
318
+
319
+ Five strategies arranged by composition depth:
320
+
321
+ ```
322
+ BM25Retriever ← in-memory lexical matching, no vector store
323
+ DenseRetriever ← semantic matching via embedding + ChromaDB
324
+ HybridRetriever ← composes Dense + BM25, normalizes + fuses scores
325
+ HierarchicalRetriever ← wraps Hybrid, promotes results to parent section context
326
+ NeighborHierarchicalRetriever ← wraps Hierarchical, expands to sibling fragments
327
+ ```
328
+
329
+ `BM25Retriever` and `DenseRetriever` extend `BaseRetriever` (abstract class enforcing `retrieve(query, k)`). The remaining three compose by duck typing — they accept any object with a `retrieve()` method.
330
+
331
+ ---
332
+
333
+ ### `evaluation/` — The Metrics Layer
334
+
335
+ `retrieval_metrics.py` contains three pure functions: `hit_at_k`, `reciprocal_rank`, `ndcg_at_k`. No state, no side effects.
336
+
337
+ Three evaluator classes exist because each retriever returns a different result type:
338
+
339
+ | Evaluator | Used with | Result type | Metrics |
340
+ |-----------|-----------|-------------|---------|
341
+ | `RetrievalEvaluator` | Dense, BM25, Hybrid | `List[RetrievalResult]` | Hit@K, MRR, NDCG@K, Latency |
342
+ | `HierarchicalRetrievalEvaluator` | Hierarchical | `List[HierarchicalRetrievalResult]` | Child Hit@K, Section Hit@K, Sect+Child, MRR, Latency |
343
+ | `NeighborHierarchicalRetrievalEvaluator` | NeighborHierarchical | `List[Chunk]` | Child Hit@K, Latency |
344
+
345
+ ---
346
+
347
+ ### `question_generation/` — The Dataset Layer
348
+
349
+ Turns `List[Chunk]` into 627 evaluation Q&A pairs with full ground truth traceability.
350
+
351
+ - `QuestionGenerator` — wraps an injectable `LLMProvider` (Ollama/OpenAI/Groq, defaults to OpenAI); generates one Q&A per chunk with engineered prompts
352
+ - `QuestionDatasetBuilder` — orchestrates generation with resumability via `get_completed_chunk_ids()`
353
+ - `question_cache.py` — JSONL persistence; append-only so the file survives mid-run interruptions
354
+ - `QuestionDatasetLoader` — filters the cache to `status=success` records and returns `List[QuestionSample]`
355
+
356
+ ---
357
+
358
+ ### `cache/` — The Persistence Layer
359
+
360
+ Two different caching formats serving two different failure modes:
361
+
362
+ - **Pickle (`parser_cache.py`)** — binary, fast, stores `Document` objects post-Docling. Docling parsing can take 30+ seconds per PDF; the cache avoids re-running it every session.
363
+ - **JSONL (`question_cache.py`)** — text, append-only, human-readable. LLM generation can fail mid-run; JSONL means each completed record is immediately safe. A full-file rewrite (as JSON would require) would lose data on a crash.
364
+
365
+ ---
366
+
367
+ ### `preprocessing/` — The Cleaning Layer
368
+
369
+ `formula_cleaner.py` pre-processes LaTeX content before it reaches Docling, removing or normalizing malformed formula syntax that would produce parsing artifacts in the rendered Markdown output.
370
+
371
+ ---
372
+
373
+ ### `validation/` — The Audit Layer
374
+
375
+ `ChunkAuditor` validates chunk integrity after chunking completes. It checks for empty chunks, missing IDs, malformed `fragment_index` values, and parent-child consistency. Catches issues before they silently corrupt the vector store.
376
+
377
+ ---
378
+
379
+ ## Benchmark Results
380
+
381
+ Results from `experiments/experiments/data/benchmark_results.json`, measured across 627 evaluation samples.
382
+
383
+ ### Numbers
384
+
385
+ | Retriever | Type | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@5 | NDCG@5 | Section Hit@5 | Latency@5 |
386
+ |-----------|------|-------|-------|-------|--------|-------|--------|---------------|-----------|
387
+ | BM25 | Flat | 75.6% | 87.0% | 90.2% | 92.9% | 81.3% | 83.5% | — | 2.1 ms |
388
+ | Dense | Flat | 51.9% | 67.2% | 73.2% | 79.3% | 60.4% | 63.6% | — | 13.9 ms |
389
+ | Hybrid | Flat | 73.0% | 87.8% | 91.5% | 93.9% | 80.6% | 83.3% | — | 19.3 ms |
390
+ | Hierarchical | Hierarchical | — | 82.2% | 83.4% | 85.1% | 78.7% | — | 95.0% | 24.4 ms |
391
+ | Hier+Neighbor | Hier+Context | — | 87.8% | 89.4% | 91.3% | — | — | — | 23.0 ms |
392
+
393
+ ![Benchmark Results Table](experiments/experiments/data/Retrieval_Benchmark_Reults.png)
394
+
395
+ ![Benchmark Chart — Hit@K, MRR, Hierarchical Breakdown, Latency](experiments/experiments/data/benchmark_chart.png)
396
+
397
+ ---
398
+
399
+ ### Are These Numbers Good? — An Objective Assessment
400
+
401
+ **What the numbers show:**
402
+ - Hybrid at 91.5% Hit@5 means 9 out of 10 queries find the right chunk in the top 5 results.
403
+ - Hierarchical at 95.0% Section Hit@5 means the correct document section is retrieved almost every time.
404
+ - Hier+Neighbor at 89.4% vs Hierarchical at 83.4% (+6%) confirms that expanding the context window to neighboring fragments meaningfully recovers missed chunks.
405
+
406
+ **Why these numbers are optimistic — three honest caveats:**
407
+
408
+ **1. BM25 has a structural advantage in this benchmark.**
409
+ The ground truth questions were generated by an LLM *from* the source chunks. The LLM naturally uses terminology from the chunk text, so BM25 (keyword matching) is predisposed to score well. The 75.6% vs 51.9% Hit@1 gap (BM25 vs Dense) likely overstates BM25's real-world advantage — on natural user queries that don't mirror chunk vocabulary, the gap would be smaller.
410
+
411
+ **2. Dense retrieval underperforms its potential here.**
412
+ `nomic-embed-text` runs locally via Ollama on CPU. Frontier embedding models (`text-embedding-3-large`, `BGE-M3`, `MXBAI`) would likely close the gap with BM25 significantly. The 51.9% Hit@1 reflects this local model limitation, not a ceiling on semantic retrieval.
413
+
414
+ **3. Single ground truth per question underestimates retrieval quality.**
415
+ Each question maps to exactly one "correct" chunk ID. Many questions can be correctly answered by multiple semantically similar chunks in the corpus. When a retriever returns a different-but-equally-valid chunk, the evaluation marks it as a miss. Hit@K underestimates actual retrieval usefulness — a point first identified in EXP01 and still true in V2.
416
+
417
+ **Bottom line:** The retrieval layer is working correctly within this benchmark setup. These numbers confirm the pipeline is functioning and that the five strategies are meaningfully differentiated. RAGAS will give a more honest end-to-end assessment — it measures whether the final generated answer is actually correct, not whether a specific chunk ID was found.
418
+
419
+ ---
420
+
421
+ ## RAGAS End-to-End Evaluation — In Progress
422
+
423
+ RAGAS (Retrieval Augmented Generation Assessment) measures end-to-end RAG quality — not just whether the right chunk was retrieved, but whether the system produced a correct, faithful, relevant answer.
424
+
425
+ ### The Four Metrics Actually Used
426
+
427
+ | Metric | Question it answers |
428
+ |--------|-------------------|
429
+ | **Faithfulness** | Does the generated answer stay within what the retrieved context actually says? Detects hallucination. |
430
+ | **Factual Correctness** | Is the generated answer factually aligned with the reference answer? (Used in place of Answer Relevancy — a better fit here since every question already has a verified reference answer.) |
431
+ | **Context Precision** | Of the retrieved chunks, what fraction are genuinely useful for answering the question? Scored with a custom `FastContextPrecision` metric that parallelizes ragas's default sequential per-context loop. |
432
+ | **Context Recall** | Does the retrieved context contain all the information needed to construct the correct answer? |
433
+
434
+ Judge LLM: `gpt-4o-mini` by default (configurable — `raglens evaluate --judge-provider ollama\|openai\|groq`).
435
+
436
+ ### How It Runs
437
+
438
+ 1. **Retrieve + generate answers** (`raglens.ragas.EvaluationDatasetBuilder` + `AnswerDatasetBuilder`) — for a stratified 150-question sample (proportional per source document, seed 42) × each retriever, retrieve top-5 chunks and generate an answer. Cached to JSONL, resumable.
439
+ 2. **Score with RAGAS** (`raglens.ragas.RagasScorer`) — batches of 10 samples sent to the judge LLM, with results appended to a JSONL cache after every batch and keyed by `(retriever, chunk_id)`. An interrupted run — expected, since judge-LLM scoring runs at roughly 1-8 minutes per sample — resumes exactly where it left off; already-scored pairs are never re-sent.
440
+ 3. **Aggregate** (`raglens report`) — mean scores per retriever → comparison table + chart.
441
+
442
+ ### Status
443
+
444
+ | Retriever | Answers generated | RAGAS scored |
445
+ |-----------|:---:|:---:|
446
+ | BM25 | ✅ 150/150 | ✅ 150/150 |
447
+ | Dense | ✅ 150/150 | ✅ 150/150 |
448
+ | Hybrid | ✅ 150/150 (622/622 full set) | ⏳ 0/150 |
449
+ | Hierarchical | ✅ 150/150 | ⏳ 0/150 |
450
+ | Hier+Neighbor | ✅ 150/150 | ⏳ 0/150 |
451
+
452
+ Resume scoring with `raglens evaluate` — it picks up on `hybrid` next automatically.
453
+
454
+ ### The Key Insight RAGAS Unlocks
455
+
456
+ Hit@K tells us *a chunk was retrieved*. RAGAS tells us *whether the LLM used that chunk to produce a correct, faithful answer*. A retriever that scores high on Hit@K may still produce hallucinations if its retrieved context is noisy or misaligned with the question. That is the gap this stage closes.
457
+
458
+ ---
459
+
460
+ ## Project Structure
461
+
462
+ ```
463
+ rag-evaluation-framework/
464
+
465
+ ├── data/
466
+ │ └── documents/ # Your PDF corpus (BYO — 13 curated GenAI/RAG PDFs used for the reference run)
467
+
468
+ ├── experiments/
469
+ │ ├── pipeline_validation.ipynb # Reference notebook — all stages run cell-by-cell
470
+ │ └── experiments/data/
471
+ │ ├── benchmark_results.json # Saved benchmark numbers
472
+ │ ├── Retrieval_Benchmark_Reults.png # Pandas styled results table
473
+ │ └── benchmark_chart.png # 4-panel comparison chart
474
+
475
+ ├── raglens/ # Core library (pip-installable)
476
+ │ ├── models/ # Dataclasses: Document, Section, Chunk, results
477
+ │ ├── parsers/ # Docling → Markdown → Section tree
478
+ │ ├── normalization/ # Section tree → flat FlattenedSection list
479
+ │ ├── chunking/ # SectionChunker + StructurePreserver
480
+ │ ├── embedding/ # Ollama / OpenAI embedding providers + EmbeddingGenerator
481
+ │ ├── llm/ # Ollama / OpenAI / Groq text-generation providers
482
+ │ ├── vectorstore/ # ChromaDB wrapper
483
+ │ ├── config/ # Injectable RetrievalConfig (bad sections, top-k)
484
+ │ ├── retrieval/ # BM25, Dense, Hybrid, Hierarchical, Neighbor
485
+ │ ├── evaluation/ # Hit@K, MRR, NDCG, benchmark runner + charts
486
+ │ ├── question_generation/ # Q&A generation + JSONL cache
487
+ │ ├── ragas/ # RagasScorer, judge factory, sampling, dataset builders
488
+ │ ├── cache/ # Resumable caches (parsed docs, chunks, answers)
489
+ │ ├── preprocessing/ # Formula cleaner
490
+ │ ├── validation/ # Chunk auditor
491
+ │ ├── pipeline.py # RagLensPipeline — orchestrates every stage
492
+ │ └── cli.py # `raglens` command (ingest/index/questions/benchmark/evaluate/report)
493
+
494
+ ├── dashboard/
495
+ │ └── app.py # Streamlit dashboard — visualizes CLI-produced results
496
+
497
+ ├── tests/ # pytest — pure-logic unit tests (metrics, config, sampling, scorer)
498
+
499
+ ├── src/ # V1 (legacy, kept for historical reference — see "Why We Rebuilt")
500
+
501
+ └── artifacts/
502
+ └── processtillnow.png # V1 architecture diagram
503
+ ```
504
+
505
+ ---
506
+
507
+ ## Tech Stack
508
+
509
+ | Component | Tool | Why |
510
+ |-----------|------|-----|
511
+ | PDF Parsing | [Docling](https://github.com/DS4SD/docling) | Formula-aware; preserves heading hierarchy from complex PDFs |
512
+ | LLM (Q&A gen / answers / judge) | Ollama, OpenAI, or Groq (your choice per-command) | Free local default via Ollama; cloud providers for quality/speed when needed |
513
+ | Embedding | Ollama (`nomic-embed-text`) or OpenAI | Local, deterministic, no cloud dependency — or OpenAI when you want it |
514
+ | Vector Store | ChromaDB | Lightweight, local persistence, no infrastructure required |
515
+ | Lexical Retrieval | rank-bm25 (Okapi BM25) | Fast in-memory BM25, no external service |
516
+ | Text Splitting | LangChain `RecursiveCharacterTextSplitter` | Used only for text fragments within large sections |
517
+ | Evaluation Framework | RAGAS | Standard RAG evaluation library |
518
+ | CLI | Typer | `raglens` command: ingest/index/questions/benchmark/evaluate/report |
519
+ | Dashboard | Streamlit | Local visualization of CLI-produced results |
520
+ | Language | Python 3.12 | — |
521
+
522
+ ---
523
+
524
+ See [Quickstart](#quickstart--run-this-on-your-own-corpus) above to run this on your own PDFs. To reproduce the exact reference run instead, open `experiments/pipeline_validation.ipynb` and run top to bottom.
525
+
526
+ ---
527
+
528
+ ## Version 1 Notes (Historical Context)
529
+
530
+ The following summarises the first iteration of this project, which used a LangChain-based pipeline on Wikipedia data. It is preserved here as context for the transition to V2.
531
+
532
+ - **Data source**: ~20 Wikipedia articles on ML/AI topics (Transformers, LLMs, Embeddings, Vector Databases, etc.) fetched via the Wikipedia API
533
+ - **Tech stack**: LangChain + ChromaDB + nomic-embed-text (same embedding model) + Qwen3:8B
534
+ - **Chunking**: `RecursiveCharacterTextSplitter` at chunk_size=1000, overlap=200
535
+ - **Retrieval evaluated**: Vector similarity search and BM25 (LangChain abstractions)
536
+ - **Evaluation metrics**: Exact Chunk Recall, Semantic Similarity (cosine ~0.63 average), LLM-as-a-Judge (~0.74 average)
537
+ - **Key finding**: Exact chunk recall severely underestimated retrieval quality — relevant answers came from chunks other than the "expected" source chunk
538
+ - **Key finding**: LLM-as-a-Judge scores correlated better with answer quality than embedding cosine similarity
539
+ - **Identified limitations**: No hybrid retrieval, no hallucination detection, Wikipedia noise, naive chunking, no section awareness
540
+ - **Outcome**: All identified limitations became the design requirements for V2
541
+ - **Reference links**: [Hallucination Detection Guide](https://oneuptime.com/blog/post/32026-01-30-hallucination-detection/view) · [LLM Eval Projects Guide](https://docs.google.com/document/d/1JrwCVhrDScXxZ_1l9s04TmSUj4BaptTU/mobilebasic)