Advanced Indexing Patterns

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 langchain 1.4.3, langchain-core 1.6.6, langchain-classic 1.0.8 and langchain-text-splitters 1.1.2 on Python 3.12, Spring AI 2.0.1 and LangChain4j 1.20.2 (see the version baseline). The auto-merging function was run against in-memory data; the model-driven code and the Java classes were checked against the released packages (the Java compiled) but not run against a model.

The chunking strategies of Document Ingestion & Chunking embed a chunk and return the same chunk. That couples two different requirements: a chunk should be small and focused to embed well (one idea, so its vector is not an average of several), and large and complete to answer well (enough context for the generator). Every pattern on this page breaks the coupling by indexing one representation and returning another. Parent-document retrieval and context expansion by neighbouring chunks, the simplest members of the family, are already on Retrieval Strategies; this page covers the rest.

Pattern Indexed Returned Where it helps

Hypothetical questions

Questions a chunk answers

The chunk

FAQ-style traffic; vocabulary mismatch between users and docs.

Propositions

Atomic, self-contained facts

The facts, or their source passage

Dense reference text; precise factual lookups.

Auto-merging parent/child

Small children

The parent, when enough children match

Long sections where answers span paragraphs.

Sentence window

Single sentences

The sentence with its neighbours

Precise matching in flowing prose.

Multi-vector (summary + raw)

A summary (of a table, figure or long section)

The raw content

Tables, figures, code listings.

RAPTOR

Chunks and recursive summaries of clusters

Whichever level matches

Broad, thematic questions over large corpora.

Contextual retrieval

The chunk with a generated context header

The chunk

Chunks that are ambiguous out of context ("it", "the new version").

Late chunking

Chunk vectors pooled from whole-document token embeddings

The chunk

The same ambiguity, without an LLM call per chunk.

Where each pattern lives in the frameworks

The LangChain 1.x core packages keep only the retriever interface and the vector-store retrievers; the classic composite retrievers moved to langchain-classic. None of the frameworks ships every pattern, so several of the implementations below are short functions of your own:

Pattern LangChain 1.x Spring AI 2.0.1 LangChain4j 1.20.2

Multi-vector, hypothetical questions, summaries

MultiVectorRetriever in langchain-classic

A DocumentTransformer that adds derived documents plus a DocumentPostProcessor that swaps in the parent

A TextSegmentTransformer or extra segments plus a ContentRetriever wrapper

Parent/child

ParentDocumentRetriever in langchain-classic

Same as above

Same as above

Auto-merging, sentence window

Your own function over the vector store (below); LlamaIndex ships both

DocumentPostProcessor

DocumentBySentenceSplitter for indexing, a ContentRetriever wrapper for merging

Propositions, RAPTOR

Your own ingestion step (below)

Your own DocumentTransformer

Your own ingestion step

Contextual retrieval

Your own ingestion step

DocumentTransformer (or SummaryMetadataEnricher for document-level context)

TextSegmentTransformer

Late chunking

Needs an embedding model that returns token embeddings; not a framework feature

Same

Same

Hypothetical-question indexing

For each chunk, a model writes the three to five questions the chunk answers; the questions are embedded and point to the chunk. A user question is then matched against questions, which are closer in form and vocabulary to user questions than documentation prose is. It is the index-time mirror of HyDE, which generates a hypothetical answer at query time: hypothetical questions pay the model cost once per chunk instead of once per query. MultiVectorRetriever stores the derived vectors in the vector store and the originals in a document store, linked by id_key:

docsassistant/indexing/hypothetical_questions.py — questions indexed, chunks returned (Python, langchain-classic 1.0.8)
import os
import uuid

from langchain.chat_models import init_chat_model
from langchain_classic.retrievers import MultiVectorRetriever
from langchain_core.documents import Document
from langchain_core.stores import InMemoryStore
from langchain_core.vectorstores import VectorStore
from pydantic import BaseModel, Field


class Questions(BaseModel):
    questions: list[str] = Field(min_length=3, max_length=5)


writer = init_chat_model(os.environ["INDEXING_MODEL"], temperature=0).with_structured_output(Questions)


def build_retriever(chunks: list[Document], vector_store: VectorStore) -> MultiVectorRetriever:
    retriever = MultiVectorRetriever(vectorstore=vector_store, docstore=InMemoryStore(), id_key="doc_id",
                                     search_kwargs={"k": 8})
    ids = [str(uuid.uuid5(uuid.NAMESPACE_URL, c.metadata["chunk_id"])) for c in chunks]
    derived = []
    for doc_id, chunk in zip(ids, chunks):
        qs = writer.invoke("Write the questions a reader could answer with this passage only:\n\n" + chunk.page_content)
        derived += [Document(page_content=q, metadata={"doc_id": doc_id, **chunk.metadata}) for q in qs.questions]
    retriever.vectorstore.add_documents(derived)
    retriever.docstore.mset(list(zip(ids, chunks)))
    return retriever

The InMemoryStore is for the example; in production the parent store is a table (the chunks table of the DocsAssistant schema already is one) behind a BaseStore implementation. Copy the ACL metadata onto every derived vector, as the **chunk.metadata does, so that the ACL filter applies at the vector level.

Proposition and agentic chunking

Proposition chunking rewrites a passage into atomic propositions: short, self-contained statements that each express one fact and resolve pronouns ("The sparse solver in Algebra 4.2 reports convergence diagnostics per iteration."). The Dense X Retrieval paper found that retrieving propositions improves precision for fact lookups compared with passages or sentences of similar length. Agentic chunking goes one step further: a model reads the propositions in order and decides which ones belong together in a chunk, producing topic-coherent chunks of variable length. Both cost one or more model calls per passage at ingestion and should be reserved for high-value, fact-dense content.

docsassistant/indexing/propositions.py — passages to propositions (Python, langchain 1.4.3)
import os

from langchain.chat_models import init_chat_model
from langchain_core.documents import Document
from pydantic import BaseModel, Field


class Propositions(BaseModel):
    propositions: list[str] = Field(description="Atomic, self-contained facts; resolve every pronoun and reference")


extractor = init_chat_model(os.environ["INDEXING_MODEL"], temperature=0).with_structured_output(Propositions)


def to_propositions(chunk: Document) -> list[Document]:
    result = extractor.invoke(
        "Decompose the passage into propositions. Each proposition states exactly one fact, names its subject "
        "explicitly and can be understood without the passage.\n\nPassage:\n" + chunk.page_content)
    return [Document(page_content=p, metadata={**chunk.metadata, "parent_chunk_id": chunk.metadata["chunk_id"],
                                               "granularity": "proposition"})
            for p in result.propositions]

Index propositions as derived vectors of their parent chunk (the multi-vector layout above) rather than on their own: then a proposition match can return either the proposition (for a terse factual answer) or its passage (for context).

Auto-merging parent/child retrieval

Split each section into a parent (a whole section or a large chunk) and children (small chunks), index only the children, and at query time count how many retrieved children share a parent. When the share of a parent’s children in the result exceeds a threshold, replace them with the parent:

with the retrieved children and typically 0.5. The effect is that a question answered by one paragraph gets that paragraph, and a question answered by most of a section gets the whole section, without the generator seeing duplicates.

docsassistant/indexing/auto_merge.py — merge children into parents after retrieval (Python, langchain-core 1.6.6)
from collections import defaultdict
from collections.abc import Callable

from langchain_core.documents import Document


def auto_merge(children: list[Document], parent_size: Callable[[str], int],
               load_parent: Callable[[str], Document], ratio: float = 0.5) -> list[Document]:
    """Replace retrieved children by their parent when at least `ratio` of the parent's children were retrieved."""
    by_parent: dict[str, list[Document]] = defaultdict(list)
    for child in children:
        by_parent[child.metadata["parent_id"]].append(child)
    merged, emitted = [], set()
    for child in children:                                   # keep the retrieval order
        pid = child.metadata["parent_id"]
        if len(by_parent[pid]) / parent_size(pid) >= ratio:
            if pid not in emitted:
                merged.append(load_parent(pid))
                emitted.add(pid)
        else:
            merged.append(child)
    return merged

Source: LangChain — Retrieval (custom retrieval steps).

In Spring AI the same logic is a DocumentPostProcessor, the post-retrieval hook of RetrievalAugmentationAdvisor:

AutoMergingPostProcessor.java — auto-merging as a post-retrieval step (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.indexing;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.ToIntFunction;

import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.postretrieval.document.DocumentPostProcessor;

public class AutoMergingPostProcessor implements DocumentPostProcessor {

    private final ToIntFunction<String> parentSize;
    private final Function<String, Document> loadParent;
    private final double ratio;

    public AutoMergingPostProcessor(ToIntFunction<String> parentSize, Function<String, Document> loadParent,
                                    double ratio) {
        this.parentSize = parentSize;
        this.loadParent = loadParent;
        this.ratio = ratio;
    }

    @Override
    public List<Document> process(Query query, List<Document> children) {
        Map<String, Integer> counts = new LinkedHashMap<>();
        for (Document child : children) {
            counts.merge((String) child.getMetadata().get("parent_id"), 1, Integer::sum);
        }
        List<Document> merged = new ArrayList<>();
        Set<String> emitted = new HashSet<>();
        for (Document child : children) {
            String parentId = (String) child.getMetadata().get("parent_id");
            if ((double) counts.get(parentId) / parentSize.applyAsInt(parentId) >= ratio) {
                if (emitted.add(parentId)) {
                    merged.add(loadParent.apply(parentId));
                }
            } else {
                merged.add(child);
            }
        }
        return merged;
    }
}

Register it with RetrievalAugmentationAdvisor.builder().documentPostProcessors(new AutoMergingPostProcessor(…​)).

Sentence-window retrieval

Index single sentences, each with the IDs of its neighbours, and return a window of sentences on each side of the matched sentence. Matching is precise (a sentence has one meaning) and the generator still gets context. It is the sentence-level version of context expansion, and works best on flowing prose rather than on lists or tables. In LangChain4j, the sentence detector inside DocumentBySentenceSplitter produces the sentences at indexing time (its split(String) returns one string per sentence, while split(Document) would pack several short sentences into one segment up to the size limit); the window is assembled after retrieval from the stored sentence index:

SentenceWindow.java — index sentences, return windows (Java, LangChain4j 1.20.2)
package com.example.docsassistant.l4j.indexing;

import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.splitter.DocumentBySentenceSplitter;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.query.Query;

public final class SentenceWindow {

    private SentenceWindow() {
    }

    /** One segment per sentence, numbered within its document so that neighbours can be found later. */
    public static List<TextSegment> sentences(Document document, String source) {
        String[] sentences = new DocumentBySentenceSplitter(300, 0).split(document.text());   // sentence detection only
        List<TextSegment> numbered = new ArrayList<>();
        for (int i = 0; i < sentences.length; i++) {
            numbered.add(TextSegment.from(sentences[i],
                    document.metadata().copy().put("source", source).put("sentence", i)));
        }
        return numbered;
    }

    /** Wraps a sentence retriever: each hit is replaced by the window of w sentences around it. */
    public static ContentRetriever windowed(ContentRetriever sentenceRetriever, int w,
                                            BiFunction<String, int[], String> loadRange) {
        return (Query query) -> sentenceRetriever.retrieve(query).stream()
                .map(hit -> {
                    var meta = hit.textSegment().metadata();
                    int i = meta.getInteger("sentence");
                    String window = loadRange.apply(meta.getString("source"), new int[] {Math.max(0, i - w), i + w});
                    return Content.from(TextSegment.from(window, meta));
                })
                .toList();
    }
}

Source: LangChain4j — RAG (Document Splitter, Content Retriever).

The same wrapper shape (a function over a retriever’s results) implements sentence windows in Python and, as a DocumentPostProcessor, in Spring AI.

Multi-vector: summary plus raw content

A table, a code listing or a long section embeds poorly as raw text. Index a model-written summary and return the raw content: the summary matches natural-language questions, the raw content gives the generator the exact numbers or code. This is the same MultiVectorRetriever layout as hypothetical questions, with one summary per item instead of several questions; Multimodal RAG uses it for tables and figures. Keep the summary prompt specific ("list every column and the range of values"), because a vague summary is a lossy index.

RAPTOR: recursive summary trees

RAPTOR (Recursive Abstractive Processing for Tree-Organized Retrieval) builds a tree over the corpus: embed the chunks, cluster them, summarise each cluster, embed the summaries, cluster those, and repeat until a few top-level summaries remain. All levels go into one index. A detailed question matches leaf chunks; a thematic question ("what are the main changes across the 4.x releases?") matches a higher-level summary that no single chunk could answer. The paper uses Gaussian mixture clustering over reduced embeddings; for documentation, the document hierarchy itself (sections, pages, components) is often a good enough "clustering" and far cheaper:

docsassistant/indexing/raptor_lite.py — a summary tree over the documentation hierarchy (Python, langchain 1.4.3)
import os
from collections import defaultdict

from langchain.chat_models import init_chat_model
from langchain_core.documents import Document

summariser = init_chat_model(os.environ["INDEXING_MODEL"], temperature=0)
LEVELS = ["section", "page", "component"]              # leaf chunks carry all three keys (full paths) in metadata


def summarise(texts: list[str]) -> str:
    return summariser.invoke(
        "Summarise the key facts, names and versions in these passages for search. Be specific.\n\n"
        + "\n\n---\n\n".join(texts)).text


def build_tree(chunks: list[Document]) -> list[Document]:
    """Return summary documents for every level; index them together with the leaf chunks."""
    nodes, summaries = chunks, []
    for level in LEVELS:
        groups: dict[str, list[Document]] = defaultdict(list)
        for node in nodes:
            groups[node.metadata[level]].append(node)
        nodes = [Document(page_content=summarise([n.page_content for n in members]),
                          metadata={**{k: v for k, v in members[0].metadata.items() if k != "chunk_id"},
                                    "chunk_id": f"raptor:{level}:{key}", "raptor_level": level,
                                    "children": [n.metadata["chunk_id"] for n in members]})
                 for key, members in groups.items()]
        summaries += nodes
    return summaries

Each summary gets its own chunk_id (raptor:<level>:<key>) instead of its first member’s, so citations, upserts and deletes by ID never confuse a summary with a leaf chunk, and the children of a page or component summary list the IDs of the summaries one level down. Apart from that ID, a summary inherits the metadata of its first member, including its ACL, which is only correct when every member shares the ACL. Group by ACL as well as by hierarchy (or summarise only within one ACL), otherwise a summary can leak content from documents the caller cannot read. Summaries also go stale when their children change: rebuild the affected path of the tree from the changed chunk upwards during incremental ingestion.

Contextual retrieval

A chunk such as "It now defaults to 64." is unretrievable on its own: the embedding does not know what "it" is. Contextual retrieval asks a model, once per chunk and with the whole document in the prompt, for one or two sentences that situate the chunk ("This chunk is from the pgvector tuning section of Vector Databases & RAG; it describes the default of ef_search."), and prepends that context to the chunk before embedding and before keyword indexing. Because the whole document is in every prompt, prompt caching of the document prefix makes the cost manageable (see Caching, Latency & Cost).

A cheaper, model-free variant is a structural context header: prepend the page title and heading breadcrumb from the parser (structure as metadata). The LangChain4j ingestor on Ingestion Pipelines & Freshness does exactly that with a TextSegmentTransformer. The model-generated version in Spring AI is a DocumentTransformer:

ContextualChunkTransformer.java — contextual retrieval at ingestion (Java, Spring AI 2.0.1)
package com.example.docsassistant.rag.indexing;

import java.util.List;
import java.util.function.Function;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;

public class ContextualChunkTransformer implements DocumentTransformer {

    private static final String PROMPT = """
            <document>
            {document}
            </document>
            Here is a chunk from the document:
            <chunk>
            {chunk}
            </chunk>
            Write one or two sentences that situate this chunk within the document, to improve search retrieval
            of the chunk. Answer only with those sentences.""";

    private final ChatClient chatClient;
    private final Function<Document, String> fullDocument;   // loads the parent document's text

    public ContextualChunkTransformer(ChatClient.Builder builder, Function<Document, String> fullDocument) {
        this.chatClient = builder.build();
        this.fullDocument = fullDocument;
    }

    @Override
    public List<Document> apply(List<Document> chunks) {
        return chunks.stream().map(chunk -> {
            String context = chatClient.prompt()
                    .user(u -> u.text(PROMPT)
                            .param("document", fullDocument.apply(chunk))
                            .param("chunk", chunk.getText()))
                    .call()
                    .content();
            return chunk.mutate().text(context + "\n\n" + chunk.getText())
                    .metadata("context", context).build();
        }).toList();
    }
}

Store the generated context separately in metadata as well as in the embedded text, so that the answer can show the original chunk without the added sentences.

Late chunking

Late chunking gets much of contextual retrieval’s benefit without a model call per chunk. A long-context embedding model encodes the whole document (or a long window) once, producing one contextualised vector per token; the chunk vectors are then obtained by mean-pooling the token vectors inside each chunk’s span:

where is the token embedding of token computed with the whole document as context, and the token span of chunk . Each chunk’s vector therefore "knows" what "it" refers to. The requirements are an embedding model with a long context window that exposes token-level outputs (not just the pooled vector), and control over pooling — which rules out most hosted embedding APIs and puts it outside what the frameworks' embedding abstractions expose. When the embedding model is self-hosted (see Small LLMs & Embeddings), late chunking is a change to the embedding service, invisible to the rest of the pipeline.

The patterns on this page — hypothetical questions per chunk, proposition and agentic chunking, auto-merging and sentence-window retrieval (Polzer, RAG with Python Cookbook, Chapters 4 and 7), and multi-vector indexing and RAPTOR (Oshin and Campos, Learning LangChain, Chapter 3) — are paraphrased from those books; see the bibliography.