Multimodal RAG

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-09-30 against Docling 2.131.0 (docling-core 2.99.0) and langchain-core 1.6.6 on Python 3.12, Spring AI 2.0.1 and LangChain4j 1.20.2 (see the version baseline). The table-stitching function was run on hand-built tables; the Docling and model calls were checked against the released packages but not run; the Java classes were compiled against the released jars.

Document Parsing gets text out of documents. Some of the most valuable content in DocsAssistant’s corpus is not prose: the compatibility matrix in the release notes, the architecture diagram in the design review, the benchmark chart in a slide deck, the recorded walkthrough of a new API. A text-only pipeline either drops them or flattens them into noise. Multimodal RAG keeps them retrievable and answerable. The foundations of multimodal models are in Multimodal Models; this page is about indexing and retrieving multimodal content.

flowchart TD D[Parsed document] --> T{Element type} T -->|text| C[Structure-aware chunks] T -->|table| TD[Detect and extract cells] TD --> TN[Normalise to Markdown and JSON] TN --> TS[Stitch tables across pages] TS --> TC[Table chunk plus summary] T -->|figure| IC[Crop image with bbox] IC --> IV[VLM summary as text] IC --> IE[Image embedding] T -->|audio or video| AT[Transcribe with timestamps] AT --> AC[Timed transcript chunks] C --> IDX[(Vector index)] TC --> IDX IV --> IDX IE --> MIDX[(Multimodal index)] AC --> IDX IC --> OBJ[(Object store for visual citations)]

Tables: detect, extract, normalise, stitch

A table is a two-dimensional object that embeddings and chunkers handle badly. The pipeline has four steps:

  1. Detect the table region. Layout models do this; Docling’s TableFormer and Unstructured’s hi_res strategy both return table elements, and cloud document AI services return table blocks with cell coordinates.

  2. Extract the cell grid, including row and column spans. This is where parsers differ most; merged header cells and borderless tables are the usual failures.

  3. Normalise the grid into a representation both the embedding model and the generator understand. Markdown is compact and readable by models; JSON (one object per row, keyed by header) is better for exact lookups and for passing to a SQL or pandas tool. DocsAssistant stores both: Markdown in the chunk text, JSON in metadata.

  4. Stitch tables that continue across pages. A long compatibility matrix is split by page breaks into several tables whose later parts repeat the header or have none. Merge consecutive tables on adjacent pages when the header matches or the column count matches and the second part has no header.

Large tables need one more decision: whether to embed the whole table as one chunk (good for "which versions support X?"), row groups with the header repeated (good for lookups), or a short model-written summary of the table plus the table itself as the returned payload (good for recall on vague questions). The summary-plus-payload variant is the multi-vector pattern of Advanced Indexing Patterns.

Docling exposes every table as a TableItem with page provenance, which can be exported to a pandas DataFrame or to Markdown. The stitcher below merges continuation tables before normalising:

docsassistant/multimodal/tables.py — extract, stitch across pages and normalise (Python, Docling 2.131.0)
import pandas as pd
from docling_core.types.doc import DoclingDocument


def extract_tables(doc: DoclingDocument) -> list[tuple[int, pd.DataFrame, str]]:
    """(page_no, dataframe, caption) for every table, in reading order."""
    return [(t.prov[0].page_no, t.export_to_dataframe(doc=doc), t.caption_text(doc)) for t in doc.tables]


def stitch(tables: list[tuple[int, pd.DataFrame, str]]) -> list[tuple[int, pd.DataFrame, str]]:
    """Merge a table into the previous one when it continues on the next page."""
    merged: list[tuple[int, pd.DataFrame, str]] = []
    for page, df, caption in tables:
        if merged:
            prev_page, prev_df, prev_caption = merged[-1]
            same_header = list(df.columns) == list(prev_df.columns)
            headerless_continuation = (len(df.columns) == len(prev_df.columns)
                                       and all(str(c).isdigit() for c in df.columns))
            if page == prev_page + 1 and (same_header or headerless_continuation):
                df.columns = prev_df.columns
                merged[-1] = (page, pd.concat([prev_df, df], ignore_index=True), prev_caption)
                continue
        merged.append((page, df, caption))
    return merged


def to_chunk(page: int, df: pd.DataFrame, caption: str, source: str) -> dict:
    return {
        "text": (f"Table: {caption}\n\n" if caption else "") + df.to_markdown(index=False),
        "metadata": {"source": source, "page": page, "element_type": "Table",
                     "rows_json": df.to_dict(orient="records")},
    }

A headerless continuation shows up in pandas with integer column labels; the check above treats all-digit labels as "no header". DataFrame.to_markdown needs the tabulate package.

Neither Spring AI nor LangChain4j detects tables: Tika and PDFBox return the cell text in reading order without the grid. On the JVM, call a layout-aware parser (a Docling service, or a cloud document AI API) during ingestion and store its Markdown or JSON tables as Document text and metadata; retrieval and generation then work exactly as for prose.

Images: summaries or shared-space embeddings

There are two ways to make a figure retrievable, and they are complementary:

Approach How Trade-offs

VLM summary (image to text)

A vision-language model writes a dense description of the figure (what it shows, labels, numbers, the conclusion). The description is embedded with the normal text embedding model; the image is returned as the payload.

Works with any text index and any text retriever. Quality depends on the prompt and model; the summary can omit or invent details. Cost is paid once, at ingestion.

Shared-space embedding (CLIP, SigLIP)

An image encoder and a text encoder trained together map images and text into the same vector space; the query text is compared directly with image vectors.

No generated text, so no summary hallucination; good for "find the diagram of X". Weaker on dense text inside images and on charts; a second index.

Late interaction over page images (ColPali)

A vision-language model embeds each page image into many patch vectors; queries are scored by late interaction (the sum over query tokens of the best-matching patch).

Skips parsing entirely for visually rich documents and retrieves whole pages very well. Multi-vector storage is large, and the generator must be a VLM that reads the page image.

For DocsAssistant the default is the VLM summary, because it keeps one text index and one retriever; ColPali is the upgrade path for slide decks where layout carries the meaning. The shared-space and late-interaction models are covered from the Hub side in Vision Models.

docsassistant/multimodal/figures.py — VLM summaries of Docling figures (Python, langchain 1.4.3, Docling 2.131.0)
import base64
import io
import os

from docling_core.types.doc import DoclingDocument
from langchain.chat_models import init_chat_model
from langchain_core.documents import Document
from langchain_core.messages import HumanMessage

vision = init_chat_model(os.environ["VISION_MODEL"], temperature=0)

PROMPT = ("Describe this figure from technical documentation for search. State what it shows, every label and "
          "number you can read, and the conclusion it supports. Do not guess text you cannot read.")


def summarise_figures(doc: DoclingDocument, source: str, image_uri: callable) -> list[Document]:
    out = []
    for i, picture in enumerate(doc.pictures):
        image = picture.get_image(doc)                     # needs generate_picture_images=True at parse time
        if image is None:
            continue
        buf = io.BytesIO()
        image.save(buf, format="PNG")
        message = HumanMessage(content_blocks=[
            {"type": "text", "text": PROMPT + "\nCaption: " + picture.caption_text(doc)},
            {"type": "image", "base64": base64.b64encode(buf.getvalue()).decode(), "mime_type": "image/png"},
        ])
        summary = vision.invoke([message]).text
        prov = picture.prov[0]
        out.append(Document(page_content=summary, metadata={
            "source": source, "page": prov.page_no, "bbox": prov.bbox.as_tuple(), "element_type": "Picture",
            "image_uri": image_uri(source, i),             # stored in the object store, used as visual citation
            "parser": "vlm-summary",
        }))
    return out

The same summary step in Spring AI passes the image as Media on the user message; any chat model whose provider supports vision accepts it:

FigureSummarizer.java — summarising a figure with a vision model (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.multimodal;

import java.util.Map;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;

@Service
public class FigureSummarizer {

    private static final String PROMPT = """
            Describe this figure from technical documentation for search. State what it shows, every label and
            number you can read, and the conclusion it supports. Do not guess text you cannot read.
            Caption: {caption}""";

    private final ChatClient chatClient;

    public FigureSummarizer(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    public Document summarise(Resource png, String caption, String source, int page, String imageUri) {
        String summary = chatClient.prompt()
                .user(u -> u.text(PROMPT).param("caption", caption).media(MimeTypeUtils.IMAGE_PNG, png))
                .call()
                .content();
        return Document.builder()
                .text(summary)
                .metadata(Map.of("source", source, "page", page, "element_type", "Picture",
                        "image_uri", imageUri, "parser", "vlm-summary"))
                .build();
    }
}

LangChain4j builds the same request from TextContent and ImageContent:

FigureSummarizer.java — summarising a figure with a vision model (Java, LangChain4j 1.20.2)
package com.example.docsassistant.l4j.multimodal;

import java.util.Base64;

import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.chat.ChatModel;

public class FigureSummarizer {

    private static final String PROMPT = "Describe this figure from technical documentation for search. State what "
            + "it shows, every label and number you can read, and the conclusion it supports. "
            + "Do not guess text you cannot read. Caption: ";

    private final ChatModel visionModel;

    public FigureSummarizer(ChatModel visionModel) {
        this.visionModel = visionModel;
    }

    public TextSegment summarise(byte[] png, String caption, String source, int page, String imageUri) {
        UserMessage message = UserMessage.from(
                TextContent.from(PROMPT + caption),
                ImageContent.from(Base64.getEncoder().encodeToString(png), "image/png"));
        String summary = visionModel.chat(message).aiMessage().text();
        Metadata metadata = new Metadata()
                .put("source", source).put("page", page).put("element_type", "Picture")
                .put("image_uri", imageUri).put("parser", "vlm-summary");
        return TextSegment.from(summary, metadata);
    }
}

Neither Java framework ships a CLIP, SigLIP or ColPali embedding model. On the JVM, shared-space embeddings come from an ONNX export of the model run with ONNX Runtime or DJL (see Java & Spring Boot in the Hugging Face sub-section) or from a small Python embedding service, and the vectors are written to the same vector store under a separate collection.

Audio and video: transcription first

Speech becomes retrievable once it is text. Transcribe with a speech-to-text model that returns segment timestamps, chunk the transcript by time or by speaker turns (30 to 90 seconds is a good start), and keep start and end seconds in metadata so that a citation can open the recording at the right moment. For video, add keyframes sampled at scene changes and treat them as figures. The speech models themselves — Whisper and its successors, diarisation, streaming — are covered in Speech Models and Audio Transcription & Speech.

TranscriptIngestor.java — transcribe a recording into a document (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.multimodal;

import java.util.Map;

import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.ai.document.Document;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

@Service
public class TranscriptIngestor {

    private final TranscriptionModel transcriptionModel;

    public TranscriptIngestor(TranscriptionModel transcriptionModel) {
        this.transcriptionModel = transcriptionModel;
    }

    public Document ingest(Resource recording, String source) {
        String transcript = transcriptionModel.transcribe(recording);
        return Document.builder()
                .text(transcript)
                .metadata(Map.of("source", source, "element_type", "Transcript"))
                .build();
    }
}

The portable transcribe(Resource) returns plain text. Segment timestamps are provider-specific: request a verbose or timestamped response format through the provider’s transcription options (for example the OpenAI options' response format) and read the segments from the provider response when you need time-coded citations. LangChain4j’s equivalent is AudioTranscriptionModel.transcribeToText(Audio), where Audio.builder().binaryData(bytes).mimeType("audio/mpeg") wraps the recording (LangChain4j — models). In Python, transcribe with the model of your choice (see Speech Models) and turn each timed segment into a LangChain Document whose metadata holds start and end.

Visual citations

A text citation says "release notes, page 12". A visual citation shows the page region the answer came from: the table, the figure, the highlighted paragraph. It is the most convincing grounding signal for tables and figures, and the only honest one for VLM-parsed content. It needs three things carried end to end from ingestion:

  • the page and bbox (bounding box in page coordinates) of every chunk, from Docling provenance or the document AI service;

  • a stable image_uri for figure crops and rendered pages in an object store, served through signed, short-lived URLs because the originals are subject to the same ACLs as the text (Access Control & Privacy);

  • a citation schema in the API that includes them (page, bbox, imageUrl), so the client can render a crop or open the PDF viewer at the region; see Citations & Grounding.

Evaluating multimodal hallucination

Multimodal pipelines add failure modes that text evaluation does not see: a table stitched wrongly, a figure summary that invents a number, a transcript with a misheard product name. Evaluate each stage separately:

  • Extraction accuracy. Keep a small set of hard pages with hand-checked Markdown for their tables and compare cell by cell (exact match rate of cells, row count, header match).

  • Summary faithfulness. For figure summaries, ask a judge model with the image and the summary to list every claim in the summary that the image does not support; the same claim-decomposition method as Hallucination Detection & Correction, with an image as the context.

  • Answer grounding by modality. Break the faithfulness and citation metrics of Evaluation at Scale down by the element_type and parser of the cited chunks. If answers citing VLM summaries are less faithful than answers citing text, tighten the summary prompt or switch that document class to ColPali and a VLM generator.

  • Transcription error rate. Word error rate on a sample of recordings with human transcripts, and a glossary check for product names.

Tables (detection, normalisation and cross-page stitching), images (VLM summaries versus shared-space embeddings) and visual citations follow the concepts of Mendelevitch and Bao, Hands-On RAG for Production (Chapter 8), and the multimodal partitioning recipes of Polzer, RAG with Python Cookbook (Chapter 3), paraphrased; see the bibliography.