Skip to content
  • 10 min read

LlamaIndex — Retrieval & Query Engines

A query engine is three replaceable stages: a retriever finds nodes, node postprocessors filter or reorder them, and a response synthesizer asks the LLM to answer from what is left. Keeping the stages separate is what makes RAG testable: retrieval can be asserted on without any LLM at all (05).

flowchart LR
  Q["query str / QueryBundle"] --> RET["retriever<br/>similarity_top_k, filters, mode"]
  RET -->|"list[NodeWithScore]"| PP["postprocessors<br/>cutoff, rerank, replace"]
  PP --> SYN["synthesizer<br/>response_mode"]
  SYN -->|"LLM calls"| RESP["Response<br/>.response, .source_nodes"]

Retrievers

retriever = index.as_retriever(similarity_top_k=5)          # default top_k is 2
hits = retriever.retrieve("how long does express shipping take")   # list[NodeWithScore]
for hit in hits:
    print(hit.node_id, round(hit.score, 3), hit.metadata["topic"], hit.get_content()[:60])

hits = await retriever.aretrieve("...")                     # async variant

retrieve accepts a string or a QueryBundle(query_str=..., custom_embedding_strs=[...]) when the text to embed differs from the text to ask. NodeWithScore exposes .node, .score, .node_id, .metadata and .get_content(). Scores are store-specific: cosine similarity in the in-memory store, but a distance-derived or fused score elsewhere, so compare scores only within one setup.

Metadata filters

from llama_index.core.vector_stores import FilterCondition, FilterOperator, MetadataFilter, MetadataFilters

filters = MetadataFilters(
    filters=[
        MetadataFilter(key="visibility", value="public", operator=FilterOperator.EQ),
        MetadataFilter(key="topic", value=["billing", "shipping"], operator=FilterOperator.IN),
        MetadataFilter(key="year", value=2025, operator=FilterOperator.GTE),
    ],
    condition=FilterCondition.AND,          # AND, OR, NOT
)
retriever = index.as_retriever(similarity_top_k=6, filters=filters)
engine = index.as_query_engine(similarity_top_k=6, filters=filters)
FilterOperator Meaning
EQ, NE equal, not equal (==, !=)
GT, GTE, LT, LTE numeric or date comparison
IN, NIN value in / not in a list
ANY, ALL, CONTAINS list-valued metadata
TEXT_MATCH, TEXT_MATCH_INSENSITIVE substring on text metadata
IS_EMPTY metadata key missing or empty

Filters are applied before the top-k cut inside stores that support them, so similarity_top_k=3 with a filter still returns up to three allowed nodes. Operator support varies by vector store, so test the filters you rely on against the real store. Metadata used for filtering must exist on every node of the document (set it on Document.metadata before chunking).

Hybrid, sparse and fusion

Technique How
Store-side hybrid index.as_retriever(vector_store_query_mode="hybrid", similarity_top_k=5) on stores that implement it: PGVectorStore(hybrid_search=True), QdrantVectorStore(enable_hybrid=True) and others. alpha= weights dense against sparse where the store honours it (Postgres hybrid search does not accept alpha)
MMR vector_store_query_mode="mmr", vector_store_kwargs={"mmr_threshold": 0.3} trades similarity for diversity (works with the in-memory store)
BM25 + vectors BM25Retriever (llama-index-retrievers-bm25) fused with a vector retriever by QueryFusionRetriever
Multi-query QueryFusionRetriever(num_queries=4) asks the LLM for query variants, retrieves for each and fuses
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.retrievers.bm25 import BM25Retriever

nodes = list(index.docstore.docs.values())                      # in-memory index; use your own node list otherwise
bm25 = BM25Retriever.from_defaults(nodes=nodes, similarity_top_k=5)
fusion = QueryFusionRetriever(
    [index.as_retriever(similarity_top_k=5), bm25],
    llm=llm,                       # resolved even when num_queries=1
    similarity_top_k=5,
    num_queries=1,                 # 1 = no LLM query rewriting; >1 calls the LLM
    mode="reciprocal_rerank",      # simple, reciprocal_rerank, relative_score, dist_based_score
    use_async=False,
)
engine = RetrieverQueryEngine.from_args(fusion, llm=llm)

BM25Retriever ranks by exact terms (IDs, error codes, product names) that embeddings blur. Rank-based fusion scores are small numbers (about 0.03 for reciprocal rank), not similarities.

Node Postprocessors

Postprocessors run on the retrieved list before synthesis. Retrieve wide (similarity_top_k=10), then narrow.

Postprocessor Does Cost
SimilarityPostprocessor(similarity_cutoff=0.7) Drops nodes below a score none
KeywordNodePostprocessor(required_keywords=[...], exclude_keywords=[...]) Keyword filter (needs spacy) none
SentenceTransformerRerank(model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=3) Local cross-encoder rerank (sentence-transformers) CPU/GPU
LLMRerank(top_n=3, choice_batch_size=5) LLM rates each batch of nodes for relevance (1-10) one call per batch
StructuredLLMRerank(...) Same, with structured output one call per batch
MetadataReplacementPostProcessor(target_metadata_key="window") Swaps node text for a metadata value (sentence window) none
PrevNextNodePostprocessor(docstore=..., num_nodes=1, mode="both") Adds neighbouring nodes none
LongContextReorder() Moves best nodes to the start and end of the prompt none
PIINodePostprocessor(llm=...), NERPIINodePostprocessor() Masks PII in node text (LLM or NER model) LLM or model per node
SentenceEmbeddingOptimizer, TimeWeightedPostprocessor, FixedRecencyPostprocessor Trim sentences by relevance; weight or keep the newest nodes by a date metadata field varies
Hosted rerankers (llama-index-postprocessor-cohere-rerank and others) CohereRerank(top_n=3) API call
from llama_index.core.postprocessor import LLMRerank, SimilarityPostprocessor

engine = index.as_query_engine(
    similarity_top_k=10,
    node_postprocessors=[
        SimilarityPostprocessor(similarity_cutoff=0.2),
        LLMRerank(llm=llm, top_n=3, choice_batch_size=5),      # scores replace similarity scores
    ],
)

LLMRerank expects the model to answer Doc: 2, Relevance: 9 lines and replaces node.score with the relevance number. SentenceTransformerRerank defaults to trust_remote_code=True; pass False for models that do not need it. A reranker only reorders what retrieval returned, so recall is bounded by similarity_top_k.

Response Synthesizers

get_response_synthesizer(response_mode=...) decides how retrieved text becomes prompts. The default is compact.

response_mode Behaviour LLM calls (n nodes)
compact (default) Packs nodes into as few prompts as the context window allows, then refines across them 1 when it fits
refine Answers from node 1, then refines the answer with each further node n, sequential
tree_summarize Summarises batches, then summarises the summaries; good for "summarise" queries fewer than n, then one more to merge
simple_summarize Truncates all text to one prompt 1 (can lose text)
accumulate One answer per node, concatenated as Response 1: ... n
compact_accumulate Accumulate over packed chunks fewer than n
no_text No LLM call; returns only source_nodes 0
context_only Returns the joined node text as the "answer" 0
generation Ignores the context and asks the LLM directly 1
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import get_response_synthesizer

synth = get_response_synthesizer(llm=llm, response_mode="tree_summarize", use_async=True)
engine = RetrieverQueryEngine(
    retriever=index.as_retriever(similarity_top_k=8),
    response_synthesizer=synth,
    node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.2)],
)
response = engine.query("Summarise our shipping options")

index.as_query_engine(**kwargs) builds the same thing: retriever kwargs (similarity_top_k, filters, vector_store_query_mode), node_postprocessors, and synthesizer kwargs (response_mode, streaming, output_cls, text_qa_template, refine_template, use_async). Build RetrieverQueryEngine yourself when the retriever is not a plain vector retriever.

no_text and context_only still resolve Settings.llm

The synthesizer base class reads Settings.llm when it is created, even in modes that never call it. Without an API key this raises. Set Settings.llm = None (a MockLLM) or use the retriever alone when you only need sources.

What the LLM sees. as_query_engine fills the text_qa_template with node text including metadata (MetadataMode.LLM): chunks arrive as topic: shipping\nvisibility: public\n\n.... Override prompts with engine.update_prompts({"response_synthesizer:text_qa_template": PromptTemplate(...)}); engine.get_prompts() lists the keys. CitationQueryEngine sends text without metadata by default.

Streaming, async and sources

engine = index.as_query_engine(streaming=True, similarity_top_k=3)
response = engine.query("express shipping time")            # StreamingResponse
for token in response.response_gen:                          # or response.print_response_stream()
    print(token, end="")

response = await index.as_query_engine().aquery("...")       # async; Response
response.source_nodes          # list[NodeWithScore]
response.metadata              # {node_id: node metadata}

Always return source_nodes (ids, scores, file names) next to the answer: that is the evidence a reviewer or a test needs.

Choosing Between Several Sources

RouterQueryEngine

An LLM picks one (or several) of a set of query engines by their descriptions.

from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector, PydanticSingleSelector
from llama_index.core.tools import QueryEngineTool

tools = [
    QueryEngineTool.from_defaults(billing.as_query_engine(), name="billing", description="Refund and billing policy"),
    QueryEngineTool.from_defaults(shipping.as_query_engine(), name="shipping", description="Shipping times and costs"),
]
router = RouterQueryEngine(selector=LLMSingleSelector.from_defaults(), query_engine_tools=tools)
response = router.query("how fast is express?")
response.metadata["selector_result"]          # which tool was chosen and why

LLMSingleSelector asks for a JSON choice in plain text; PydanticSingleSelector uses a function call (needs a function-calling LLM); LLMMultiSelector / PydanticMultiSelector can pick several and the engine summarises their answers. Selection quality depends on the descriptions: write them like tool descriptions.

SubQuestionQueryEngine

Breaks a compound question into sub-questions, sends each to the named tool, and synthesises.

from llama_index.core.query_engine import SubQuestionQueryEngine
from llama_index.core.question_gen import LLMQuestionGenerator

engine = SubQuestionQueryEngine.from_defaults(
    query_engine_tools=tools,
    question_gen=LLMQuestionGenerator.from_defaults(llm=llm),   # without this, from_defaults raises ImportError
    use_async=True,
)
engine.query("Compare the express delivery time with the refund window")

Without question_gen, from_defaults imports llama_index.question_gen.openai and raises an ImportError that suggests installing llama-index-question-gen-openai. That package (0.3.1) requires llama-index-core<0.13, so installing it downgrades core. Always pass LLMQuestionGenerator, which expects the model to return a JSON list of {"sub_question", "tool_name"} objects.

Chat Engines

index.as_chat_engine(chat_mode=...) adds conversation memory around retrieval. The default mode "best" resolves to condense_plus_context.

chat_mode Flow LLM calls per turn
simple LLM only, no retrieval (SimpleChatEngine) 1
context Retrieve with the raw user message, put the text in the system prompt 1
condense_plus_context Rewrite history plus message into a standalone question, retrieve for it, answer with context 2 (1 on the first turn: nothing to condense)
condense_question Rewrite to a standalone question, then run the query engine 2
react, openai Removed (ValueError); use an agent with a query engine tool (03) n/a
from llama_index.core.memory import Memory

chat = index.as_chat_engine(
    chat_mode="condense_plus_context",
    llm=llm,
    memory=Memory.from_defaults(session_id="user-42", token_limit=3000),
    system_prompt="You are a support assistant. Answer only from the context.",
    similarity_top_k=3,
    node_postprocessors=[SimilarityPostprocessor(similarity_cutoff=0.2)],
)
r = chat.chat("express shipping time?")
r = await chat.achat("and the cost?")          # condensed to a standalone question first
print(r.response, [n.node_id for n in r.source_nodes])
for token in chat.stream_chat("thanks").response_gen:
    print(token, end="")
chat.chat_history                                  # list[ChatMessage]
chat.reset()                                       # clear this conversation

A chat engine holds one conversation. Create one engine (and one Memory with its own session_id) per user, never share one across users. ChatMemoryBuffer.from_defaults(token_limit=...) still works but is deprecated in favour of Memory; Memory keeps messages in a SQL store (SQLite in memory by default; pass async_database_uri= or chat_store= to persist, 03). Set skip_condense=True on CondensePlusContextChatEngine to save the extra call, and condense_prompt= to tune the rewrite.

Citations and Structured Output

from pydantic import BaseModel

from llama_index.core.query_engine import CitationQueryEngine

cite = CitationQueryEngine.from_args(index, llm=llm, similarity_top_k=3, citation_chunk_size=512)
response = cite.query("express shipping time")
print(response)                                   # "Express takes 1 to 2 days [1]."
for n in response.source_nodes:                   # numbered "Source 1:\n..." chunks, new node ids
    print(n.node.get_content()[:80])


class Shipping(BaseModel):
    days_min: int
    days_max: int
    price_usd: float | None = None


engine = index.as_query_engine(llm=llm, output_cls=Shipping, similarity_top_k=1)
response = engine.query("express shipping")       # PydanticResponse
response.response                                 # Shipping(days_min=1, days_max=2, price_usd=15.0)

CitationQueryEngine re-splits the retrieved nodes into citation_chunk_size pieces and numbers them, so citation ids point at the new pieces; map back with node.ref_doc_id or metadata. With output_cls, response.response is the Pydantic object (not a string) and str(response) is its JSON. With an OpenAI LLM the schema is sent as a JSON-schema response_format. For one-off extraction without retrieval use llm.as_structured_llm(Shipping).complete("..."); the parsed object is .raw.

Advanced Retrieval Patterns

Pattern Idea Building blocks
Sentence window Embed single sentences, hand the LLM the surrounding window SentenceWindowNodeParser, MetadataReplacementPostProcessor(target_metadata_key="window")
Auto-merging Index small leaf chunks; when most children of a parent are retrieved, return the parent HierarchicalNodeParser, get_leaf_nodes, AutoMergingRetriever(retriever, storage_context, simple_ratio_thresh=0.5)
Small-to-big Search small chunks, answer from the bigger parent IndexNode(index_id=parent_id), RecursiveRetriever
HyDE Embed an LLM-written hypothetical answer instead of the question HyDEQueryTransform(include_original=True) + TransformQueryEngine
Multi-step Decompose a question step by step, using earlier answers StepDecomposeQueryTransform + MultiStepQueryEngine
Multi-query + fusion Several rewrites, fused ranking QueryFusionRetriever(num_queries=4)
Rerank Retrieve wide, rerank narrow LLMRerank, SentenceTransformerRerank
Metadata routing Pick the index or the filter by question type RouterQueryEngine, filters from the user's role
from llama_index.core import VectorStoreIndex
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core.postprocessor import MetadataReplacementPostProcessor

window_parser = SentenceWindowNodeParser.from_defaults(window_size=1, window_metadata_key="window")
nodes = window_parser.get_nodes_from_documents(documents)         # text = one sentence; metadata["window"] = three
index = VectorStoreIndex(nodes, embed_model=embed_model)
engine = index.as_query_engine(
    llm=llm,
    similarity_top_k=3,
    node_postprocessors=[MetadataReplacementPostProcessor(target_metadata_key="window")],
)
from llama_index.core import StorageContext
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes
from llama_index.core.retrievers import AutoMergingRetriever

all_nodes = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128]).get_nodes_from_documents(documents)
storage = StorageContext.from_defaults()
storage.docstore.add_documents(all_nodes)                          # parents must be in the docstore
base = VectorStoreIndex(get_leaf_nodes(all_nodes), storage_context=storage, embed_model=embed_model)
retriever = AutoMergingRetriever(base.as_retriever(similarity_top_k=6), storage, simple_ratio_thresh=0.5)

Auto-merging needs the parent nodes in a docstore. With a remote vector store that keeps text in the store, keep your own docstore (for example persisted next to it) for the parents.

Add patterns one at a time and measure each against a golden set (04); every extra stage adds latency and LLM calls, and a pattern that helps one corpus can hurt another. A side-by-side with LangChain retrievers is in LangChain RAG & Retrieval.

Common Mistakes

Mistake Effect Fix
Default similarity_top_k=2 Answers miss facts spread across chunks Raise it, then rerank down
Reranker with similarity_top_k equal to top_n Nothing to rerank Retrieve 3-5x more than top_n
Score cutoff tuned on one query Empty retrievals on others Tune on a golden set; fall back when empty
One shared chat engine for all users Conversations leak across users One engine and Memory per session
Expecting filters to be a security boundary on their own Missing filter returns everything Build filters server-side from the caller's identity (05)
response_mode="refine" on 20 nodes 20 sequential calls compact or tree_summarize
SubQuestionQueryEngine.from_defaults without question_gen ImportError pointing at a package that pins an old core Pass LLMQuestionGenerator
Trusting citation numbers [1] refers to a re-chunked piece Map to ref_doc_id and metadata

See also