Skip to content

Retain dense image-only VDB records#2312

Open
jioffe502 wants to merge 3 commits into
NVIDIA:mainfrom
jioffe502:codex/dense-image-only-vdb
Open

Retain dense image-only VDB records#2312
jioffe502 wants to merge 3 commits into
NVIDIA:mainfrom
jioffe502:codex/dense-image-only-vdb

Conversation

@jioffe502

Copy link
Copy Markdown
Collaborator

Summary

Retain image-backed page records in dense LanceDB ingestion when the VL embedder produced a valid vector but extracted text is blank.

  • admit blank dense rows only when an embedding and concrete string image backing are present
  • normalize admitted rows to document_type=image and content_metadata.type=image
  • allow the dense leg of hybrid retrieval to return them without adding FTS terms
  • keep sparse ingestion text-only and preserve rejection of missing or malformed vectors
  • omit hits without answer-ready text from evidence output while reporting visual-only coverage gaps

Root cause

The page-level text_image pipeline can successfully embed a scanned page with no native text layer. Both the graph-to-VDB adapter and the LanceDB writer previously required truthy text, so they discarded the valid visual vector before it could be retrieved.

Impact

On ViDoRe v3 Finance FR, the fix restored 235 pages, including 69 uniquely judged pages. In the controlled comparison, Recall@5 improved from 0.325283 to 0.327425, Recall@10 from 0.426938 to 0.432171, and nDCG@10 from 0.345894 to 0.350014. A Computer Science control retained full coverage; its two newly eligible blank-text image rows did not enter any query's top 10.

Scope

This does not add a LanceDB column, OCR behavior, model-name enforcement, ViDoRe configuration, or a multimodal answer-generation contract. Raw retrieval can still return a visual hit with text=""; defining how /v1/answer consumes visual-only hits remains follow-up work.

Validation

  • 68 passed in the focused adapter, writer, hybrid retrieval, evidence, and pipeline-helper suite
  • 254 passed in the broader VDB/query suite
  • changed-file pre-commit hooks passed
  • mutation audit confirmed that the retained tests fail when image admission, canonical guards, nonblank-text policy, answer-readiness filtering, or metadata-first modality resolution are regressed

@jioffe502 jioffe502 marked this pull request as ready for review July 8, 2026 15:08
@jioffe502 jioffe502 requested review from a team as code owners July 8, 2026 15:08
@jioffe502 jioffe502 requested review from ChrisJar and nkmcalli and removed request for nkmcalli July 8, 2026 15:08
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a data-loss bug where image-backed pages with no native text layer were silently discarded during dense LanceDB ingestion, even when the VL embedder produced a valid vector. The fix threads a new image_only path through the graph adapter, the dense writer, and the evidence formatter, while keeping the sparse path and rows without concrete image backing closed.

  • Ingestion (records.py, lancedb.py): _is_image_backed_row gates admission on a concrete string image payload (_image_b64, _stored_image_uri); admitted rows are canonicalized to document_type=image / content_metadata.type=image with text="", bypassing FTS indexing while remaining retrievable via vector similarity.
  • Evidence (evidence.py): build_evidence_result now splits projected hits into answer-ready evidence and omitted lists; image-only omissions produce a distinct "visual-only matches omitted" thin-spot so consumers know visual coverage existed but was not text-answerable.
  • Testing: Four new test files cover admission/rejection at the adapter, writer, hybrid-retrieval, and evidence layers with parametrized edge cases (whitespace-only text, bytes/numpy image payloads, missing canonical fields, and invalid embeddings).

Confidence Score: 5/5

Safe to merge. The change is well-scoped and all existing admission contracts are preserved or tightened.

All three layers of the changed path (graph adapter, LanceDB writer, evidence formatter) have matching unit tests covering happy-path, rejection, and edge-case variants. The sparse ingestion path and the bytes/numpy fail-closed behavior are explicitly tested. No new schema columns, no breaking API changes.

No files require special attention.

Important Files Changed

Filename Overview
nemo_retriever/src/nemo_retriever/common/vdb/records.py Adds _text_from_graph_row, _is_image_backed_row helpers and an image_only branch in _client_record_from_graph_row to admit blank-text, image-backed rows as canonical document_type=image records; sparse path (require_embedding=False) correctly stays closed.
nemo_retriever/src/nemo_retriever/common/vdb/lancedb.py Dense writer now admits canonical image rows with text="" when both document_type and content_metadata.type are "image"; sparse writer tightens the blank-text check to not isinstance(text, str) or not text.strip() but otherwise unchanged.
nemo_retriever/src/nemo_retriever/query/evidence.py Splits projected hits into evidence (answer-ready) and omitted (blank-text) lists; n_docs_seen counts all projected sources; adds visual-only and generic omission thin-spots to coverage; uses resolve_hit_content_type / parse_hit_content_metadata for consistent modality resolution.
nemo_retriever/src/nemo_retriever/query/shaping.py Renames _hit_content_type to public resolve_hit_content_type and reuses it in shape_query_hits; no logic change beyond visibility promotion.
nemo_retriever/tests/test_nv_ingest_vdb_operator.py Adds parametrized tests covering: blank image-only row admission/normalization, non-canonical image payloads (bytes/numpy) fail-closed, stored_image_uri field variants, and ineligible blank row rejection.
nemo_retriever/tests/test_lancedb_write_policy.py Adds write-policy tests: canonical image rows stored with correct LanceDB shape, missing canonical fields cause rejection, invalid embeddings dropped, sparse ingestion rejects image-only and whitespace-only rows.
nemo_retriever/tests/test_lancedb_retrieval_where.py Adds hybrid-retrieval test verifying image-only rows surface through dense fusion but are excluded from FTS hits.
nemo_retriever/tests/query/test_evidence.py New test file for build_evidence_result covering visual-only omission, non-visual blank-text omission, and mixed evidence+omitted coverage reporting.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Graph row\n(text='', _image_b64='...')"] --> B["_client_record_from_graph_row\n(require_embedding=True)"]
    B --> C{embedding\npresent?}
    C -- No --> DROP1["return None\n(dropped)"]
    C -- Yes --> D{"text\nnonblank?"}
    D -- Yes --> TEXT["Normal text/table/etc path\ncontent_type from row"]
    D -- No --> E{"_is_image_backed_row?\n(string _image_b64\nor stored_image_uri)"}
    E -- No --> DROP2["return None\n(dropped)"]
    E -- Yes --> IMG["image_only=True\ndocument_type='image'\ncontent_metadata.type='image'\ncontent=''"]
    TEXT --> RECORD["Client VDB record\n{document_type, metadata}"]
    IMG --> RECORD
    RECORD --> F["_create_lancedb_results\n(dense writer)"]
    F --> G{"text empty &\nis_canonical_image?"}
    G -- "Yes" --> STORE["Store row\ntext='', vector=embedding"]
    G -- No --> DROP3["dropped_no_text++"]
    STORE --> H["LanceDB table"]
    H --> I["Dense/hybrid retrieval\nreturns hit with text=''"]
    I --> J["build_evidence_result"]
    J --> K["_is_answer_ready_evidence\n(text.strip() is falsy)"]
    K -- False --> OMIT["omitted list\ncoverage.thin_spots:\n'visual-only matches omitted'"]
    K -- True --> EVID["evidence list"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Graph row\n(text='', _image_b64='...')"] --> B["_client_record_from_graph_row\n(require_embedding=True)"]
    B --> C{embedding\npresent?}
    C -- No --> DROP1["return None\n(dropped)"]
    C -- Yes --> D{"text\nnonblank?"}
    D -- Yes --> TEXT["Normal text/table/etc path\ncontent_type from row"]
    D -- No --> E{"_is_image_backed_row?\n(string _image_b64\nor stored_image_uri)"}
    E -- No --> DROP2["return None\n(dropped)"]
    E -- Yes --> IMG["image_only=True\ndocument_type='image'\ncontent_metadata.type='image'\ncontent=''"]
    TEXT --> RECORD["Client VDB record\n{document_type, metadata}"]
    IMG --> RECORD
    RECORD --> F["_create_lancedb_results\n(dense writer)"]
    F --> G{"text empty &\nis_canonical_image?"}
    G -- "Yes" --> STORE["Store row\ntext='', vector=embedding"]
    G -- No --> DROP3["dropped_no_text++"]
    STORE --> H["LanceDB table"]
    H --> I["Dense/hybrid retrieval\nreturns hit with text=''"]
    I --> J["build_evidence_result"]
    J --> K["_is_answer_ready_evidence\n(text.strip() is falsy)"]
    K -- False --> OMIT["omitted list\ncoverage.thin_spots:\n'visual-only matches omitted'"]
    K -- True --> EVID["evidence list"]
Loading

Reviews (2): Last reviewed commit: "Merge branch 'main' into codex/dense-ima..." | Re-trigger Greptile

Comment thread nemo_retriever/src/nemo_retriever/query/evidence.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants