mmap-worker / app /workers /chunking.py
jugalgajjar's picture
deploy: retrieval v2 + verifier tightening
25a5236
Raw
History Blame Contribute Delete
7.99 kB
"""Section-aware chunker with a recursive-character fallback.
Design:
1. Scan the source text for heading-like lines (all-caps headings, markdown
headings, our own `--- page N ---` / `--- slide N ---` markers from the
PDF and PowerPoint extractors, numbered sections like "1. Introduction").
2. Split at those boundaries — a section is a heading plus the body text
that follows it until the next heading.
3. For each section, produce chunks with the *heading prepended* so
retrieval sees "RESEARCH PUBLICATIONS\\nRSAT: ..." rather than an
orphaned bullet the reader can't place. When the section body is longer
than the target chunk size, split recursively on paragraph → sentence →
word → char boundaries (the old chunker's behaviour).
Tuned defaults: ~500 char chunks with 50 char overlap. Same story as before;
the outer API `chunk_text(text) -> list[Chunk]` is unchanged so callers keep
working.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
DEFAULT_CHUNK_SIZE = 500
DEFAULT_CHUNK_OVERLAP = 50
# Chunks with less alphanumeric content than this are dropped at ingest;
# they're typically OCR/PDF noise and rank highly on character-level overlap.
MIN_MEANINGFUL_ALNUM_CHARS = 80
# Order matters: try larger separators first.
_SEPARATORS: tuple[str, ...] = ("\n\n", "\n", ". ", " ", "")
# ---------------------------------------------------------------------------
# Heading detection
# ---------------------------------------------------------------------------
# Any match → line is treated as a heading.
_HEADING_PATTERNS: tuple[re.Pattern[str], ...] = (
# Markdown ATX headings ("#", "##", ..., "######").
re.compile(r"^#{1,6}\s+\S"),
# Our own extractor markers (PDF page markers, PPTX slide markers).
re.compile(r"^-{3}\s*(page|slide)\s+\d+\s*-{3}\s*$", re.IGNORECASE),
# Numbered sections like "1. Introduction", "2.1 Method". Short lines
# only so we don't accidentally match numbered bullet points.
re.compile(r"^\d+(\.\d+)*\.?\s+[A-Z][^\n]{0,79}$"),
)
# Heuristic: an all-caps line that looks like a heading. Length + alpha
# constraints keep us from matching "OK." or an entire capsed paragraph.
_ALL_CAPS_HEADING_RE = re.compile(
r"^[A-Z0-9][A-Z0-9\s\-&:,/()'.]{2,79}$",
)
def _is_heading(line: str) -> bool:
stripped = line.strip()
if not stripped or len(stripped) > 100:
return False
for pat in _HEADING_PATTERNS:
if pat.match(stripped):
return True
if _ALL_CAPS_HEADING_RE.match(stripped):
# Need at least 3 real letters; regex already forbids lowercase.
alpha = sum(1 for c in stripped if c.isalpha())
if alpha >= 3:
return True
return False
@dataclass(frozen=True)
class _Section:
heading: str # empty when no heading (leading body text before any heading)
content: str # body text under the heading (heading line not included)
source_start: int # offset of the section's first character in the source
source_end: int # exclusive end offset in the source
def _parse_sections(text: str) -> list[_Section]:
"""Split `text` into (heading, content) sections. When no headings are
detected the whole text becomes a single anonymous section."""
lines_with_offset: list[tuple[int, str]] = []
offset = 0
for raw in text.splitlines(keepends=True):
lines_with_offset.append((offset, raw))
offset += len(raw)
sections: list[_Section] = []
current_heading = ""
current_start = 0
body_parts: list[str] = []
def flush(end_offset: int) -> None:
content = "".join(body_parts).strip("\n")
if current_heading or content.strip():
sections.append(
_Section(
heading=current_heading,
content=content,
source_start=current_start,
source_end=end_offset,
)
)
started = False
for line_offset, line in lines_with_offset:
if _is_heading(line):
flush(line_offset)
current_heading = line.strip()
current_start = line_offset
body_parts = []
started = True
else:
if not started and not body_parts:
current_start = line_offset
started = True
body_parts.append(line)
flush(offset)
return sections
# ---------------------------------------------------------------------------
# Chunk model + public API
# ---------------------------------------------------------------------------
def is_meaningful(text: str) -> bool:
"""A chunk is meaningful if it has enough alphanumeric content to be
worth indexing. Cheap filter that drops fragments like 'rs' or 'rmat [1'."""
return sum(1 for c in text if c.isalnum()) >= MIN_MEANINGFUL_ALNUM_CHARS
@dataclass(frozen=True)
class Chunk:
index: int
text: str
char_start: int
char_end: int
def chunk_text(
text: str,
*,
chunk_size: int = DEFAULT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP,
) -> list[Chunk]:
"""Split `text` into chunks that respect section structure.
Each returned chunk carries the section's heading prepended (when one was
detected) so retrieval sees "PUBLICATIONS\\nRSAT: ..." instead of an
orphan bullet the model can't place. Long sections still get sub-split
recursively on paragraph → sentence → word → char boundaries.
`char_start` / `char_end` on the returned Chunks refer to offsets in the
**original** text (pointing at the section body; the heading prefix is
metadata prepended to `text`, not part of the source range).
"""
if chunk_overlap >= chunk_size:
raise ValueError("chunk_overlap must be smaller than chunk_size")
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
if not text:
return []
sections = _parse_sections(text)
chunks: list[Chunk] = []
idx = 0
for section in sections:
prefix = f"{section.heading}\n" if section.heading else ""
# Reserve room for the heading prefix inside the chunk size budget.
available = max(chunk_size - len(prefix), 100)
body_source_offset = section.source_start + (
len(section.heading) + 1 if section.heading else 0
)
for body_text, body_start, body_end in _split_body(
section.content, available, chunk_overlap
):
source_start = body_source_offset + body_start
source_end = source_start + (body_end - body_start)
chunks.append(
Chunk(
index=idx,
text=prefix + body_text,
char_start=source_start,
char_end=source_end,
)
)
idx += 1
return chunks
def _split_body(body: str, chunk_size: int, chunk_overlap: int) -> list[tuple[str, int, int]]:
"""Recursive-character splitter for the *body* of a section. Returns
(text, start, end) tuples where start/end are offsets inside `body`."""
if not body:
return []
out: list[tuple[str, int, int]] = []
start = 0
n = len(body)
while start < n:
end = min(start + chunk_size, n)
if end < n:
best = _find_best_break(body, start, end)
if best > start:
end = best
out.append((body[start:end], start, end))
if end >= n:
break
start = max(start + 1, end - chunk_overlap)
return out
def _find_best_break(text: str, start: int, end: int) -> int:
"""Look for the latest natural separator inside [start, end)."""
for sep in _SEPARATORS:
if not sep:
continue
cut = text.rfind(sep, start, end)
if cut > start:
return cut + len(sep)
return end