Attach figure bytes when a result matches its caption
This commit is contained in:
parent
07098d55e4
commit
d2593304c5
5 changed files with 175 additions and 15 deletions
|
|
@ -5,6 +5,10 @@
|
||||||
|
|
||||||
- Custom rerankers override `RerankerBase._rerank` instead of `rerank`; the base `rerank` handles the empty-input short-circuit.
|
- Custom rerankers override `RerankerBase._rerank` instead of `rerank`; the base `rerank` handles the empty-input short-circuit.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A search result whose matched refs include a figure's caption now attaches that figure's picture bytes for vision-capable models, resolved through the caption's adjacent picture item.
|
||||||
|
|
||||||
## [0.63.1] - 2026-06-29
|
## [0.63.1] - 2026-06-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -110,40 +110,54 @@ def _dedup_picture_chunks(results: list[SearchResult]) -> list[SearchResult]:
|
||||||
async def _populate_image_data(client: "HaikuRAG", results: list[SearchResult]) -> None:
|
async def _populate_image_data(client: "HaikuRAG", results: list[SearchResult]) -> None:
|
||||||
"""Attach base64 picture bytes to ``SearchResult.image_data`` in-place.
|
"""Attach base64 picture bytes to ``SearchResult.image_data`` in-place.
|
||||||
|
|
||||||
|
A result carries a picture when its refs include the picture directly, or
|
||||||
|
when they include the picture's caption — the common case where a prose
|
||||||
|
chunk carrying a figure's caption ranks while the picture is its own chunk.
|
||||||
Groups results by document_id and batches one picture-bytes lookup per
|
Groups results by document_id and batches one picture-bytes lookup per
|
||||||
document so a result set spanning N documents costs N reads, not one per
|
document so a result set spanning N documents costs N reads, not one per
|
||||||
picture. Only refs starting with ``PICTURE_REF_PREFIX`` are queried.
|
picture.
|
||||||
"""
|
"""
|
||||||
|
repo = client.document_item_repository
|
||||||
by_doc: dict[str, list[SearchResult]] = {}
|
by_doc: dict[str, list[SearchResult]] = {}
|
||||||
for r in results:
|
for r in results:
|
||||||
if not r.document_id:
|
if r.document_id and r.doc_item_refs:
|
||||||
continue
|
by_doc.setdefault(r.document_id, []).append(r)
|
||||||
if not any(ref.startswith(PICTURE_REF_PREFIX) for ref in r.doc_item_refs):
|
|
||||||
continue
|
|
||||||
by_doc.setdefault(r.document_id, []).append(r)
|
|
||||||
|
|
||||||
for doc_id, doc_results in by_doc.items():
|
for doc_id, doc_results in by_doc.items():
|
||||||
|
all_refs = {ref for r in doc_results for ref in r.doc_item_refs}
|
||||||
|
caption_to_picture = await repo.get_caption_picture_refs(doc_id, list(all_refs))
|
||||||
|
|
||||||
|
result_pictures: list[tuple[SearchResult, list[str]]] = []
|
||||||
wanted: list[str] = []
|
wanted: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for r in doc_results:
|
for r in doc_results:
|
||||||
|
pictures: list[str] = []
|
||||||
for ref in r.doc_item_refs:
|
for ref in r.doc_item_refs:
|
||||||
if ref.startswith(PICTURE_REF_PREFIX) and ref not in seen:
|
picture = (
|
||||||
wanted.append(ref)
|
ref
|
||||||
seen.add(ref)
|
if ref.startswith(PICTURE_REF_PREFIX)
|
||||||
|
else caption_to_picture.get(ref)
|
||||||
|
)
|
||||||
|
if picture and picture not in pictures:
|
||||||
|
pictures.append(picture)
|
||||||
|
if pictures:
|
||||||
|
result_pictures.append((r, pictures))
|
||||||
|
for picture in pictures:
|
||||||
|
if picture not in seen:
|
||||||
|
wanted.append(picture)
|
||||||
|
seen.add(picture)
|
||||||
if not wanted:
|
if not wanted:
|
||||||
continue
|
continue
|
||||||
bytes_by_ref = await client.document_item_repository.get_pictures_for_chunk(
|
bytes_by_ref = await repo.get_pictures_for_chunk(doc_id, wanted)
|
||||||
doc_id, wanted
|
|
||||||
)
|
|
||||||
if not bytes_by_ref:
|
if not bytes_by_ref:
|
||||||
continue
|
continue
|
||||||
captions_by_ref = await client.document_item_repository.get_text_for_refs(
|
captions_by_ref = await repo.get_text_for_refs(
|
||||||
doc_id, list(bytes_by_ref.keys())
|
doc_id, list(bytes_by_ref.keys())
|
||||||
)
|
)
|
||||||
for r in doc_results:
|
for r, pictures in result_pictures:
|
||||||
attached: dict[str, str] = {}
|
attached: dict[str, str] = {}
|
||||||
captions: dict[str, str] = {}
|
captions: dict[str, str] = {}
|
||||||
for ref in r.doc_item_refs:
|
for ref in pictures:
|
||||||
blob = bytes_by_ref.get(ref)
|
blob = bytes_by_ref.get(ref)
|
||||||
if blob:
|
if blob:
|
||||||
attached[ref] = base64.b64encode(blob).decode("ascii")
|
attached[ref] = base64.b64encode(blob).decode("ascii")
|
||||||
|
|
|
||||||
|
|
@ -271,3 +271,47 @@ class DocumentItemRepository:
|
||||||
if text:
|
if text:
|
||||||
result[row["self_ref"]] = text
|
result[row["self_ref"]] = text
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def get_caption_picture_refs(
|
||||||
|
self, document_id: str, refs: list[str]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Map caption refs to the picture item immediately preceding them.
|
||||||
|
|
||||||
|
Docling emits a figure's caption at the position right after its
|
||||||
|
picture, so a caption's picture is the picture item at
|
||||||
|
``position - 1``. Returns ``{caption_ref: picture_ref}`` for the
|
||||||
|
caption refs among ``refs`` that have a picture predecessor. Non-caption
|
||||||
|
refs, and captions whose predecessor is not a picture (table captions),
|
||||||
|
map to nothing.
|
||||||
|
"""
|
||||||
|
if not refs:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
safe_id = escape_sql_string(document_id)
|
||||||
|
refs_sql = ", ".join(f"'{escape_sql_string(r)}'" for r in refs)
|
||||||
|
caption_rows = await (
|
||||||
|
self.store.document_items_table.query()
|
||||||
|
.select(["self_ref", "position"])
|
||||||
|
.where(
|
||||||
|
f"document_id = '{safe_id}' AND label = 'caption' "
|
||||||
|
f"AND self_ref IN ({refs_sql})"
|
||||||
|
)
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
if not caption_rows:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
prev_to_caption = {row["position"] - 1: row["self_ref"] for row in caption_rows}
|
||||||
|
positions_sql = ", ".join(str(p) for p in prev_to_caption)
|
||||||
|
picture_rows = await (
|
||||||
|
self.store.document_items_table.query()
|
||||||
|
.select(["self_ref", "position"])
|
||||||
|
.where(
|
||||||
|
f"document_id = '{safe_id}' AND label = 'picture' "
|
||||||
|
f"AND position IN ({positions_sql})"
|
||||||
|
)
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
prev_to_caption[row["position"]]: row["self_ref"] for row in picture_rows
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,62 @@ class TestPictureDataStorage:
|
||||||
}
|
}
|
||||||
assert await repo.get_text_for_refs("doc-1", []) == {}
|
assert await repo.get_text_for_refs("doc-1", []) == {}
|
||||||
|
|
||||||
|
async def test_get_caption_picture_refs(self, temp_db_path):
|
||||||
|
"""A caption ref resolves to the picture at the immediately preceding
|
||||||
|
position; a table caption (no preceding picture) resolves to nothing."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
repo = DocumentItemRepository(rag.store)
|
||||||
|
await repo.create_items(
|
||||||
|
"doc-1",
|
||||||
|
[
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=0,
|
||||||
|
self_ref="#/pictures/0",
|
||||||
|
label="picture",
|
||||||
|
picture_data=b"\x89PNG\r\n\x1a\nfake",
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=1,
|
||||||
|
self_ref="#/texts/0",
|
||||||
|
label="caption",
|
||||||
|
text="Figure 1. A figure caption.",
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=2,
|
||||||
|
self_ref="#/texts/1",
|
||||||
|
label="paragraph",
|
||||||
|
text="Body prose.",
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=3,
|
||||||
|
self_ref="#/tables/0",
|
||||||
|
label="table",
|
||||||
|
text="| a | b |",
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=4,
|
||||||
|
self_ref="#/texts/2",
|
||||||
|
label="caption",
|
||||||
|
text="Table 1. A table caption.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Figure caption resolves to its picture; table caption does not.
|
||||||
|
got = await repo.get_caption_picture_refs(
|
||||||
|
"doc-1", ["#/texts/0", "#/texts/1", "#/texts/2"]
|
||||||
|
)
|
||||||
|
assert got == {"#/texts/0": "#/pictures/0"}
|
||||||
|
|
||||||
|
# A non-caption ref alone yields nothing.
|
||||||
|
assert await repo.get_caption_picture_refs("doc-1", ["#/texts/1"]) == {}
|
||||||
|
assert await repo.get_caption_picture_refs("doc-1", []) == {}
|
||||||
|
|
||||||
async def test_hot_paths_exclude_picture_data(self, temp_db_path):
|
async def test_hot_paths_exclude_picture_data(self, temp_db_path):
|
||||||
"""Light read paths must NOT pull picture_data into memory."""
|
"""Light read paths must NOT pull picture_data into memory."""
|
||||||
async with HaikuRAG(temp_db_path, create=True) as rag:
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,48 @@ async def test_populate_image_data_attaches_base64(temp_db_path):
|
||||||
assert with_picture.image_data == {"#/pictures/0": PICTURE_B64}
|
assert with_picture.image_data == {"#/pictures/0": PICTURE_B64}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_populate_image_data_attaches_picture_via_caption(temp_db_path):
|
||||||
|
"""A result whose matched refs include a figure's caption (but not the
|
||||||
|
picture itself) gets the picture bytes attached, resolved through the
|
||||||
|
caption's adjacent picture. This is the common case: the prose chunk
|
||||||
|
carrying a figure's caption ranks, while the picture is its own chunk."""
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as rag:
|
||||||
|
await rag.document_item_repository.create_items(
|
||||||
|
"doc-1",
|
||||||
|
[
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=0,
|
||||||
|
self_ref="#/pictures/0",
|
||||||
|
label="picture",
|
||||||
|
text="Figure 1. The layout.",
|
||||||
|
picture_data=PICTURE_BYTES,
|
||||||
|
),
|
||||||
|
DocumentItem(
|
||||||
|
document_id="doc-1",
|
||||||
|
position=1,
|
||||||
|
self_ref="#/texts/0",
|
||||||
|
label="caption",
|
||||||
|
text="Figure 1. The layout.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
via_caption = SearchResult(
|
||||||
|
content="Figure 1. The layout.",
|
||||||
|
score=1.0,
|
||||||
|
document_id="doc-1",
|
||||||
|
doc_item_refs=["#/texts/0"],
|
||||||
|
labels=["caption"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await _populate_image_data(rag, [via_caption])
|
||||||
|
|
||||||
|
assert via_caption.image_data == {"#/pictures/0": PICTURE_B64}
|
||||||
|
assert via_caption.picture_captions == {"#/pictures/0": "Figure 1. The layout."}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_client_search_include_images_false_skips_lookup(temp_db_path):
|
async def test_client_search_include_images_false_skips_lookup(temp_db_path):
|
||||||
"""include_images=False must short-circuit the picture-bytes lookup."""
|
"""include_images=False must short-circuit the picture-bytes lookup."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue