fix(embeddings): no chunk is a heading alone (#4784)
Judging live auto-inject menus (#4772) found "## Work log — 2026-08-21", "## Findings worth carrying forward" and "Two dev→main PRs this session." each embedded as a whole chunk. Text about nothing embeds close to everything, so they took ranks 1–3 on vague queries and pushed real records down. Three ways the chunker made them, each closed: - _split_paragraphs flushed a heading on its own when the paragraph under it was long. A thin lead is now carried into the paragraph that follows, and a hard cut never lands in the first quarter of the budget, where the newline it finds closes a heading. - A heading with no body (above its ### parts) became a section of its own when the section before it was full. A thin section now takes the next. - A short lead-in or a short last section stood alone. The lead-in joins what follows; a thin tail joins what precedes it. A continuation piece now repeats the LAST heading before it, since one section can now hold several. CHUNKER_VERSION 2 → 3, so the startup backfill re-embeds; tuned dials will report their shape as changed, which is true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,11 @@ untouched), and long records lose NOTHING — every line of the body lands in
|
||||
some chunk, each chunk inside the window budget, each carrying the title as
|
||||
its topical anchor.
|
||||
"""
|
||||
import re
|
||||
|
||||
from scribe.services.embeddings import (
|
||||
_CHUNK_CHAR_BUDGET,
|
||||
_MIN_CHUNK_CONTENT,
|
||||
chunk_document,
|
||||
embedding_text,
|
||||
)
|
||||
@@ -136,6 +139,87 @@ def test_a_monster_single_paragraph_is_hard_split_not_dropped():
|
||||
assert total_words == 2000
|
||||
|
||||
|
||||
# --- no chunk is a heading alone (#4784) -------------------------------------
|
||||
#
|
||||
# Found by judging live auto-inject menus: "## Work log — 2026-08-21",
|
||||
# "## Findings worth carrying forward" and "Two dev→main PRs this session."
|
||||
# were each a whole chunk, and took ranks 1–3 on vague queries — text about
|
||||
# nothing embeds close to everything. Each case below is one of those shapes.
|
||||
|
||||
_HEADING = re.compile(r"^#{1,6}\s")
|
||||
|
||||
|
||||
def _said(chunk: str, title: str) -> str:
|
||||
"""A chunk's text once its title prefix and heading lines are set aside."""
|
||||
body = chunk[len(title) + 1:] if chunk.startswith(title + "\n") else chunk
|
||||
return "\n".join(ln for ln in body.splitlines() if not _HEADING.match(ln)).strip()
|
||||
|
||||
|
||||
def _assert_nothing_thin_and_nothing_lost(title: str, body: str) -> list[str]:
|
||||
chunks = chunk_document(title, body)
|
||||
assert len(chunks) > 1, "the case must be long enough to split"
|
||||
for chunk in chunks:
|
||||
assert len(_said(chunk, title)) >= _MIN_CHUNK_CONTENT, (
|
||||
f"a chunk says almost nothing: {chunk[:120]!r}")
|
||||
assert len(chunk) <= _CHUNK_CHAR_BUDGET + len(title) + 1 + 80
|
||||
joined = "\n".join(chunks)
|
||||
for line in (ln.strip() for ln in body.splitlines()):
|
||||
# A line longer than a chunk is cut across two, so only its ends can
|
||||
# be looked for whole.
|
||||
for part in ([line] if len(line) < 400 else [line[:60], line[-60:]]):
|
||||
assert part in joined, f"content dropped: {part[:60]!r}"
|
||||
return chunks
|
||||
|
||||
|
||||
def test_a_heading_over_one_long_paragraph_is_not_a_chunk_of_its_own():
|
||||
"""A work log whose entry is one long paragraph: the split used to flush
|
||||
the heading as its own piece before cutting the paragraph."""
|
||||
entry = " ".join(["The deploy was verified against the live instance."] * 40)
|
||||
_assert_nothing_thin_and_nothing_lost(
|
||||
"Step 6 — webhook drift-flagging",
|
||||
"The step body.\n\n## Work log — 2026-08-16\n\n" + entry,
|
||||
)
|
||||
|
||||
|
||||
def test_a_heading_with_nothing_under_it_joins_what_follows():
|
||||
"""'## Findings' directly above its '### …' parts is a section with no
|
||||
body, and stood alone whenever the section before it was full."""
|
||||
body = (
|
||||
# Five paragraphs fill the section before it, so the heading cannot
|
||||
# merge backwards and has to be carried forward.
|
||||
"## Context\n\n" + _long_section("context", paragraphs=5)
|
||||
+ "\n\n## Findings worth carrying forward\n\n"
|
||||
+ "### One\n\n" + _long_section("one", paragraphs=5)
|
||||
+ "\n\n### Two\n\n" + _long_section("two", paragraphs=5)
|
||||
)
|
||||
_assert_nothing_thin_and_nothing_lost("A milestone dev-log", body)
|
||||
|
||||
|
||||
def test_a_lone_lead_in_line_joins_the_section_after_it():
|
||||
_assert_nothing_thin_and_nothing_lost(
|
||||
"2026-06-10 — UI batch",
|
||||
"Two dev→main PRs this session.\n\n## PR 89\n\n"
|
||||
+ _long_section("pr89", paragraphs=8),
|
||||
)
|
||||
|
||||
|
||||
def test_a_thin_last_section_joins_the_one_before_it():
|
||||
_assert_nothing_thin_and_nothing_lost(
|
||||
"T",
|
||||
"## Body\n\n" + _long_section("body", paragraphs=8) + "\n\n## Next\n\nTBD.",
|
||||
)
|
||||
|
||||
|
||||
def test_a_continuation_piece_repeats_the_heading_it_sits_under():
|
||||
"""A thin heading merged forward puts two headings in one section; the
|
||||
pieces of the second must carry the second, not the first."""
|
||||
body = "## Parent\n\n## Child\n\n" + _long_section("child", paragraphs=40)
|
||||
chunks = chunk_document("T", body)
|
||||
assert len(chunks) > 2
|
||||
for chunk in chunks[1:]:
|
||||
assert "## Child" in chunk
|
||||
|
||||
|
||||
# --- the read path: best chunk wins (#280 step 4) ----------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user