feat(llm): markdown-aware chunker with deterministic ids (refs #566)

This commit is contained in:
kert
2026-07-17 10:14:12 -04:00
parent f630a9349b
commit 80fd8f8014
2 changed files with 161 additions and 0 deletions

104
src/llm/chunk.py Normal file
View File

@@ -0,0 +1,104 @@
"""Markdown-aware chunking with deterministic ids.
Ids are ``{item_key}:{content_hash[:12]}:{seq:04d}`` — same input, same
ids — so pgvector upserts are idempotent and ``index_state`` can skip
unchanged items by comparing hashes.
Sizes are in characters (~4 chars/token; 2000 chars ≈ 500 tokens, inside
every candidate embed model's window).
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Doc:
key: str
text: str
metadata: dict[str, str]
@dataclass(frozen=True)
class Chunk:
id: str
text: str
metadata: dict[str, str]
def content_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
_FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
_HEADING = re.compile(r"^#{1,6} ", re.MULTILINE)
def _sections(text: str) -> list[str]:
"""Split at markdown headings, keeping the heading with its body."""
starts = [m.start() for m in _HEADING.finditer(text)]
if not starts:
return [text]
bounds = ([0] if starts[0] != 0 else []) + starts + [len(text)]
return [text[a:b] for a, b in zip(bounds, bounds[1:])]
def _hard_wrap(para: str, target: int, overlap: int) -> list[str]:
"""Slice an oversized paragraph into <= target windows, each window's
leading `overlap` chars equal to the previous window's trailing
`overlap` chars."""
n = len(para)
pieces: list[str] = []
start = 0
while True:
end = min(start + target, n)
pieces.append(para[start:end])
if end == n:
break
start = end - overlap
return pieces
def _pack(section: str, target: int, overlap: int) -> list[str]:
"""Greedily pack paragraphs; hard-wrap oversized ones with overlap."""
paras = [p for p in re.split(r"\n\n+", section) if p.strip()]
pieces: list[str] = []
buf = ""
for para in paras:
if buf and len(buf) + len(para) + 2 > target:
pieces.append(buf)
buf = ""
if len(para) > target:
pieces.extend(_hard_wrap(para, target, overlap))
continue
buf = f"{buf}\n\n{para}" if buf else para
if buf:
pieces.append(buf)
return pieces
def chunk_doc(
doc: Doc, *, target_chars: int = 2000, overlap_chars: int = 200
) -> list[Chunk]:
if overlap_chars >= target_chars:
raise ValueError("overlap_chars must be smaller than target_chars")
body = _FRONTMATTER.sub("", doc.text).strip()
if not body:
return []
prefix = f"{doc.key}:{content_hash(doc.text)[:12]}"
texts = [
piece
for section in _sections(body)
for piece in _pack(section, target_chars, overlap_chars)
]
return [
Chunk(
id=f"{prefix}:{seq:04d}",
text=piece,
metadata={**doc.metadata, "item_key": doc.key, "seq": str(seq)},
)
for seq, piece in enumerate(texts)
]

57
tests/llm/test_chunk.py Normal file
View File

@@ -0,0 +1,57 @@
"""llm.chunk — markdown-aware chunking with deterministic ids."""
from llm.chunk import Doc, chunk_doc, content_hash
FRONTMATTER_DOC = """---
comment_id: CMS-2019-0111-0042
docket_id: CMS-2019-0111
---
# Re: CY 2020 PFS Proposed Rule
We object to the E/M consolidation.
## Telehealth
Originating-site rules should be relaxed.
"""
def _doc(text, key="K1"):
return Doc(key=key, text=text, metadata={"docket": "CMS-2019-0111"})
class TestChunkDoc:
def test_strips_yaml_frontmatter(self):
chunks = chunk_doc(_doc(FRONTMATTER_DOC))
assert "comment_id:" not in chunks[0].text
assert chunks[0].text.startswith("# Re:")
def test_deterministic_ids(self):
a = chunk_doc(_doc(FRONTMATTER_DOC))
b = chunk_doc(_doc(FRONTMATTER_DOC))
assert [c.id for c in a] == [c.id for c in b]
h = content_hash(FRONTMATTER_DOC)
assert a[0].id == f"K1:{h[:12]}:0000"
def test_metadata_carries_item_key_and_seq(self):
chunks = chunk_doc(_doc(FRONTMATTER_DOC))
assert chunks[0].metadata["item_key"] == "K1"
assert chunks[0].metadata["docket"] == "CMS-2019-0111"
assert chunks[0].metadata["seq"] == "0"
def test_splits_on_headings_before_size(self):
text = "# A\n\n" + "para. " * 100 + "\n\n# B\n\nshort."
chunks = chunk_doc(_doc(text), target_chars=300)
assert all(len(c.text) <= 300 + 200 for c in chunks)
assert any(c.text.lstrip().startswith("# B") for c in chunks)
def test_long_paragraph_hard_wrapped_with_overlap(self):
text = "x" * 5000
chunks = chunk_doc(_doc(text), target_chars=2000, overlap_chars=200)
assert len(chunks) == 3
assert chunks[1].text[:200] == chunks[0].text[-200:]
def test_empty_and_whitespace_yield_nothing(self):
assert chunk_doc(_doc("")) == []
assert chunk_doc(_doc(" \n\n ")) == []