fix(llm): strip NUL/control chars from chunk text before pgvector insert
All checks were successful
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m29s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 55s
Infra CI / zotero (push) Successful in 17s
Infra CI / docs (push) Successful in 17s
Infra CI / api (push) Successful in 1m6s
Infra CI / llm (push) Successful in 47s
Infra CI / mc (push) Successful in 13s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 14m3s
Harden / build-scan-report (push) Successful in 26m9s
Renovate / renovate (push) Successful in 14s
Notebooks Integration / notebooks-integration (push) Successful in 7m21s
Zotero Sync / zotero-sync (push) Successful in 1m8s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m2s

Extracted PDF comment text carries NUL (0x00) and form-feed bytes that
Postgres text columns reject (DataError: cannot contain NUL bytes) — the
2017 pilot was clean by luck, the 2018+ dockets crash the indexer. Strip
C0 control chars (except tab/newline/return) in chunk_doc.
This commit is contained in:
kert
2026-07-19 08:32:56 -04:00
parent 1cbecb110f
commit 2e22045ed2
2 changed files with 21 additions and 1 deletions

View File

@@ -35,6 +35,10 @@ def content_hash(text: str) -> str:
_FRONTMATTER = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
_HEADING = re.compile(r"^#{1,6} ", re.MULTILINE)
# C0 control chars except tab/newline/carriage-return. Extracted PDF text
# carries NUL (0x00) and form-feeds that Postgres text columns reject
# ("cannot contain NUL bytes") and that add no semantic value to embeddings.
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
def _sections(text: str) -> list[str]:
@@ -90,7 +94,7 @@ def chunk_doc(
"""
if overlap_chars >= target_chars:
raise ValueError("overlap_chars must be smaller than target_chars")
body = _FRONTMATTER.sub("", doc.text).strip()
body = _CONTROL.sub("", _FRONTMATTER.sub("", doc.text)).strip()
if not body:
return []
prefix = f"{doc.key}:{content_hash(doc.text)[:12]}"

View File

@@ -61,3 +61,19 @@ class TestChunkDoc:
def test_overlap_gte_target_raises(self):
with pytest.raises(ValueError, match="overlap_chars must be smaller"):
chunk_doc(_doc("some text"), target_chars=100, overlap_chars=100)
class TestControlCharStripping:
def test_strips_nul_and_control_chars(self):
doc = _doc("Clean text\x00 with\x0c a NUL\x07 and formfeed.")
chunks = chunk_doc(doc)
joined = "".join(c.text for c in chunks)
assert "\x00" not in joined
assert "\x0c" not in joined
assert "\x07" not in joined
assert "Clean text with a NUL and formfeed." in joined
def test_keeps_tab_newline_return(self):
doc = _doc("line1\n\nline2\twith tab")
joined = "".join(c.text for c in chunk_doc(doc))
assert "\t" in joined and "line2" in joined