Skip to main content

A provider-agnostic toolkit for benchmarking retrieval strategies and running RAGAS end-to-end evaluation against your own PDF corpus.

Project description


title: raglens emoji: ๐Ÿ” colorFrom: blue colorTo: green sdk: docker app_port: 7860 pinned: false license: mit

Pipeline Workflow Animation

raglens

PyPI CI Live Demo Python 3.12 MIT License

Live Demo ยท PyPI ยท Quickstart ยท Full Docs Below

A ground-up, provider-agnostic toolkit for building, benchmarking, and rigorously evaluating Retrieval-Augmented Generation systems โ€” structure-aware PDF ingestion, five retrieval strategies, and RAGAS end-to-end evaluation, wrapped in a pip install-able CLI and a Streamlit dashboard.

TL;DR

pip install raglens-toolkit[full]
raglens ingest --docs ./my_pdfs && raglens index --docs ./my_pdfs --embedding-provider ollama

That's the whole install-to-first-result path. No API keys needed if you use Ollama locally. Or skip installing anything and click through the live demo โ€” it shows this project's own real benchmark and RAGAS numbers.

What it is, in one paragraph: most RAG tutorials measure "did the chatbot answer nicely." This project instead asks a narrower, harder question โ€” for a given PDF corpus, which retrieval strategy actually finds the right information, and does the final generated answer stay faithful to it? โ€” and answers it with a from-scratch pipeline (no LangChain retrieval abstractions, no off-the-shelf chunker) that's traceable from source chunk โ†’ generated question โ†’ retrieval result โ†’ RAGAS score at every step.

Where it stands right now: retrieval benchmarking across 5 strategies is complete (627 Q&A pairs, full results below). RAGAS end-to-end evaluation is in progress โ€” 2 of 5 retrievers fully scored, the rest answer-generated and queued, resumable at any time (raglens evaluate).


Table of Contents


Quickstart โ€” Run This On Your Own Corpus

pip install raglens-toolkit[full]   # or: git clone + pip install -e ".[full]"
cp .env.example .env                # fill in whichever provider(s) you'll use

# 1. Parse + structure-aware chunk your PDFs
raglens ingest --docs ./my_pdfs

# 2. Embed + index (local & free via Ollama, or --embedding-provider openai)
raglens index --docs ./my_pdfs --embedding-provider ollama

# 3. Generate Q&A ground truth (resumable โ€” safe to Ctrl-C and re-run)
raglens questions --docs ./my_pdfs --llm-provider openai

# 4. Benchmark retrieval quality (Hit@K, MRR, NDCG across 5 strategies)
raglens benchmark --docs ./my_pdfs --embedding-provider ollama

# 5. RAGAS end-to-end evaluation โ€” judge-LLM-bound, checkpointed every batch
raglens evaluate --docs ./my_pdfs --embedding-provider ollama --judge-provider openai

# 6. Visualize everything
streamlit run dashboard/app.py

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.

pip install raglens-toolkit alone (no [full]) installs just enough for the dashboard and raglens report โ€” the ingest/index/benchmark/evaluate pipeline needs the [full] extra (Docling, ChromaDB, Ollama client). See From Research Pipeline to Open-Source Tool for why that split exists.

Want a public, zero-install link instead of running it locally? huggingface.co/spaces/Arun-56/raglens-demo โ€” the dashboard falls back to bundled reference results automatically, no API keys or local corpus needed for a hosted demo. See docs/deploy-hf-space.md to deploy your own copy.


Why We Rebuilt (V1 โ†’ V2)

Three root causes forced the rebuild from scratch.

1. Naive chunking broke semantic units. 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.

2. Wikipedia was the wrong corpus. 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.

3. The evaluation methodology measured the wrong thing. 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.

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.


Pipeline Stages

Eight stages transform raw PDFs into benchmark-ready retrieval results.

# Stage Tool(s) Input Output
1 PDF Ingestion Docling Raw PDFs (13 docs) Markdown with LaTeX formulae intact
2 Section Parsing + Hierarchy MarkdownSectionParser โ†’ LevelInference โ†’ HierarchyBuilder Markdown string Document with nested Section tree
3 Normalization SectionFlattener Nested Section tree List[FlattenedSection] with pre-computed breadcrumb paths
4 Section-Aware Chunking SectionChunker + StructurePreserver FlattenedSections List[Chunk] โ€” three types: parent_section, table_fragment, section_fragment
5 Embedding EmbeddingGenerator (Ollama nomic-embed-text or OpenAI) Chunks List[ChunkEmbedding]
6 Vector Store ChromaStore (ChromaDB) ChunkEmbeddings Persisted similarity index
7 Synthetic Q&A Generation Configurable LLM (Ollama / OpenAI / Groq) Chunks 627 Q&A pairs in JSONL cache
8 Retrieval + Evaluation 5 Retrievers + 3 Evaluators QuestionSamples Hit@K, MRR, NDCG, Section Hit, Latency

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.


Strategies at Each Stage

Chunking Strategies

The chunker applies a three-tier decision based on section size:

Section content length?
โ”œโ”€โ”€ โ‰ค 2,500 chars
โ”‚   โ””โ”€โ”€ Single parent_section chunk  (fragment_index = -1, meaning "atomic, not split")
โ”‚
โ””โ”€โ”€ > 2,500 chars
    โ”œโ”€โ”€ Always create: parent_section chunk  (full section text โ€” used by hierarchical retrieval)
    โ”‚
    โ””โ”€โ”€ Also create fragment children:
        โ”‚
        โ”œโ”€โ”€ Tables  (StructurePreserver)
        โ”‚   โ”œโ”€โ”€ Table โ‰ค 3,000 chars โ†’ single atomic table_fragment chunk
        โ”‚   โ””โ”€โ”€ Table > 3,000 chars โ†’ row-by-row table_fragment chunks
        โ”‚         โ””โ”€โ”€ Header Duplication: every fragment = header_row + separator_row + data_rows
        โ”‚             (a row of "| 0.75 | 0.83 |" is meaningless without its column names)
        โ”‚
        โ””โ”€โ”€ Remaining text โ†’ RecursiveCharacterTextSplitter (size=1,200, overlap=200)
              โ””โ”€โ”€ section_fragment chunks, fragment_index = 0, 1, 2 ...
                    โ””โ”€โ”€ Sequential fragment_index enables neighbor retrieval later

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.

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.

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.

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.


Retrieval Strategies

Five strategies are implemented, each building on the previous:

Strategy Approach Score Key Design
BM25 Lexical (Okapi BM25) Raw, 0โ€“โˆž In-memory; no vector store dependency
Dense Semantic (nomic-embed-text) Cosine similarity, 0โ€“1 Retrieves kร—3 before filtering bad sections
Hybrid Dense + BM25 fusion Fused, 0โ€“1 Min-max normalization per retriever, then 50/50 weighted sum
Hierarchical Hybrid โ†’ parent section context Fused, 0โ€“1 Pre-built (doc_id, section_id) โ†’ Chunk dict for O(1) parent lookup
Hier+Neighbor Hierarchical + fragment window โ€” Expands to ยฑ1 fragment siblings using fragment_index (section-local, never crosses boundaries)

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.

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.

Candidate over-retrieval โ€” All retrievers retrieve more candidates than k before filtering. Reference sections, bibliographies, and indices are excluded via an injectable RetrievalConfig. Over-retrieval guarantees k clean results remain after filtering.


Reranking โ€” an Optional Pipeline Stage, Not a 6th Strategy

Retriever โ†’ Reranker โ†’ LLM. Reranking isn't a peer alternative to BM25/Dense/Hybrid/etc. โ€” it's a stage that wraps any of them: over-retrieve a candidate pool, score every candidate against the query with a cross-encoder (which sees the query and document together, unlike embedding-based retrieval which scores them independently), and keep only the reordered top-k. RerankedRetriever (raglens/retrieval/reranker/) composes with an existing retriever exactly the way HybridRetriever composes Dense+BM25:

from raglens.retrieval import RerankedRetriever, get_reranker_provider

reranker = get_reranker_provider("cross_encoder")  # BAAI/bge-reranker-base, local, free
reranked_hybrid = RerankedRetriever(retrievers["hybrid"], reranker, candidate_k=25)
results = reranked_hybrid.retrieve("your query", k=5)

It's opt-in, not part of the default retriever set raglens benchmark/evaluate build โ€” reranking adds real inference cost and a new dependency (sentence-transformers, in the [full] extra), so it shouldn't silently attach itself to every existing run.

Why a Protocol, not a subclass of BaseRetriever. A reranker is a provider (an interchangeable scoring backend), matching get_embedding_provider/get_llm_provider's existing pattern, not a retriever. RerankerProvider.rerank(query, documents) -> list[(index, score)] stays domain-agnostic (raw strings in, ranked indices out) โ€” it never sees RetrievalResult/Chunk; RerankedRetriever is the one place that maps back to domain objects, the same layering EmbeddingGenerator uses for Chunk โ†” vector. The (index, score) return shape (rather than a parallel list of scores) is deliberate too: it matches both a local cross-encoder's predict()-then-sort and how hosted rerank APIs (Cohere, Jina) already respond โ€” so adding one of those later via get_reranker_provider("cohere") is a new branch in raglens/retrieval/reranker/providers.py, not an interface redesign.


Question Generation Strategy

  • One Q&A per chunk โ€” avoids questions where multiple chunks give equivalent answers, which would make ground truth ambiguous.
  • 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.
  • Skip logic โ€” chunks with insufficient content (very short fragments, context-free table rows) return {"status": "skip"} and are excluded from the evaluation dataset.
  • 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.

Benchmark Results

Results from experiments/experiments/data/benchmark_results.json, measured across 627 evaluation samples.

Numbers

Retriever Type Hit@1 Hit@3 Hit@5 Hit@10 MRR@5 NDCG@5 Section Hit@5 Latency@5
BM25 Flat 75.6% 87.0% 90.2% 92.9% 81.3% 83.5% โ€” 2.1 ms
Dense Flat 51.9% 67.2% 73.2% 79.3% 60.4% 63.6% โ€” 13.9 ms
Hybrid Flat 73.0% 87.8% 91.5% 93.9% 80.6% 83.3% โ€” 19.3 ms
Hierarchical Hierarchical โ€” 82.2% 83.4% 85.1% 78.7% โ€” 95.0% 24.4 ms
Hier+Neighbor Hier+Context โ€” 87.8% 89.4% 91.3% โ€” โ€” โ€” 23.0 ms

Benchmark Results Table

Benchmark Chart โ€” Hit@K, MRR, Hierarchical Breakdown, Latency

Are These Numbers Good? โ€” An Objective Assessment

What the numbers show:

  • Hybrid at 91.5% Hit@5 means 9 out of 10 queries find the right chunk in the top 5 results.
  • Hierarchical at 95.0% Section Hit@5 means the correct document section is retrieved almost every time.
  • Hier+Neighbor at 89.4% vs Hierarchical at 83.4% (+6%) confirms that expanding the context window to neighboring fragments meaningfully recovers missed chunks.

Why these numbers are optimistic โ€” three honest caveats:

1. BM25 has a structural advantage in this benchmark. 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.

2. Dense retrieval underperforms its potential here. 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.

3. Single ground truth per question underestimates retrieval quality. 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.

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 gives 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.


RAGAS End-to-End Evaluation โ€” In Progress

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.

The Four Metrics Actually Used

Metric Question it answers
Faithfulness Does the generated answer stay within what the retrieved context actually says? Detects hallucination.
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.)
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.
Context Recall Does the retrieved context contain all the information needed to construct the correct answer?

Judge LLM: gpt-4o-mini by default (configurable โ€” raglens evaluate --judge-provider ollama|openai|groq).

How It Runs

  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.
  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.
  3. Aggregate (raglens report) โ€” mean scores per retriever โ†’ comparison table + chart.

Status

Retriever Answers generated RAGAS scored
BM25 โœ… 150/150 โœ… 150/150
Dense โœ… 150/150 โœ… 150/150
Hybrid โœ… 150/150 (622/622 full set) โณ 0/150
Hierarchical โœ… 150/150 โณ 0/150
Hier+Neighbor โœ… 150/150 โณ 0/150

Resume scoring with raglens evaluate โ€” it picks up on hybrid next automatically. At an observed ~1-8 minutes per judge-LLM call, the CLI prints an upfront time estimate before committing to a run (450 samples remaining โ‰ˆ 15-60 hours, hence the resumable/checkpointed design rather than a single long-lived process).

The Key Insight RAGAS Unlocks

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.


From Research Pipeline to Open-Source Tool

Everything above was built to answer a research question. The pipeline that answers it, though, was originally just notebook cells (experiments/pipeline_validation.ipynb) โ€” great for exploration, useless to anyone who isn't the original author. This section is about the second phase of the project: turning that notebook into something a stranger can pip install and point at their own PDFs.

Provider abstraction. Every LLM/embedding call used to be a hardcoded OpenAI() client instantiated inline. raglens/embedding/providers.py and raglens/llm/providers.py now expose get_embedding_provider("ollama"|"openai") and get_llm_provider("ollama"|"openai"|"groq") factories; every class that needs a model takes one as a constructor argument instead of building its own client. This is what makes the whole pipeline runnable for free (Ollama, local) or with better quality (OpenAI/Groq) with a single CLI flag.

The CLI (raglens/cli.py, built with Typer) โ€” ingest, index, questions, benchmark, evaluate, report โ€” is the notebook's cell-by-cell logic extracted into RagLensPipeline (raglens/pipeline.py) and driven from the command line. The expensive commands (questions, evaluate) inherit the same resumable-cache discipline the research pipeline already needed for multi-hour LLM calls, so interrupting a raglens evaluate run overnight and resuming it the next day is a first-class, tested behavior โ€” not an afterthought.

Dependency-extras split. pip install raglens-toolkit alone installs a lean set (pandas, matplotlib, ragas, openai, groq, streamlit, typer) โ€” enough for the dashboard and raglens report. Docling, ChromaDB, and Ollama's client (all needed only for ingest/index/benchmark/evaluate) live behind a [full] extra. This exists because a public deployment (PyPI, Hugging Face Spaces) shouldn't force a multi-GB install of a PDF-parsing/ML stack just to render a chart โ€” and because raglens/cli.py lazily imports its heavy dependencies per-command rather than at module load, raglens report and raglens --help work correctly on the lean install alone. A dedicated CI job (lean-install in .github/workflows/ci.yml) asserts this stays true.

Tests and CI. 24 pytest tests cover the pure-logic surfaces that don't need a live LLM or vector store: retrieval metrics (hit_at_k, reciprocal_rank, ndcg_at_k), the injectable RetrievalConfig, stratified sampling, and RagasScorer's resume/aggregate logic (verified directly against the real, live ragas_scores.jsonl cache, not a mock). GitHub Actions runs the full suite plus lint on every push, in two jobs โ€” one with [dev,full] for correctness, one with zero extras to guard the lean-install promise above.

Distribution. The package is published on PyPI as raglens-toolkit (the plain raglens name was already taken by an unrelated package โ€” the import path is unaffected, only the install name differs). The dashboard is deployed to Hugging Face Spaces at huggingface.co/spaces/Arun-56/raglens-demo via Docker (see docs/deploy-hf-space.md), with a demo-mode fallback (dashboard/app.py's _resolve()) that serves this project's own bundled reference results (dashboard/demo_data/) whenever no local corpus or API keys are present โ€” so the public demo needs neither.


Bugs Found and Fixed

Concrete, specific things that broke and how they were diagnosed โ€” the kind of detail that's easy to forget by the time an interview asks "tell me about a bug you fixed."

Bug Root cause Fix
RAGAS entirely unimportable ragas==0.4.3 unconditionally imports langchain_community.chat_models.vertexai, a module removed in langchain-community==0.4.2 โ€” an upstream incompatibility, not project code Pinned langchain-community>=0.4.1,<0.4.2 in pyproject.toml; this would have silently blocked the very next attempt to resume RAGAS scoring had it gone unnoticed
DenseRetriever crashed on embed_query The retriever called .embed_query() on whatever embedding object it was handed, but the original OllamaEmbeddingGenerator wrapper only implemented .generate() โ€” callers worked around it by reaching into a private .model attribute The new EmbeddingGenerator + get_embedding_provider() design returns a single object that implements both embed_documents (indexing) and embed_query (retrieval) correctly, closing the gap instead of working around it
Duplicate similarity_search method ChromaStore had the same method defined twice; a first fix attempt accidentally kept both copies (just moved which one had a comment) Caught properly on a second pass via ruff (F811: redefinition of unused name) โ€” a good example of why lint in CI catches what manual review misses
Dead WeightedRetriever An empty, 0-byte stub file with an __init__.py that imported a class that was never implemented โ€” looked at first like a missing export, was actually never-finished code with zero references anywhere Deleted outright rather than "fixed"; confirmed via repo-wide grep that nothing referenced it
Dashboard crashed on Hugging Face Spaces plot_benchmark_chart/plot_ragas_scores unconditionally called plt.savefig() to a hardcoded path (experiments/data/...) that only exists in the full dev repo โ€” the slim Docker image only ships raglens/, dashboard/, and bundled demo data Found live, in production, by actually opening the deployed Space (not caught by local testing, which always had the full repo present) โ€” save_path now accepts None to skip the disk write; the dashboard only ever needed the returned Figure for st.pyplot()
HF Space deploy rejected by a plain git push This repo's git history has binary files (old chroma_db/ artifacts) committed early on, before .gitignore excluded them โ€” Hugging Face's pre-receive hook blocks any binary content not stored via their LFS/Xet system, and git push sends full history, not just the current tree Deploy via a single orphan commit containing only the files the Docker build needs (Dockerfile, raglens/, dashboard/, pyproject.toml, README.md), force-pushed to the Space's main โ€” sidesteps the history entirely rather than trying to scrub it

Engineering Decisions Worth Discussing

A dense, scannable list for interview prep โ€” each is a decision that had a real alternative, not just "the obvious way to do it."

  • Why min-max normalize before fusing BM25 + Dense, not z-score or raw sum? BM25 is unbounded; min-max guarantees both signals land in [0,1] before the 50/50 weighted sum, so neither can silently dominate regardless of corpus size. See Retrieval Strategies.
  • Why is every cache keyed by (chunk_id, retriever) and append-only JSONL, not a database? A crash mid-run (or an intentional Ctrl-C after a few hours) must lose zero completed work. Append-only JSONL means a completed record is durable the instant it's written; a real database would need transactions to get the same guarantee for no real benefit at this scale.
  • Why swap RAGAS's AnswerRelevancy for FactualCorrectness? Every question already has a human-verifiable reference answer (traced back to its source chunk) โ€” FactualCorrectness directly compares generated vs. reference answer, which is strictly more informative here than relevancy-to-question alone.
  • Why a custom FastContextPrecision metric? RAGAS's default ContextPrecision scores each retrieved context sequentially; wrapping the same prompt/logic in asyncio.gather parallelizes it, cutting real wall-clock time on a metric that's otherwise O(k) LLM calls per sample.
  • Why lazy-import Docling/ChromaDB inside CLI command functions instead of at module top? A module-level from raglens.pipeline import RagLensPipeline at the top of cli.py would force every command โ€” including raglens report, which never touches the ingest pipeline โ€” to pay Docling's (multi-GB, transformers/torch-pulling) import cost. Moving the import inside each command function keeps pip install raglens-toolkit (no extras) actually lean, and it's covered by a dedicated CI job rather than just claimed in a docstring.
  • Why does raglens evaluate print a time estimate before starting? Judge-LLM latency for RAGAS scoring is inherently variable (rate limits, time of day) and can span hours to days for a few hundred samples โ€” not something to silently discover after committing to a run. The estimate is deliberately a range (best/worst observed), not a false-precision single number.
  • Why deploy via an orphan git commit instead of cleaning the repo's history? Rewriting history (git filter-repo, BFG) to remove old binary blobs would rewrite every commit hash on a repo with existing PRs/branches/tags โ€” high blast radius for a problem that a single clean orphan commit solves without touching anything else.

raglens Folder Stories

Every folder in raglens/ has a specific responsibility. Here is the story of each one.

models/ โ€” The Schema Layer

Pure dataclasses with no behavior. Defines the full data hierarchy:

Document
โ””โ”€โ”€ Section (nested tree, preserves heading hierarchy)
    โ””โ”€โ”€ FlattenedSection (tree โ†’ flat list, path pre-computed)
        โ””โ”€โ”€ Chunk (atomic unit for embedding + retrieval)
            โ””โ”€โ”€ ChunkEmbedding (Chunk + its dense vector)

Three result types exist because three retrieval paradigms return different shapes of data:

  • RetrievalResult โ€” flat retrievers (Dense, BM25, Hybrid): chunk + score + retriever_name
  • HierarchicalRetrievalResult โ€” Hierarchical retriever: parent_chunk + child_chunk + score
  • NeighborRetrievalResult โ€” NeighborRetriever: center_chunk + neighbor_chunks + score

Everything between pipeline stages is typed. No raw dicts are passed between components.


parsers/ โ€” The Document Understanding Layer

Four classes form a sequential pipeline:

  1. DoclingParser โ€” converts a PDF file to a Markdown string using Docling with formula enrichment enabled. This preserves heading levels as #/## and LaTeX as $$...$$.
  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.
  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.
  4. HierarchyBuilder โ€” validates and rebuilds parent-child relationships from section levels; corrects any inconsistencies introduced by the parser.

Together they transform raw PDF bytes into a typed, hierarchical Document object.


normalization/ โ€” The Bridge Layer

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.

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.


chunking/ โ€” The Fragmentation Layer

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.

The key output fields on every Chunk:

  • chunk_type โ€” parent_section, table_fragment, or section_fragment
  • fragment_index โ€” -1 for atomic parent sections; 0, 1, 2โ€ฆ for ordered fragments
  • parent_section_id โ€” links every fragment back to its originating section

These three fields are what make hierarchical and neighbor retrieval possible downstream.


embedding/ โ€” The Vectorization Layer

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.


llm/ โ€” The Generation Layer

Mirrors embedding/ for text generation. get_llm_provider("ollama" | "openai" | "groq") returns an object implementing a single .generate(prompt) -> str method; QuestionGenerator and AnswerGenerator take one as a constructor argument instead of instantiating their own client. The RAGAS judge LLM has its own, separate factory (raglens/ragas/judge.py) since it must satisfy ragas's own LLM wrapper interface, not this simpler one.


vectorstore/ โ€” The Index Layer

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 โ€” ChromaStore.get_all_chunks() uses exactly this to let the CLI rebuild retrievers from an existing index without re-parsing or re-embedding.


config/ โ€” The Settings Layer

RetrievalConfig is an injectable dataclass (constructor-passed to BM25Retriever/DenseRetriever, defaulting to a shared DEFAULT_CONFIG instance) with three fields:

  • bad_sections โ€” section titles to exclude: references, bibliography, contents, index, list of figures, list of tables
  • exclude_reference_sections โ€” boolean flag
  • is_bad_section(title) โ€” normalizes title to lowercase alphanumeric, checks membership

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.


retrieval/ โ€” The Strategy Layer

Five strategies arranged by composition depth:

BM25Retriever          โ† in-memory lexical matching, no vector store
DenseRetriever         โ† semantic matching via embedding + ChromaDB
HybridRetriever        โ† composes Dense + BM25, normalizes + fuses scores
HierarchicalRetriever  โ† wraps Hybrid, promotes results to parent section context
NeighborHierarchicalRetriever  โ† wraps Hierarchical, expands to sibling fragments

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.


evaluation/ โ€” The Metrics Layer

retrieval_metrics.py contains three pure functions: hit_at_k, reciprocal_rank, ndcg_at_k. No state, no side effects.

Three evaluator classes exist because each retriever returns a different result type:

Evaluator Used with Result type Metrics
RetrievalEvaluator Dense, BM25, Hybrid List[RetrievalResult] Hit@K, MRR, NDCG@K, Latency
HierarchicalRetrievalEvaluator Hierarchical List[HierarchicalRetrievalResult] Child Hit@K, Section Hit@K, Sect+Child, MRR, Latency
NeighborHierarchicalRetrievalEvaluator NeighborHierarchical List[Chunk] Child Hit@K, Latency

benchmark_runner.py orchestrates all three evaluators across all five retrievers and every k value in one call; benchmark_visualizer.py renders the comparison table and 4-panel chart shown above.


question_generation/ โ€” The Dataset Layer

Turns List[Chunk] into 627 evaluation Q&A pairs with full ground truth traceability.

  • QuestionGenerator โ€” wraps an injectable LLMProvider (Ollama/OpenAI/Groq, defaults to OpenAI); generates one Q&A per chunk with engineered prompts
  • QuestionDatasetBuilder โ€” orchestrates generation with resumability via get_completed_chunk_ids()
  • question_cache.py โ€” JSONL persistence; append-only so the file survives mid-run interruptions
  • QuestionDatasetLoader โ€” filters the cache to status=success records and returns List[QuestionSample]

ragas/ โ€” The End-to-End Evaluation Layer

The productized version of the notebook's RAGAS cells:

  • EvaluationDatasetBuilder + AnswerGenerator + AnswerDatasetBuilder โ€” retrieve contexts and generate answers per (retriever, question) pair, cached and resumable
  • RagasScorer โ€” the batched, checkpointed scoring loop described in RAGAS End-to-End Evaluation; also exposes .status() and .aggregate() for reporting without triggering any new scoring
  • judge.py โ€” builds the ragas-compatible judge LLM per provider; sampling.py โ€” the stratified-by-source-document sampling used to pick the 150-question evaluation subset
  • ragas_visualizer.py โ€” the per-metric bar chart shown by the dashboard

cache/ โ€” The Persistence Layer

Three different caching concerns, one pattern (append-only where it matters):

  • 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.
  • Pickle (chunk_cache.py) โ€” stores the full List[Chunk] after chunking, so the CLI's later stages (index, benchmark, evaluate) can skip re-parsing entirely if a corpus has already been ingested.
  • JSONL (question_cache.py, answer_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.

preprocessing/ โ€” The Cleaning Layer

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.


validation/ โ€” The Audit Layer

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.


pipeline.py and cli.py โ€” The Orchestration Layer

RagLensPipeline is the notebook's cell-by-cell logic (ingest โ†’ index โ†’ build retrievers โ†’ generate questions โ†’ benchmark โ†’ RAGAS-evaluate) as callable, testable methods. cli.py wraps each stage as a Typer command, lazily importing heavy dependencies per-command (see From Research Pipeline to Open-Source Tool) so the package stays lean when the full pipeline isn't needed.


Project Structure

rag-evaluation-framework/
โ”‚
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ documents/               # Your PDF corpus (BYO โ€” 13 curated GenAI/RAG PDFs used for the reference run)
โ”‚
โ”œโ”€โ”€ experiments/
โ”‚   โ”œโ”€โ”€ pipeline_validation.ipynb   # Reference notebook โ€” all stages run cell-by-cell
โ”‚   โ””โ”€โ”€ experiments/data/
โ”‚       โ”œโ”€โ”€ benchmark_results.json         # Saved benchmark numbers
โ”‚       โ”œโ”€โ”€ Retrieval_Benchmark_Reults.png # Pandas styled results table
โ”‚       โ””โ”€โ”€ benchmark_chart.png            # 4-panel comparison chart
โ”‚
โ”œโ”€โ”€ raglens/                      # Core library (pip-installable as raglens-toolkit)
โ”‚   โ”œโ”€โ”€ models/                  # Dataclasses: Document, Section, Chunk, results
โ”‚   โ”œโ”€โ”€ parsers/                 # Docling โ†’ Markdown โ†’ Section tree
โ”‚   โ”œโ”€โ”€ normalization/           # Section tree โ†’ flat FlattenedSection list
โ”‚   โ”œโ”€โ”€ chunking/                # SectionChunker + StructurePreserver
โ”‚   โ”œโ”€โ”€ embedding/               # Ollama / OpenAI embedding providers + EmbeddingGenerator
โ”‚   โ”œโ”€โ”€ llm/                     # Ollama / OpenAI / Groq text-generation providers
โ”‚   โ”œโ”€โ”€ vectorstore/             # ChromaDB wrapper
โ”‚   โ”œโ”€โ”€ config/                  # Injectable RetrievalConfig (bad sections, top-k)
โ”‚   โ”œโ”€โ”€ retrieval/               # BM25, Dense, Hybrid, Hierarchical, Neighbor
โ”‚   โ”œโ”€โ”€ evaluation/              # Hit@K, MRR, NDCG, benchmark runner + charts
โ”‚   โ”œโ”€โ”€ question_generation/     # Q&A generation + JSONL cache
โ”‚   โ”œโ”€โ”€ ragas/                   # RagasScorer, judge factory, sampling, dataset builders
โ”‚   โ”œโ”€โ”€ cache/                   # Resumable caches (parsed docs, chunks, answers)
โ”‚   โ”œโ”€โ”€ preprocessing/           # Formula cleaner
โ”‚   โ”œโ”€โ”€ validation/              # Chunk auditor
โ”‚   โ”œโ”€โ”€ pipeline.py              # RagLensPipeline โ€” orchestrates every stage
โ”‚   โ””โ”€โ”€ cli.py                   # `raglens` command (ingest/index/questions/benchmark/evaluate/report)
โ”‚
โ”œโ”€โ”€ dashboard/
โ”‚   โ”œโ”€โ”€ app.py                   # Streamlit dashboard โ€” visualizes CLI-produced results
โ”‚   โ””โ”€โ”€ demo_data/                # Bundled reference results for the public HF Spaces demo
โ”‚
โ”œโ”€โ”€ tests/                       # pytest โ€” pure-logic unit tests (metrics, config, sampling, scorer)
โ”‚
โ”œโ”€โ”€ docs/
โ”‚   โ””โ”€โ”€ deploy-hf-space.md       # Hugging Face Spaces deployment guide
โ”‚
โ”œโ”€โ”€ .github/workflows/ci.yml     # Test + lint + lean-install CI, on every push
โ”œโ”€โ”€ Dockerfile                    # Hugging Face Spaces (Docker SDK) build
โ”œโ”€โ”€ pyproject.toml                # base / [full] / [dev] dependency extras
โ”œโ”€โ”€ LICENSE                       # MIT
โ”œโ”€โ”€ CONTRIBUTING.md
โ”œโ”€โ”€ CHANGELOG.md
โ”‚
โ”œโ”€โ”€ src/                          # V1 (legacy, kept for historical reference โ€” see "Why We Rebuilt")
โ”‚
โ””โ”€โ”€ artifacts/
    โ””โ”€โ”€ processtillnow.png       # V1 architecture diagram

Tech Stack

Component Tool Why
PDF Parsing Docling Formula-aware; preserves heading hierarchy from complex PDFs
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
Embedding Ollama (nomic-embed-text) or OpenAI Local, deterministic, no cloud dependency โ€” or OpenAI when you want it
Vector Store ChromaDB Lightweight, local persistence, no infrastructure required
Lexical Retrieval rank-bm25 (Okapi BM25) Fast in-memory BM25, no external service
Reranking (optional) sentence-transformers CrossEncoder (BAAI/bge-reranker-base) Local, free; wraps any retriever via RerankedRetriever
Text Splitting LangChain RecursiveCharacterTextSplitter Used only for text fragments within large sections
Evaluation Framework RAGAS Standard RAG evaluation library
CLI Typer raglens command: ingest/index/questions/benchmark/evaluate/report
Dashboard Streamlit Local + hosted (Hugging Face Spaces) visualization
Packaging setuptools, PyPI pip install raglens-toolkit, base/[full]/[dev] extras
CI GitHub Actions Test + lint + lean-install regression guard on every push
Deployment Docker, Hugging Face Spaces Live demo, no API keys needed
Language Python 3.12 โ€”

Version 1 Notes (Historical Context)

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.

  • Data source: ~20 Wikipedia articles on ML/AI topics (Transformers, LLMs, Embeddings, Vector Databases, etc.) fetched via the Wikipedia API
  • Tech stack: LangChain + ChromaDB + nomic-embed-text (same embedding model) + Qwen3:8B
  • Chunking: RecursiveCharacterTextSplitter at chunk_size=1000, overlap=200
  • Retrieval evaluated: Vector similarity search and BM25 (LangChain abstractions)
  • Evaluation metrics: Exact Chunk Recall, Semantic Similarity (cosine ~0.63 average), LLM-as-a-Judge (~0.74 average)
  • Key finding: Exact chunk recall severely underestimated retrieval quality โ€” relevant answers came from chunks other than the "expected" source chunk
  • Key finding: LLM-as-a-Judge scores correlated better with answer quality than embedding cosine similarity
  • Identified limitations: No hybrid retrieval, no hallucination detection, Wikipedia noise, naive chunking, no section awareness
  • Outcome: All identified limitations became the design requirements for V2
  • Reference links: Hallucination Detection Guide ยท LLM Eval Projects Guide

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

raglens_toolkit-0.2.0.tar.gz (94.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

raglens_toolkit-0.2.0-py3-none-any.whl (77.6 kB view details)

Uploaded Python 3

File details

Details for the file raglens_toolkit-0.2.0.tar.gz.

File metadata

  • Download URL: raglens_toolkit-0.2.0.tar.gz
  • Upload date:
  • Size: 94.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for raglens_toolkit-0.2.0.tar.gz
Algorithm Hash digest
SHA256 e12dbad6e6ae1d1cf350c42d86a6116c462011b5c6742655577b6dcdb4a0c47f
MD5 bbd5527570bc915b9ce9baebe5321779
BLAKE2b-256 eed9ac955b1601c416e88bccde14416e2dd9897b3e97f9d9f6cd0bb71f0eab09

See more details on using hashes here.

Provenance

The following attestation bundles were made for raglens_toolkit-0.2.0.tar.gz:

Publisher: publish.yml on officialarun/RAG-Eval-Frameworks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file raglens_toolkit-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for raglens_toolkit-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9a449d03f7d584f7046fa89085b8d1e4072cf220cdc58cf5022cb1a9b04022d1
MD5 3b057c46af5a66ebd4ac1ee308a7be34
BLAKE2b-256 0b84024c06be9e361f43367bf382886663b96b6ccf1a7b4aa68f581800cc3ffd

See more details on using hashes here.

Provenance

The following attestation bundles were made for raglens_toolkit-0.2.0-py3-none-any.whl:

Publisher: publish.yml on officialarun/RAG-Eval-Frameworks

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page