ARslan-Ahamd's picture
Deploy from GitHub commit 9c1d82a
465b477 verified
Raw History Blame Contribute Delete
6.69 kB
"""Chunking with stable provenance IDs.
Paragraphs are packed greedily, in document order, up to ``target_tokens`` (hard cap
``max_tokens``). Section boundaries are preferred break points: a chunk ends at a
section boundary once it holds at least ``min_tokens``, so short adjacent sections can
share a chunk while long sections are split. A paragraph longer than the cap is split
on sentence boundaries. Each section start inside a chunk is marked by a heading line
(``§ Heading``) so the model sees the structure.
When a chunk starts in the middle of a section it is prefixed with up to
``overlap_tokens`` of trailing sentences from the previous chunk; that prefix is
recorded in ``overlap_chars`` and is not owned evidence.
``spans`` records exactly which (section, first paragraph, last paragraph) each chunk
owns. IDs look like ``doc_1912_01214_sec_04_chunk_01``: the section the chunk starts in
and a running index of chunks starting in that section.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
from pydantic import BaseModel
from .data.qasper import normalize_paragraph
from .schemas import Chunk, Document, Question
from .tokens import count_tokens
_SENT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\[(])")
class ChunkingConfig(BaseModel):
target_tokens: int = 550
max_tokens: int = 700
min_tokens: int = 300
overlap_tokens: int = 50
def split_sentences(text: str) -> list[str]:
return [s for s in _SENT.split(text) if s.strip()]
def _split_long(paragraph: str, max_tokens: int) -> list[str]:
pieces, cur = [], []
for sent in split_sentences(paragraph):
if cur and count_tokens(" ".join(cur + [sent])) > max_tokens:
pieces.append(" ".join(cur))
cur = [sent]
else:
cur.append(sent)
if cur:
pieces.append(" ".join(cur))
# A single sentence longer than max_tokens stays whole: we never cut inside a sentence.
return pieces
def _tail(text: str, overlap_tokens: int) -> str:
if overlap_tokens <= 0:
return ""
out: list[str] = []
for sent in reversed(split_sentences(text)):
if count_tokens(" ".join([sent] + out)) > overlap_tokens:
break
out.insert(0, sent)
return " ".join(out)
def heading_line(heading: str) -> str:
return f"§ {heading}"
def chunk_document(doc: Document, cfg: ChunkingConfig, id_prefix: str | None = None) -> list[Chunk]:
prefix = id_prefix or doc.slug
# unit = (section idx, paragraph idx, text, first unit of its section?)
units: list[tuple[int, int, str, bool]] = []
for sec in doc.sections:
first = True
for p_idx, para in enumerate(sec.paragraphs):
pieces = _split_long(para, cfg.max_tokens) if count_tokens(para) > cfg.max_tokens else [para]
for piece in pieces:
units.append((sec.idx, p_idx, piece, first))
first = False
headings = {s.idx: s.heading for s in doc.sections}
groups: list[list[tuple[int, int, str, bool]]] = []
cur: list[tuple[int, int, str, bool]] = []
cur_tokens = 0
for u in units:
t = count_tokens(u[2]) + (count_tokens(heading_line(headings[u[0]])) if u[3] else 0)
boundary_break = u[3] and cur_tokens >= cfg.min_tokens
if cur and (boundary_break or cur_tokens + t > cfg.target_tokens):
groups.append(cur)
cur, cur_tokens = [], 0
cur.append(u)
cur_tokens += t
if cur:
groups.append(cur)
chunks: list[Chunk] = []
per_section_count: dict[int, int] = {}
prev_body = ""
for group in groups:
start_sec = group[0][0]
lines: list[str] = []
for i, (s, _, text, first) in enumerate(group):
# Always show the heading at the top of a chunk, and at each new section inside it.
if first or i == 0:
lines.append(heading_line(headings[s]))
lines.append(text)
body = "\n\n".join(lines)
overlap = "" if group[0][3] else _tail(prev_body, cfg.overlap_tokens)
if overlap:
# The heading stays first; the carried-over sentences follow it.
head, rest = body.split("\n\n", 1)
text = f"{head}\n\n{overlap}\n\n{rest}"
else:
text = body
spans: list[tuple[int, int, int]] = []
for s, p, _, _ in group:
if spans and spans[-1][0] == s:
spans[-1] = (s, spans[-1][1], p)
else:
spans.append((s, p, p))
c_idx = per_section_count.get(start_sec, 0)
per_section_count[start_sec] = c_idx + 1
chunks.append(
Chunk(
chunk_id=f"{prefix}_sec_{start_sec:02d}_chunk_{c_idx:02d}",
doc_id=doc.doc_id,
section_idx=start_sec,
section_heading=headings[start_sec],
chunk_idx=c_idx,
text=text,
spans=spans,
overlap_chars=len(overlap) + 2 if overlap else 0,
n_tokens=count_tokens(text),
)
)
prev_body = "\n\n".join(u[2] for u in group)
return chunks
def gold_chunk_ids(question: Question, doc: Document, chunks: Iterable[Chunk]) -> tuple[list[set[str]], int, int]:
"""Map each annotator's evidence paragraphs to the chunks that own them.
Returns one set of chunk IDs per reference, plus (#evidence strings matched,
#evidence strings total) so coverage of the mapping can be reported.
"""
owner: dict[tuple[int, int], str] = {}
for c in chunks:
if c.is_distractor:
continue
for s, p0, p1 in c.spans:
for p in range(p0, p1 + 1):
owner.setdefault((s, p), c.chunk_id)
index: dict[str, tuple[int, int]] = {}
for sec in doc.sections:
for p_idx, para in enumerate(sec.paragraphs):
index.setdefault(normalize_paragraph(para), (sec.idx, p_idx))
per_ref: list[set[str]] = []
matched = total = 0
for ref in question.references:
ids: set[str] = set()
for ev in ref.evidence:
total += 1
key = normalize_paragraph(ev)
loc = index.get(key)
if loc is None:
# Evidence sometimes differs from full_text by truncation; accept containment of long strings.
loc = next((v for k, v in index.items() if min(len(k), len(key)) > 40 and (key in k or k in key)), None)
if loc is not None and loc in owner:
ids.add(owner[loc])
matched += 1
per_ref.append(ids)
return per_ref, matched, total