lexora / apps /api /app /rag /chunk.py
Abdr007's picture
Lexora — deployed tree
3fc8e60
Raw
History Blame Contribute Delete
10.7 kB
"""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 boundaries in UAE/Dubai statutory drafting, most structural first. Splitting
# here keeps "a. ... b. ... c. ..." ladders intact instead of cutting mid-enumeration.
_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
# Never carry the entire previous chunk: that would stall forward progress.
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: # pragma: no cover - defensive
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