Spaces:
Running on Zero
Running on Zero
File size: 6,689 Bytes
465b477 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | """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
|