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 |
|
A |
A |
Parent/child |
|
Same as above |
Same as above |
Auto-merging, sentence window |
Your own function over the vector store (below); LlamaIndex ships both |
|
|
Propositions, RAPTOR |
Your own ingestion step (below) |
Your own |
Your own ingestion step |
Contextual retrieval |
Your own ingestion step |
|
|
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:
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
Source: LangChain reference — langchain_classic
retrievers (MultiVectorRetriever).
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.
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]
Source: LangChain — Structured output.
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.
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:
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:
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:
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
Source: LangChain — Models and Sarthi et al., RAPTOR.
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:
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();
}
}
Source: Spring AI — ETL Pipeline (transformers) and Anthropic — Contextual Retrieval.
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.
References
-
LlamaIndex framework documentation (auto-merging and sentence-window retrievers)