| """ |
| Chunking ablation: sweep chunk sizes [256, 512, 1024] and measure Recall@5 |
| using pure dense retrieval (no BM25, no reranker) to isolate chunking impact. |
| |
| Usage (from backend/): |
| python -m scripts.ablation |
| """ |
|
|
| import asyncio |
| import json |
| import sys |
| import tempfile |
| from pathlib import Path |
|
|
| import chromadb |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| from app.config import settings |
| from app.rag.chunker import chunk_pdf |
| from app.rag.embeddings import embed_texts |
|
|
| _FIXTURES = Path(__file__).parent.parent / "tests" / "fixtures" |
| _QUESTIONS_PATH = Path(__file__).parent.parent / "app" / "eval" / "questions.json" |
| _DOCS_PATH = Path(__file__).parent.parent / "docs" / "ablation.md" |
| _CHUNK_SIZES = [256, 512, 1024] |
| _TOP_K = 5 |
| _COLLECTION = "ablation" |
|
|
|
|
| def _override_chunk_size(size: int) -> None: |
| settings.chunk_size = size |
| settings.chunk_overlap = size // 8 |
|
|
|
|
| def _get_collection(client: chromadb.ClientAPI) -> chromadb.Collection: |
| try: |
| client.delete_collection(_COLLECTION) |
| except Exception: |
| pass |
| return client.create_collection(_COLLECTION, metadata={"hnsw:space": "cosine"}) |
|
|
|
|
| def _recall_hit(results: list[dict], expected_sources: list[dict], k: int) -> bool: |
| for chunk in results[:k]: |
| meta = chunk["metadata"] |
| for e in expected_sources: |
| if meta.get("filename") == e["filename"] and meta.get("page") == e["page"]: |
| return True |
| return False |
|
|
|
|
| async def _run_chunk_size(size: int, questions: list[dict]) -> dict: |
| _override_chunk_size(size) |
|
|
| pdfs = [_FIXTURES / "A survey on sentiment analysis.pdf"] |
| all_chunks = [] |
| for pdf in pdfs: |
| all_chunks.extend(chunk_pdf(pdf)) |
|
|
| texts = [c.text for c in all_chunks] |
| vectors = await embed_texts(texts, task="retrieval.passage") |
|
|
| with tempfile.TemporaryDirectory() as tmp: |
| client = chromadb.PersistentClient(path=tmp) |
| col = _get_collection(client) |
| col.upsert( |
| ids=[f"{c.filename}__{c.chunk_index}" for c in all_chunks], |
| embeddings=vectors, |
| documents=texts, |
| metadatas=[ |
| {"filename": c.filename, "page": c.page, "section": c.section} |
| for c in all_chunks |
| ], |
| ) |
|
|
| hits = 0 |
| for q in questions: |
| q_vec = await embed_texts([q["question"]], task="retrieval.query") |
| count = col.count() |
| results = col.query( |
| query_embeddings=[q_vec[0]], |
| n_results=min(_TOP_K, count), |
| include=["metadatas", "distances"], |
| ) |
| retrieved = [ |
| {"metadata": m, "score": 1.0 - d} |
| for m, d in zip(results["metadatas"][0], results["distances"][0]) |
| ] |
| if _recall_hit(retrieved, q["expected_sources"], _TOP_K): |
| hits += 1 |
|
|
| avg_len = sum(len(c.text) for c in all_chunks) / len(all_chunks) if all_chunks else 0 |
| return { |
| "chunk_size": size, |
| "chunk_overlap": size // 8, |
| "total_chunks": len(all_chunks), |
| "avg_chunk_len": round(avg_len), |
| "recall_at_5": hits / len(questions) if questions else 0.0, |
| } |
|
|
|
|
| def _write_markdown(rows: list[dict]) -> str: |
| lines = [ |
| "# Chunking Ablation Results", |
| "", |
| "Sweep over chunk sizes using pure dense retrieval (Jina v3) on a 30+ page research paper.", |
| "Recall@5 measures whether the expected source page appears in the top 5 retrieved chunks.", |
| "BM25 and Cohere reranker are excluded to isolate chunking impact.", |
| "", |
| "| Chunk size | Overlap | Total chunks | Avg chunk length | Recall@5 |", |
| "|---|---|---|---|---|", |
| ] |
| for r in rows: |
| recall_pct = f"{r['recall_at_5'] * 100:.0f}%" |
| lines.append( |
| f"| {r['chunk_size']} | {r['chunk_overlap']} " |
| f"| {r['total_chunks']} | {r['avg_chunk_len']} chars | {recall_pct} |" |
| ) |
|
|
| lines += [ |
| "", |
| "## Trade-offs", |
| "", |
| "- **Small chunks (256):** More granular retrieval, lower risk of including irrelevant text, " |
| "but key information may be split across chunk boundaries.", |
| "- **Medium chunks (512):** Balanced — enough context for embeddings to capture meaning " |
| "while keeping noise low. Default choice.", |
| "- **Large chunks (1024):** Each chunk carries more context, which helps for multi-sentence " |
| "answers, but embeddings become less discriminative and precision drops.", |
| ] |
| return "\n".join(lines) + "\n" |
|
|
|
|
| async def main() -> None: |
| questions = json.loads(_QUESTIONS_PATH.read_text(encoding="utf-8")) |
| print(f"Running ablation over chunk sizes {_CHUNK_SIZES} with {len(questions)} questions...\n") |
|
|
| rows = [] |
| for size in _CHUNK_SIZES: |
| print(f" chunk_size={size} ...", end=" ", flush=True) |
| result = await _run_chunk_size(size, questions) |
| rows.append(result) |
| print(f"Recall@5={result['recall_at_5'] * 100:.0f}% chunks={result['total_chunks']} avg_len={result['avg_chunk_len']}") |
|
|
| md = _write_markdown(rows) |
| _DOCS_PATH.parent.mkdir(parents=True, exist_ok=True) |
| _DOCS_PATH.write_text(md, encoding="utf-8") |
|
|
| print(f"\nResults written to {_DOCS_PATH.relative_to(Path(__file__).parent.parent.parent)}\n") |
| print(md) |
|
|
|
|
| if __name__ == "__main__": |
| asyncio.run(main()) |
|
|