| """Article-aware chunking. |
| |
| Two decisions in this file do most of the work for retrieval quality. |
| |
| **Chunks never straddle an article.** An article is the unit a citation points at, so a |
| chunk that spans two of them cannot be cited honestly — half its text would support a |
| claim attributed to the wrong provision. Splitting starts from the article and works |
| down, never up. |
| |
| **Every chunk carries a context header.** The body of Dubai Law 26/2007 Article 6 begins |
| "Where the term of a Lease Contract expires, but the Tenant continues to occupy..." — it |
| never says which law it is in, and it never says "Article 6". A user asking "what does |
| Article 6 of the tenancy law say?" would therefore miss it on both retrieval paths at |
| once: the words are absent from the sparse index and from the dense vector. Prefixing |
| ``Tenancy Law - Article 6 (Term of Lease Contract)`` puts the exact terms a person |
| searches with into the text that is actually indexed. |
| |
| The splitter measures in the embedding model's own tokens (see ``app.core.tokenizer``) |
| and, in production, caps a chunk at the encoder's 512-token window. Beyond that window |
| ``bge-small-en-v1.5`` truncates its input, so the tail of an over-long chunk contributes |
| nothing to the vector while still being shown to the user as retrieved evidence. The |
| cap is a correctness property, not a tuning knob; ``eval/chunking_experiment.py`` |
| quantifies what it is worth. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import re |
| from dataclasses import dataclass |
| from typing import Final |
|
|
| from tokenizers import Tokenizer |
|
|
| from app.core.models import Article, Chunk, ParsedDocument |
| from app.core.settings import Settings, get_settings |
| from app.core.tokenizer import get_tokenizer |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| _CLAUSE_BOUNDARY: Final = re.compile( |
| r"(?=(?:^|\s)(?:\d{1,2}\.\s|[a-z]\.\s|\([a-z]\)\s|\(\d{1,2}\)\s))", |
| re.MULTILINE, |
| ) |
| _SENTENCE_BOUNDARY: Final = re.compile(r"(?<=[.;:])\s+") |
| _WORD_BOUNDARY: Final = re.compile(r"(?<=\s)") |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ChunkingStats: |
| """What the splitter actually produced — reported by ``make index``.""" |
|
|
| chunks: int |
| articles: int |
| tokens_total: int |
| tokens_max: int |
| tokens_mean: float |
| articles_split: int |
| over_window: int |
|
|
| def as_dict(self) -> dict[str, float | int]: |
| return { |
| "chunks": self.chunks, |
| "articles": self.articles, |
| "tokens_total": self.tokens_total, |
| "tokens_max": self.tokens_max, |
| "tokens_mean": round(self.tokens_mean, 1), |
| "articles_split": self.articles_split, |
| "over_window": self.over_window, |
| } |
|
|
|
|
| def build_header(article: Article) -> str: |
| """The context line prefixed to every chunk of ``article``.""" |
| header = f"{article.law_label} - Article {article.article_no}" |
| if article.title: |
| header = f"{header}: {article.title}" |
| elif article.section: |
| header = f"{header} ({article.section})" |
| return header |
|
|
|
|
| def _segments(text: str) -> list[str]: |
| """Break body text into the smallest units the packer is allowed to keep whole.""" |
| pieces = [piece for piece in _CLAUSE_BOUNDARY.split(text) if piece.strip()] |
| return pieces or [text] |
|
|
|
|
| def _split_oversized(segment: str, budget: int, tokenizer: Tokenizer) -> list[str]: |
| """Break a single segment that cannot fit the budget on its own. |
| |
| Falls back through sentence boundaries and finally whole words. Words are never |
| split: a half-word helps neither the encoder nor a human reading the Evidence Panel. |
| """ |
| for pattern in (_SENTENCE_BOUNDARY, _WORD_BOUNDARY): |
| parts = [p for p in pattern.split(segment) if p.strip()] |
| if len(parts) <= 1: |
| continue |
| out: list[str] = [] |
| buffer = "" |
| for part in parts: |
| candidate = f"{buffer}{part}" if buffer else part |
| if buffer and _count(candidate, tokenizer) > budget: |
| out.append(buffer.strip()) |
| buffer = part |
| else: |
| buffer = candidate |
| if buffer.strip(): |
| out.append(buffer.strip()) |
| if all(_count(piece, tokenizer) <= budget for piece in out): |
| return out |
| segment = " ".join(out) |
| return [segment] |
|
|
|
|
| def _count(text: str, tokenizer: Tokenizer) -> int: |
| return len(tokenizer.encode(text, add_special_tokens=False).ids) |
|
|
|
|
| def _overlap_segments(packed: list[str], overlap_tokens: int, tokenizer: Tokenizer) -> list[str]: |
| """Trailing whole segments of the previous chunk, up to the overlap budget. |
| |
| Overlap is taken at segment granularity rather than by slicing tokens, so a chunk |
| never opens mid-clause. That keeps every chunk independently readable, which matters |
| because chunks are shown verbatim in the Evidence Panel. |
| """ |
| if overlap_tokens <= 0: |
| return [] |
| carried: list[str] = [] |
| budget = overlap_tokens |
| for segment in reversed(packed): |
| cost = _count(segment, tokenizer) |
| if cost > budget: |
| break |
| carried.insert(0, segment) |
| budget -= cost |
| |
| return carried if len(carried) < len(packed) else carried[1:] |
|
|
|
|
| def split_article( |
| article: Article, |
| *, |
| target_tokens: int, |
| overlap_tokens: int, |
| tokenizer: Tokenizer, |
| ) -> list[str]: |
| """Split one article's body into chunk texts, each already carrying its header.""" |
| header = build_header(article) |
| header_cost = _count(header, tokenizer) + 1 |
| budget = max(target_tokens - header_cost, 16) |
|
|
| segments: list[str] = [] |
| for segment in _segments(article.text): |
| cleaned = segment.strip() |
| if not cleaned: |
| continue |
| if _count(cleaned, tokenizer) <= budget: |
| segments.append(cleaned) |
| else: |
| segments.extend(_split_oversized(cleaned, budget, tokenizer)) |
|
|
| if not segments: |
| return [header] |
|
|
| chunks: list[str] = [] |
| packed: list[str] = [] |
| packed_tokens = 0 |
| for segment in segments: |
| cost = _count(segment, tokenizer) |
| if packed and packed_tokens + cost > budget: |
| chunks.append(f"{header}\n{' '.join(packed)}") |
| packed = _overlap_segments(packed, overlap_tokens, tokenizer) |
| packed_tokens = sum(_count(piece, tokenizer) for piece in packed) |
| packed.append(segment) |
| packed_tokens += cost |
| if packed: |
| chunks.append(f"{header}\n{' '.join(packed)}") |
| return chunks |
|
|
|
|
| def chunk_documents( |
| documents: list[ParsedDocument], |
| settings: Settings | None = None, |
| *, |
| target_tokens: int | None = None, |
| overlap_tokens: int | None = None, |
| enforce_encoder_window: bool = True, |
| ) -> tuple[list[Chunk], ChunkingStats]: |
| """Turn parsed documents into indexable chunks. |
| |
| Args: |
| target_tokens: chunk size budget. Defaults to ``settings.chunk_target_tokens``. |
| overlap_tokens: tokens of context carried between adjacent chunks of one article. |
| enforce_encoder_window: cap the budget at the embedding model's context window. |
| Always true in production. ``eval/chunking_experiment.py`` sets it false to |
| measure what un-capped chunking costs, which is the whole point of the |
| experiment — the answer is not "nothing". |
| """ |
| cfg = settings or get_settings() |
| tokenizer = get_tokenizer(cfg.embedding_model) |
| requested = target_tokens if target_tokens is not None else cfg.chunk_target_tokens |
| budget = min(requested, cfg.embedding_max_tokens) if enforce_encoder_window else requested |
| overlap = overlap_tokens if overlap_tokens is not None else cfg.chunk_overlap_tokens |
| overlap = min(overlap, max(budget - 1, 0)) |
|
|
| if enforce_encoder_window and requested > cfg.embedding_max_tokens: |
| logger.info( |
| "chunk target %d exceeds the %s context window (%d); capping at %d so the " |
| "indexed text and the embedded text stay identical", |
| requested, |
| cfg.embedding_model, |
| cfg.embedding_max_tokens, |
| budget, |
| ) |
|
|
| chunks: list[Chunk] = [] |
| articles = 0 |
| articles_split = 0 |
| over_window = 0 |
| token_counts: list[int] = [] |
|
|
| for document in documents: |
| for article in document.articles: |
| articles += 1 |
| texts = split_article( |
| article, |
| target_tokens=budget, |
| overlap_tokens=overlap, |
| tokenizer=tokenizer, |
| ) |
| if len(texts) > 1: |
| articles_split += 1 |
| for seq, text in enumerate(texts): |
| tokens = _count(text, tokenizer) |
| token_counts.append(tokens) |
| if tokens > cfg.embedding_max_tokens: |
| over_window += 1 |
| chunks.append( |
| Chunk( |
| chunk_id=Chunk.make_id( |
| article.law_id, article.part_id, article.article_no, seq, text |
| ), |
| law_id=article.law_id, |
| law_label=article.law_label, |
| law_title=article.law_title, |
| part_id=article.part_id, |
| part_title=article.part_title, |
| article_no=article.article_no, |
| article_title=article.title, |
| section=article.section, |
| seq=seq, |
| seq_total=len(texts), |
| page_start=article.page_start, |
| page_end=article.page_end, |
| text=text, |
| token_count=max(tokens, 1), |
| ) |
| ) |
|
|
| if not chunks: |
| raise ValueError("chunking produced no chunks; the parsed corpus is empty") |
|
|
| stats = ChunkingStats( |
| chunks=len(chunks), |
| articles=articles, |
| tokens_total=sum(token_counts), |
| tokens_max=max(token_counts), |
| tokens_mean=sum(token_counts) / len(token_counts), |
| articles_split=articles_split, |
| over_window=over_window, |
| ) |
| if over_window and enforce_encoder_window: |
| raise ValueError( |
| f"{over_window} chunks exceed the encoder window despite the cap being " |
| "enforced; this is a bug in split_article, not a tuning problem" |
| ) |
| return chunks, stats |
|
|