lexora / eval /chunking_experiment.py
Abdr007's picture
Lexora — deployed tree
3fc8e60
Raw
History Blame Contribute Delete
6.28 kB
#!/usr/bin/env python3
"""Re-index the corpus at several chunk sizes and measure what changes.
python eval/chunking_experiment.py
The interesting result is not "which number is biggest". It is that the encoder's context
window is a hard constraint the chunk size has to respect: ``bge-small-en-v1.5`` truncates
at 512 tokens, so a 600- or 1000-token chunk is *indexed* in full but *embedded* only up
to the cut. Its tail is invisible to dense retrieval while still being shown to the user
as retrieved evidence.
Each configuration is therefore run twice — capped at the window and uncapped — so the
cost of ignoring the constraint is a measured number rather than an assertion. Writes
``eval/results/chunking.json``, which the /metrics page renders.
The experiment builds its indexes in a scratch collection and restores the production
index at the end, so running it never leaves the service pointing at 300-token chunks.
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Any, Final
REPO_ROOT: Final = Path(__file__).resolve().parents[1]
# Both the repo root and the API package must be importable: these scripts import
# `eval.*` (sibling modules) and `app.*` (the service). Doing it here rather than
# relying on PYTHONPATH means the script runs correctly from any working directory
# and under any runner — a missing PYTHONPATH previously broke the CI eval step only.
for _path in (REPO_ROOT, REPO_ROOT / "apps" / "api"):
if str(_path) not in sys.path:
sys.path.insert(0, str(_path))
from app.core.embedding import embed_documents # noqa: E402
from app.core.models import Chunk # noqa: E402
from app.core.settings import get_settings # noqa: E402
from app.core.vectorstore import ( # noqa: E402
close_client,
recreate_collection,
upsert_chunks,
)
from app.rag.chunk import chunk_documents # noqa: E402
from app.rag.index import build_index # noqa: E402
from app.rag.parse import ParseDiagnostics, parse_corpus # noqa: E402
from app.rag.rerank import rerank # noqa: E402
from app.rag.retrieve import HybridRetriever # noqa: E402
from app.rag.sparse import BM25Index # noqa: E402
from eval.ragas_run import load_labels # noqa: E402
logger = logging.getLogger(__name__)
TARGETS: Final = (300, 600, 1000)
RESULTS: Final = REPO_ROOT / "eval" / "results" / "chunking.json"
def _part_of(part_id: str) -> str:
return "cabinet-resolution" if "cabinet" in part_id else "decree-law"
def evaluate(chunks: list[Chunk], top_k: int) -> dict[str, float]:
"""Index these chunks and measure hit-rate against the labelled set."""
settings = get_settings()
recreate_collection(settings)
upsert_chunks(chunks, embed_documents([c.text for c in chunks], settings), settings)
retriever = HybridRetriever(
settings=settings,
sparse=BM25Index.build(chunks),
chunks_by_id={chunk.chunk_id: chunk for chunk in chunks},
)
labels = [label for label in load_labels() if label.answerable]
hit1 = hit5 = 0
for label in labels:
candidates = list(retriever.retrieve(label.question).candidates)
ranked = rerank(label.question, candidates, settings)
for rank, item in enumerate(ranked[:top_k], start=1):
if label.matches(
item.chunk.law_id, item.chunk.article_no, _part_of(item.chunk.part_id)
):
hit5 += 1
if rank == 1:
hit1 += 1
break
total = len(labels) or 1
return {"hit_rate_at_1": hit1 / total, "hit_rate_at_5": hit5 / total}
def main() -> int:
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
settings = get_settings()
documents = parse_corpus(settings, ParseDiagnostics())
rows: list[dict[str, Any]] = []
try:
for target in TARGETS:
for capped in (True, False):
chunks, stats = chunk_documents(
documents,
settings,
target_tokens=target,
enforce_encoder_window=capped,
)
label = f"{target}{'' if capped else ' (uncapped)'}"
print(f" indexing {label}: {stats.chunks} chunks, max {stats.tokens_max} tokens…")
scores = evaluate(chunks, settings.rerank_top_k)
rows.append(
{
"target_tokens": target,
"encoder_window_enforced": capped,
"chunks": stats.chunks,
"tokens_mean": stats.tokens_mean,
"tokens_max": stats.tokens_max,
"articles_split": stats.articles_split,
"over_window": stats.over_window,
"hit_rate_at_1": round(scores["hit_rate_at_1"], 4),
"hit_rate_at_5": round(scores["hit_rate_at_5"], 4),
}
)
print(
f" hit@1 {scores['hit_rate_at_1']:.3f} "
f"hit@5 {scores['hit_rate_at_5']:.3f} "
f"over-window {stats.over_window}"
)
finally:
# Always restore the production index — a half-finished experiment must never
# leave the service serving 1000-token chunks.
print("\n restoring the production index…")
build_index(settings)
close_client()
RESULTS.parent.mkdir(parents=True, exist_ok=True)
RESULTS.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
print("\n" + "=" * 78)
print(
f" {'target':>8} {'capped':>7} {'chunks':>7} {'mean':>6} "
f"{'max':>5} {'over':>5} {'hit@1':>7} {'hit@5':>7}"
)
for row in rows:
print(
f" {row['target_tokens']:>8} {row['encoder_window_enforced']!s:>7} "
f"{row['chunks']:>7} {row['tokens_mean']:>6.0f} {row['tokens_max']:>5} "
f"{row['over_window']:>5} {row['hit_rate_at_1']:>7.3f} {row['hit_rate_at_5']:>7.3f}"
)
print(f"\n wrote {RESULTS.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())