Anchor and clip merged citations on the highest-scoring chunk

A merged search result took its chunk_id from whichever constituent
sorted earliest in the document, while its score was the max across the
group — so the citation's identity could point at a different, less
relevant chunk. Anchor chunk_id and the content/refs fallbacks on the
max-score constituent, clip the budget window around that same chunk so
its evidence is never trimmed away, and narrow page_numbers, doc_item_refs,
and attached image bytes to the items that survive the clip.
This commit is contained in:
Yiorgis Gozadinos 2026-07-09 10:49:47 +03:00
parent 6d86237dd6
commit c424f51056
No known key found for this signature in database
2 changed files with 276 additions and 38 deletions

View file

@ -104,16 +104,18 @@ def _evidence_anchors(content: str, max_chars: int) -> list[str]:
return anchors return anchors
def _clip_to_budget(content: str, results: list[SearchResult], max_chars: int) -> str: def _clip_window(
"""Clip expanded content to ``max_chars``, keeping the matched evidence. content: str, results: list[SearchResult], max_chars: int
) -> tuple[int, int]:
"""Return the ``[start, end)`` char window to keep when clipping ``content``.
Anchors on the first locatable result in ``results`` order (the primary Anchors on the first locatable result in ``results`` order (the primary
chunk that supplies the expanded result's identity) and returns a chunk that supplies the expanded result's identity) and returns a
``max_chars``-wide window centered on it. Falls back to a prefix cut only ``max_chars``-wide window centered on it. Falls back to a prefix window only
when no anchor is locatable (heavy drift). when no anchor is locatable (heavy drift).
""" """
if max_chars <= 0: if max_chars <= 0:
return "" return (0, 0)
evidence_start, evidence_len = -1, 0 evidence_start, evidence_len = -1, 0
for result in results: for result in results:
for anchor in _evidence_anchors(result.content, max_chars): for anchor in _evidence_anchors(result.content, max_chars):
@ -124,14 +126,45 @@ def _clip_to_budget(content: str, results: list[SearchResult], max_chars: int) -
if evidence_start != -1: if evidence_start != -1:
break break
if evidence_start == -1: if evidence_start == -1:
return content[:max_chars] return (0, min(len(content), max_chars))
center = evidence_start + evidence_len // 2 center = evidence_start + evidence_len // 2
start = max(0, center - max_chars // 2) start = max(0, center - max_chars // 2)
end = min(len(content), start + max_chars) end = min(len(content), start + max_chars)
start = max(0, end - max_chars) start = max(0, end - max_chars)
return (start, end)
def _clip_to_budget(content: str, results: list[SearchResult], max_chars: int) -> str:
"""Clip expanded content to ``max_chars``, keeping the matched evidence."""
start, end = _clip_window(content, results, max_chars)
return content[start:end] return content[start:end]
def _collect_meta(
spans: list[tuple[int, int, DocumentItem]],
) -> tuple[set[int], list[str], set[str]]:
"""Union the page numbers, refs, and labels of the given item spans."""
pages: set[int] = set()
refs: list[str] = []
labels: set[str] = set()
for _start, _end, item in spans:
refs.append(item.self_ref)
if item.label:
labels.add(item.label)
pages.update(item.page_numbers)
return pages, refs, labels
def _span_in_window(
span: tuple[int, int, DocumentItem], win_start: int, win_end: int
) -> bool:
"""Whether an item's char span overlaps the ``[win_start, win_end]`` clip window."""
start, end, _item = span
if start == end: # zero-width picture position
return win_start <= start <= win_end
return start < win_end and end > win_start
def _expand_outward( def _expand_outward(
items: list[DocumentItem], items: list[DocumentItem],
center_idx: int, center_idx: int,
@ -290,9 +323,11 @@ async def expand_with_items(
final_results: list[SearchResult] = [] final_results: list[SearchResult] = []
for range_start, range_end, original_results in merged: for range_start, range_end, original_results in merged:
content_parts: list[str] = [] content_parts: list[str] = []
refs: list[str] = [] # Char span of each contributing item within the joined content, so
pages: set[int] = set() # metadata can be narrowed to whatever survives a budget clip.
labels: set[str] = set() item_spans: list[tuple[int, int, DocumentItem]] = []
cursor = 0
separator = "\n\n"
for pos in range(range_start, range_end + 1): for pos in range(range_start, range_end + 1):
item = pos_to_item.get(pos) item = pos_to_item.get(pos)
@ -301,55 +336,89 @@ async def expand_with_items(
if has_sections and item.label in _NOISE_LABELS: if has_sections and item.label in _NOISE_LABELS:
continue continue
if item.text: if item.text:
if content_parts:
cursor += len(separator)
start = cursor
content_parts.append(item.text) content_parts.append(item.text)
refs.append(item.self_ref) cursor += len(item.text)
if item.label: item_spans.append((start, cursor, item))
labels.add(item.label)
pages.update(item.page_numbers)
elif item.label == "picture": elif item.label == "picture":
# Pictures may legitimately have empty text (no VLM # Pictures may legitimately have empty text (no VLM
# description configured). Keep their self_ref so the # description configured). Keep their self_ref so the
# downstream image_data lookup can still attach bytes. # downstream image_data lookup can still attach bytes. They
refs.append(item.self_ref) # occupy a zero-width position in reading order.
labels.add(item.label) item_spans.append((cursor, cursor, item))
pages.update(item.page_numbers)
all_headings: list[str] = [] all_headings: list[str] = []
for r in original_results: for r in original_results:
if r.headings: if r.headings:
all_headings.extend(h for h in r.headings if h not in all_headings) all_headings.extend(h for h in r.headings if h not in all_headings)
# Carry image_data and picture_captions through expansion so that # Anchor identity (chunk_id, content/refs fallbacks) on the
# only pictures from the originally retrieved chunks get attached. # best-scoring constituent — the chunk that earned the result its
# Pictures swept in by section expansion are referenced in `refs` # rank — rather than whichever sits earliest in the document.
# for cross-referencing but their bytes are not re-fetched — first = max(original_results, key=lambda r: r.score)
# otherwise a single search can balloon the response with adjacent
# figures the model did not actually retrieve.
merged_image_data: dict[str, str] = {}
merged_captions: dict[str, str] = {}
for r in original_results:
if r.image_data:
merged_image_data.update(r.image_data)
if r.picture_captions:
merged_captions.update(r.picture_captions)
first = original_results[0]
chunk_ids: list[str] = [] chunk_ids: list[str] = []
for r in original_results: for r in original_results:
if r.chunk_id and r.chunk_id not in chunk_ids: if r.chunk_id and r.chunk_id not in chunk_ids:
chunk_ids.append(r.chunk_id) chunk_ids.append(r.chunk_id)
joined = separator.join(content_parts)
# Expansion should never return less content than the original chunk. # Expansion should never return less content than the original chunk.
# This can happen when item texts are fragmented (e.g., docling splits # This can happen when item texts are fragmented (e.g., docling splits
# formatted HTML list items into many small text nodes). # formatted HTML list items into many small text nodes); fall back to
expanded_content = "\n\n".join(content_parts) # the chunk's own content, described by the chunk's own metadata.
if len(expanded_content) < len(first.content): if len(joined) < len(first.content):
expanded_content = first.content base_content, base_spans = first.content, None
if len(expanded_content) > max_chars: else:
expanded_content = _clip_to_budget( base_content, base_spans = joined, item_spans
expanded_content, original_results, max_chars
if len(base_content) > max_chars:
# Clip to the budget, anchored on the primary (highest-scoring)
# chunk, and narrow metadata to whatever survives the window.
win_start, win_end = _clip_window(
base_content, [first, *original_results], max_chars
) )
expanded_content = base_content[win_start:win_end]
if base_spans is None:
pages, refs, labels = (
set(first.page_numbers),
list(first.doc_item_refs),
set(first.labels),
)
else:
pages, refs, labels = _collect_meta(
[s for s in base_spans if _span_in_window(s, win_start, win_end)]
)
else:
expanded_content = base_content
if base_spans is None:
pages, refs, labels = (
set(first.page_numbers),
list(first.doc_item_refs),
set(first.labels),
)
else:
pages, refs, labels = _collect_meta(base_spans)
# Carry image_data and picture_captions from the originally retrieved
# chunks, but only for constituents whose refs survive the window — a
# chunk clipped out of the budget must not still ship its image to the
# model. Pictures swept in by section expansion are referenced in
# ``refs`` but their bytes are never re-fetched, so the multimodal
# payload stays bounded to what was actually retrieved and shown.
surviving_refs = set(refs)
merged_image_data: dict[str, str] = {}
merged_captions: dict[str, str] = {}
for r in original_results:
if r.doc_item_refs and not surviving_refs.intersection(r.doc_item_refs):
continue
if r.image_data:
merged_image_data.update(r.image_data)
if r.picture_captions:
merged_captions.update(r.picture_captions)
final_results.append( final_results.append(
SearchResult( SearchResult(

View file

@ -767,6 +767,117 @@ class TestExpandWithItems:
# to the model. # to the model.
assert "c2" not in expanded[0].format_for_agent() assert "c2" not in expanded[0].format_for_agent()
async def test_merged_anchor_is_highest_scoring_constituent(self, temp_db_path):
"""A merged result's chunk_id anchors on the best-scoring constituent,
not whichever chunk sits earliest in the document."""
from haiku.rag.client import HaikuRAG
async with HaikuRAG(temp_db_path, create=True) as rag:
items = [
DocumentItem(
document_id="doc-1",
position=i,
self_ref=f"#/texts/{i}",
label="text",
text=f"Paragraph {i}. " * 10,
)
for i in range(5)
]
await rag.document_item_repository.create_items("doc-1", items)
# earlier in the document, lower score
r_early = SearchResult(
content="Paragraph 1.",
score=0.40,
chunk_id="c-early",
document_id="doc-1",
doc_item_refs=["#/texts/1"],
)
# later in the document, higher score — the real hit
r_best = SearchResult(
content="Paragraph 3.",
score=0.95,
chunk_id="c-best",
document_id="doc-1",
doc_item_refs=["#/texts/3"],
)
expanded = await expand_with_items(
rag.document_item_repository, "doc-1", [r_early, r_best], 5000
)
assert len(expanded) == 1
assert expanded[0].chunk_id == "c-best"
assert expanded[0].score == 0.95
# provenance still lists both
assert set(expanded[0].chunk_ids) == {"c-early", "c-best"}
async def test_clipped_merged_result_keeps_anchor_evidence_and_pages(
self, temp_db_path
):
"""When a merged result is clipped to budget, the surviving window is
centered on the anchor (highest-scoring) chunk, and page_numbers reflect
only the content that survived not the full merged range."""
from haiku.rag.client import HaikuRAG
async with HaikuRAG(temp_db_path, create=True) as rag:
items = [
DocumentItem(
document_id="doc-1",
position=0,
self_ref="#/texts/0",
label="text",
text="LOWMARK " + "a" * 400,
page_numbers=[1],
),
DocumentItem(
document_id="doc-1",
position=1,
self_ref="#/texts/1",
label="text",
text="b" * 400,
page_numbers=[2],
),
DocumentItem(
document_id="doc-1",
position=2,
self_ref="#/texts/2",
label="text",
text="c" * 400 + " HIGHMARK",
page_numbers=[3],
),
]
await rag.document_item_repository.create_items("doc-1", items)
r_low = SearchResult(
content="LOWMARK " + "a" * 400,
score=0.4,
chunk_id="c-low",
document_id="doc-1",
doc_item_refs=["#/texts/0"],
page_numbers=[1],
)
r_high = SearchResult(
content="c" * 400 + " HIGHMARK",
score=0.9,
chunk_id="c-high",
document_id="doc-1",
doc_item_refs=["#/texts/2"],
page_numbers=[3],
)
expanded = await expand_with_items(
rag.document_item_repository, "doc-1", [r_low, r_high], 500
)
assert len(expanded) == 1
e = expanded[0]
# anchor is the high-scoring chunk, and its evidence survives clipping
assert e.chunk_id == "c-high"
assert "HIGHMARK" in e.content
assert "LOWMARK" not in e.content
# page_numbers reflect only the surviving window, not the full range
assert 3 in e.page_numbers
assert 1 not in e.page_numbers
# refs likewise exclude the clipped-out item
assert "#/texts/0" not in e.doc_item_refs
async def test_fuzzy_match_preserves_central_marker(self, temp_db_path): async def test_fuzzy_match_preserves_central_marker(self, temp_db_path):
"""The chunk's text need not be verbatim in the joined item text: a clean """The chunk's text need not be verbatim in the joined item text: a clean
central marker is still located via the central-slice anchor.""" central marker is still located via the central-slice anchor."""
@ -904,3 +1015,61 @@ class TestExpandWithItemsPictureBytes:
"#/pictures/1": "A", "#/pictures/1": "A",
"#/pictures/3": "B", "#/pictures/3": "B",
} }
async def test_clipped_out_picture_bytes_dropped(self, temp_db_path):
"""A lower-scoring picture chunk clipped out of the budget window no
longer contributes its image bytes the model must not receive an
image the citation and visualization omit."""
from haiku.rag.client import HaikuRAG
async with HaikuRAG(temp_db_path, create=True) as rag:
items = [
DocumentItem(
document_id="doc-1",
position=0,
self_ref="#/pictures/0",
label="picture",
text="LOWPIC " + "a" * 400,
page_numbers=[1],
),
DocumentItem(
document_id="doc-1",
position=1,
self_ref="#/texts/1",
label="text",
text="b" * 400,
page_numbers=[2],
),
DocumentItem(
document_id="doc-1",
position=2,
self_ref="#/pictures/1",
label="picture",
text="c" * 400 + " HIGHPIC",
page_numbers=[3],
),
]
await rag.document_item_repository.create_items("doc-1", items)
r_low = SearchResult(
content="LOWPIC " + "a" * 400,
score=0.4,
chunk_id="c-low",
document_id="doc-1",
doc_item_refs=["#/pictures/0"],
image_data={"#/pictures/0": "LOWBYTES"},
)
r_high = SearchResult(
content="c" * 400 + " HIGHPIC",
score=0.9,
chunk_id="c-high",
document_id="doc-1",
doc_item_refs=["#/pictures/1"],
image_data={"#/pictures/1": "HIGHBYTES"},
)
expanded = await expand_with_items(
rag.document_item_repository, "doc-1", [r_low, r_high], 500
)
assert len(expanded) == 1
assert "#/pictures/0" not in expanded[0].doc_item_refs
assert expanded[0].image_data == {"#/pictures/1": "HIGHBYTES"}