In 2020, RAG was one retrieval call. In 2026, agents search, check and cite in a loop.
Its weights stop at the training cutoff. It cannot see last week's change. It gives no source.
It retrieves the current policy page. It answers from that page and links to it.
“good context engineering means finding the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome.”
01 Why RAG
02 Ingestion and chunking
03 Embeddings and vector indexes
04 Retrieval quality and context assembly
05 Agentic RAG patterns
06 Research agents, RL-trained search and MCP
07 Graph and structured RAG
08 Multimodal, code and memory
09 Evaluation and security
10 Production, managed RAG and best practices (Oct 2026)
Models need knowledge they were never trained on.
The RAG answer. Keep knowledge in an external index that you can swap and filter by permission. At query time, retrieve the top-k passages. Condition the answer on them.
Update = re-index, not retrain.
swap the corpus
filter by user
re-index on change
The query encoder makes q(x). MIPS returns the top-k passages z. BART writes the answer y token by token.
RAG-Sequence. One passage conditions the whole answer: p(y|x) ≈ Σz p(z|x) · Πi p(yi|x, z, y<i)
RAG-Token. Each token can use a different passage: p(y|x) ≈ Πi Σz p(z|x) · p(yi|x, z, y<i). Both marginalize over the top-k.
"we can update RAG's world knowledge by simply replacing its non-parametric memory."
Lineage: DrQA 2017 → ORQA 2019 → REALM, DPR 2020 → RAG 2020 → FiD, RETRO 2021 → prompt RAG with frozen LLMs → agentic RAG
Paper Lewis et al., arXiv 2005.11401 (NeurIPS 2020)Failure rate = 1 − recall@20. Anthropic's baseline misses the gold chunk on 0 of queries. Badges show the chapter that teaches each box.
Retrieval quality is decided at ingestion, before the first query. Tables, columns, scans and charts break naive text extraction.
# Revenue by region Revenue rose in EMEA and APAC. | Region | Q1 | Q2 | |--------|-----|-----| | EMEA | 4.1 | 4.6 | | APAC | 3.2 | 3.9 |
Reading order is kept. The table stays one unit.
| Year | Units | |------|-------| | Y1 | 36 | | Y2 | 54 | | Y3 | 66 |
A VLM reads the chart and writes a table. It costs more per page, so send only hard pages here.
from docling.document_converter import DocumentConverter
source = "https://arxiv.org/pdf/2408.09869" # local path or URL
converter = DocumentConverter()
result = converter.convert(source) # layout, tables, OCR
md = result.document.export_to_markdown() # -> Markdown
print(md)
# CLI: default pipeline, then the GraniteDocling VLM
# $ docling https://arxiv.org/pdf/2206.01062
# $ docling --pipeline vlm --vlm-model granite_docling <url>
| Parser | What it gives you (Oct 2026) |
|---|---|
| Repo Docling | MIT, LF AI & Data (from IBM Research Zurich). PDF, DOCX, PPTX, XLSX, HTML, EPUB, images, audio, LaTeX. Layout, reading order, tables, OCR, MCP server. Python 3.10+. |
| Doc Unstructured | Typed elements: Title, NarrativeText, Table, ListItem. by_title never spans two sections. max_characters 500. Tables stay isolated; orig_elements kept. |
| Doc LlamaParse | v2 tiers: fast, cost_effective, agentic, agentic_plus. |
| Doc Gemini | 1,000 pages and 50 MB per PDF; 258 tokens per page. Pages to Markdown, or no chunking for a small corpus. |
Chroma: “default settings for certain popular chunking strategies can lead to relatively poor performance.” Less overlap gives a better IoU.
2026: no single chunk size fits every query. AI21, “Stop Chunking Like It's 2022” (AI Engineer World's Fair 2026): keep several indexes.
Why chunk: embedders take 2K–32K tokens, one vector averages away detail, context costs money, precision needs passage-level units.
| Strategy | How it cuts · trade-off |
|---|---|
| Fixed-size | Every N tokens, overlap O. Fast; cuts sentences and tables. |
| Recursive | \n\n → \n → ". " → " " until ≤ N. Strong default; ignores meaning. |
| Structure | Markdown/HTML headers, code AST, by_title. Needs a good parser. |
| Semantic | Embed sentences; cut where adjacent similarity drops (95th-percentile breakpoint). Extra embedding cost. |
| LLM-based | An LLM proposes the boundaries. Best recall in Chroma; slow, costly. |
| Parent-child | Index small, return the parent. LlamaIndex HierarchicalNodeParser [2048, 512, 128] + AutoMergingRetriever; sentence window 3; LangChain ParentDocumentRetriever. |
BEIR nDCG@10: NFCorpus 23.46 → 29.98, SciFact 64.20 → 66.10. No extra training. Needs token-level access to a long-context embedder.
The LLM reads the full document plus one chunk and writes 50–100 tokens of context. Prepend it before both embedding and BM25.
“The company's revenue grew by 3% over the previous quarter.”
Which company? Which quarter? “traditional RAG solutions remove context when encoding information.”
Blog Introducing Contextual Retrieval (19 Sep 2024)Prompt caching stores the document once. Every chunk call reads it from the cache. “All these benefits stack.”
import anthropic
client = anthropic.Anthropic()
def situate(doc: str, chunk: str) -> str:
msg = client.messages.create(
model="claude-haiku-4-5", # illustrative; blog used Claude 3 Haiku
max_tokens=150,
system=[{"type": "text",
"text": f"<document>\n{doc}\n</document>",
"cache_control": {"type": "ephemeral"}}], # cached once
messages=[{"role": "user", "content":
f"Here is the chunk we want to situate within "
f"the whole document\n<chunk>\n{chunk}\n</chunk>\n"
"Please give a short succinct context to situate "
"this chunk within the overall document for the "
"purposes of improving search retrieval of the "
"chunk. Answer only with the succinct context "
"and nothing else."}])
return msg.content[0].text + "\n\n" + chunk # index for dense AND BM25
AutoMergingRetriever, LangChain ParentDocumentRetriever). Cost: more storage and more bookkeeping.An encoder maps meaning to a vector. Similar meanings land near each other. An index finds the nearest vectors fast.
Use cosine, dot product on normalized vectors, or L2. In 2026 one space can hold text, images, audio, video and PDFs.
Exact k-NN costs O(N × d) per query. ANN gives up a little recall for orders of magnitude more speed.
from google import genai
client = genai.Client()
q = client.models.embed_content(
model="gemini-embedding-2",
contents="task: question answering | "
"query: What is late chunking?",
config={"output_dimensionality": 768}) # MRL
d = client.models.embed_content(
model="gemini-embedding-2",
contents="title: Late Chunking | "
"text: Late chunking pools ...",
config={"output_dimensionality": 768})
| Model | Dims · MRL | Input | Key fact |
|---|---|---|---|
| gemini-embedding-001 | 3072 · 128–3072 | 2,048 | task_type; MMTEB 68.32 |
| gemini-embedding-2 | 3072 · 128–3072 | 8,192 | 5 modalities; GA ~Apr 2026 |
| text-embedding-3-large | 3072 · dimensions | 8,192 | Jan 2024; MTEB 64.6 |
| Voyage 4 family | 1024 · 256–2048 | 32K | shared space; int8, binary |
| Cohere embed-v4.0 | 1536 · 256–1536 | 128K | text and images |
| Qwen3-Embedding-8B | 4096 · MRL | 32K | Apache-2.0; MMTEB 70.58 |
gemini-embedding-2: task in the text, not compatible with 001, Batch API at 50%. MTEB sub-boards (MMTEB, Eng v2, Code) rank differently; Voyage does not submit. Vendor-reported (Google Dev Blog, 30 Apr 2026): Nuuly Match@20 60% → ~87%; Supermemory +40% Recall@1; Harvey +3% Recall@20. Docs Gemini API embeddings Board MTEB leaderboard
Train the encoder so that every prefix (the first 64, 128, 256 … dims) is itself a good embedding. Sum the loss over the nested sizes.
“up to 14x smaller embedding size … at the same level of accuracy”
dimensions parameteroutput_dtype float, int8, uint8, binary, ubinaryHNSW (Malkov & Yashunin, TPAMI 2018) is the default in pgvector, Qdrant, Weaviate, Milvus and FAISS.
Split a vector into M sub-vectors; code each with 2nbits centroids. 768-d float32: 3,072 B → PQ96x8: 96 B.
PQ codes in RAM; graph and full vectors on SSD. SIFT1B on a 64 GB box: >5,000 QPS, <3 ms, 95%+ 1-recall@1.
Anisotropic quantization. SOAR spills each vector to a second, near-orthogonal cluster. Vertex AI Vector Search and AlloyDB use it.
import faiss, numpy as np
d, nlist, m = 768, 4096, 96 # 96 x 8 bits = 96 B
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, 8)
index.train(xt) # representative sample
index.add(xb)
index.nprobe = 16 # query-time knob
D, I = index.search(xq, 10)
# graph index instead of IVF-PQ
hnsw = faiss.IndexHNSWFlat(d, 32) # M = 32 neighbours
hnsw.hnsw.efSearch = 64
M: graph degree → memory and recallefConstruction: build quality → build timeefSearch: beam width → latency vs recallHybrid search gives recall, reranking gives precision, and context assembly decides use.
BM25 wins. Dense vectors miss exact tokens: error codes, SKUs, names and rare acronyms.
Dense wins. "car" ≈ "automobile". BM25 misses synonyms and paraphrase.
Both hit. The failures barely overlap, so the union raises recall. BEIR (NeurIPS 2021): "BM25 is a robust baseline."
SPLADE (Formal et al., SIGIR 2021): a BERT MLM head projects each token onto the whole 30,522-term vocabulary. Log-saturation and a FLOPS regulariser keep a few hundred weights. Expansion adds "vehicle" and "automobile" to a "car" passage.
It is still an inverted-index vector: Lucene, Elastic, OpenSearch, Qdrant sparse. Sentence Transformers: SparseEncoder("naver/splade-cocondenser-ensembledistil").
Score fusion, if you fuse scores: Weaviate relativeScoreFusion (default since v1.24) min-max normalises, then weights by alpha (0.5 default, 1 = pure vector, 0 = pure BM25). Qdrant DBSF normalises each list with mean ± 3σ. Rank fusion is on the next slide.
RRFscore(d) = Σ 1 / (k + rank_r(d)) over all rankers r. Only ranks count, so BM25 (unbounded) and cosine (−1..1) need no calibration.
Pilot MAP: k=0 .2072 · k=60 .2145 · k=500 .2098. "k = 60 was fixed during a pilot investigation and not altered." RRF beats Condorcet, CombMNZ and the best single system by 4–5% on average.
limit ≥ the main limit + offset. If you do not, the result can come back empty.
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
bm25 = ["A", "C", "E"]; dense = ["D", "B", "A"]
print(rrf([bm25, dense])[0]) # ('A', 0.03226...)
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
results = client.query_points(
collection_name="my_collection",
prefetch=[
models.Prefetch(query=models.SparseVector(indices=[1, 42],
values=[0.22, 0.8]), using="sparse", limit=20),
models.Prefetch(query=[0.01, 0.45, 0.67], using="dense", limit=20),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)), # k param since v1.16.0
limit=10,
)
# Weighted RRF (v1.17.0+): models.Rrf(weights=[3.0, 1.0])
MultiVectorEncoder.rerank-v5.0-pro / -fast: 32k context, 100+ languages, adds relevant_spans. rerank-v3.5: 4k. Voyage rerank-3 / rerank-3-lite: 32K.BAAI/bge-reranker-v2-m3 (0.6B, multilingual). jina-reranker-v3 (0.6B on Qwen3, listwise over 64 docs in one 131K context, BEIR nDCG@10 61.94, CC BY-NC 4.0).Anthropic: retrieve 150, rerank, keep 20. Cost is one API round-trip or one GPU pass per pair. Cap the candidates. Keep docs ≤ max_tokens_per_doc (Cohere default 4096).
import cohere
co = cohere.ClientV2()
resp = co.rerank(
model="rerank-v5.0-pro", # or rerank-v5.0-fast, rerank-v3.5
query="What is the capital of the United States?",
documents=["Carson City is the capital city of Nevada.",
"Washington, D.C. is the capital of the United States.",
"Capital gains are profits from the sale of an asset."],
top_n=2,
relevant_spans={"mode": "enabled", "threshold": 0.1,
"max_spans": 3}, # v5+ only: spans behind the score
)
for r in resp.results:
print(r.index, r.relevance_score)
LangChain default: "generate 3 different versions".
Multi-query + RRF. Repo: Raudaschl/rag-fusion.
ACL 2023. The "dense bottleneck" filters fake detail.
PaLM-2L: TimeQA +27%, Chemistry +11%.
Least-to-Most, Self-Ask. The seed of agentic RAG.
"sci-fi movies rated above 8.5 from the 90s" → query="sci-fi" + filter rating > 8.5 AND 1990 <= year < 2000 (SelfQueryRetriever + AttributeInfo). "what about its price?" → one standalone query from the chat history.
Rule: apply tenant and ACL filters server-side and deterministically. Never let the LLM set them.
def hyde_search(question, llm, embed, index, k=5):
fake_doc = llm("Write a short passage that answers the question.\n"
f"Question: {question}\nPassage:")
vec = embed(fake_doc) # embed the answer-shaped text
return index.search(vec, k) # real docs near the fake one
from langchain_classic.retrievers.multi_query import MultiQueryRetriever
mq = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(search_kwargs={"k": 5}),
llm=ChatGoogleGenerativeAI(model="gemini-2.5-flash"),
include_original=True) # default False
Lost in the Middle (Liu et al., TACL 2024): accuracy is highest when the relevant document is at the start or the end. In the middle, GPT-3.5-Turbo fell below its closed-book score. More documents made it worse than none.
Context Rot (Chroma, 14 Jul 2025, 18 models incl. Claude 4, GPT-4.1, Gemini 2.5, Qwen3): models "do not use their context uniformly". "Even a single distractor reduces performance." Shuffled haystacks beat coherent ones. Note: Chroma sells retrieval infrastructure. Kelly Hong: "Simply having the right information in the context is not enough."
LongContextReorder).Anthropic prompt-cache price, × base input
Gemini: implicit caching is on by default for 2.5+ (minimum 2,048 / 4,096 tokens). Explicit client.caches.create has a 1 h default TTL. It is not supported on Gemini 4 Argon and later. Anthropic: up to 4 breakpoints, minimum 512–4,096 tokens by model.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-opus-5-5", max_tokens=1024,
system=[{"type": "text", "text": BIG_KNOWLEDGE_BASE,
"cache_control": {"type": "ephemeral", "ttl": "1h"}}],
messages=[{"role": "user", "content": question}])
print(resp.usage.cache_read_input_tokens) # billed at the read rate
"good context engineering means finding the smallest possible set of high-signal tokens that maximize the likelihood of some desired outcome." Anthropic, 29 Sep 2025
Definition: "the set of strategies for curating and maintaining the optimal set of tokens (information) during LLM inference." LangChain (2 Jul 2025) names four buckets: Write, Select, Compress, Isolate. RAG is Select. Sub-agents return "a condensed, distilled summary of its work (often 1,000-2,000 tokens)".
Just-in-time retrieval: the agent keeps "lightweight identifiers (file paths, stored queries, web links, etc.)" and loads the content through tools when it needs it. A hybrid strategy loads some data up front and lets the agent explore the rest (suited to legal or finance work).
Practitioners: Hamel Husain and Ben Clavié ("Stop Saying RAG Is Dead"): naive single-vector search is dead, retrieval is not. Bischof and Chaurasia: "Stop searching for the perfect embedding. Build specialized representations and route intelligently."
alpha, Qdrant DBSF) only when you can tune it on labels.max_tokens_per_doc, default 4096). Use a fast tier (rerank-v5.0-fast, rerank-3-lite). Batch the pairs on a GPU. Check that a smaller candidate set keeps recall.Classic RAG is one fixed pass. The pipeline retrieves once, also when the question does not need it. Nothing checks the retrieved evidence before the model uses it.
Agentic RAG is a policy. An LLM decides whether to retrieve, where (vector DB, SQL, web, API, MCP server), what to query (rewrite, decompose), if the evidence is good enough (grade, reflect) and when to stop.
Agentic RAG works by “embedding autonomous AI agents into the RAG pipeline.”
RAG: rewrite → retrieve → answer
RAG: source router (vector, SQL, web)
RAG: multi-query fan-out, then fuse
RAG: deep research (lead plans, workers search)
RAG: grade → rewrite loop (CRAG-style)
A workflow is a fixed code path with LLM steps. An agent chooses its own path. When you know the path, use a workflow: it is cheaper to test, trace and cap.
Survey taxonomy (Singh et al.): 4 dimensions, 4 patterns, 7 architecture families. The 5 workflows come from Anthropic's “Building effective agents”.
class Route(BaseModel):
source: Literal["vector", "sql", "web", "none"]
route = llm.with_structured_output(Route).invoke(q)
none plus a clarifying question.Training labels come from the strategy that got the answer right.
"docs_v2", and the call fails. An enum stops this.Per segment: one continuation per passage, then keep the best by a weighted critique score. Self-RAG 7B and 13B beat ChatGPT and retrieval-augmented Llama2-chat on open-domain QA, reasoning and fact verification.
IRCoT Trivedi et al., ACL 2023
Each new chain-of-thought sentence becomes the next query. Up to +21 points retrieval and +15 points QA on HotpotQA, 2WikiMultihopQA, MuSiQue and IIRC.
FLARE Jiang et al., EMNLP 2023
Draft the next sentence. If a token has low confidence, use the draft as the query and regenerate the sentence.
DRAGIN Su et al., ACL 2024
RIND decides when: entropy × attention from later tokens × stopword filter > θ. QFS decides what: the tokens in the whole context that the trigger token attends to most. It needs logits and attention, so it cannot run on a closed API.
DRAG / IterDRAG Yue et al., Google DeepMind, ICLR 2025
DRAG adds many documents and in-context demos. IterDRAG splits the question into sub-queries with interleaved retrieval (max 5 iterations). Up to 58.9% over standard RAG: spend inference compute on retrieval.
graph.get_graph().draw_mermaid() in the docs.
@tool
def retrieve_blog_posts(query: str) -> str:
"""Search and return information about Lilian Weng blog posts."""
return "\n\n".join(d.page_content for d in _get_retriever().invoke(query))
class GradeDocuments(BaseModel):
binary_score: str = Field(description="'yes' if relevant, 'no' if not")
def grade_documents(state) -> Literal["generate_answer", "rewrite_question"]:
q, ctx = state["messages"][0].content, state["messages"][-1].content
msg = GRADE_PROMPT.format(question=q, context=ctx)
r = response_model.with_structured_output(GradeDocuments).invoke(
[{"role": "user", "content": msg}])
return "generate_answer" if r.binary_score == "yes" else "rewrite_question"
wf = StateGraph(MessagesState)
wf.add_node(generate_query_or_respond)
wf.add_node("retrieve", ToolNode([retrieve_blog_posts]))
wf.add_node(rewrite_question); wf.add_node(generate_answer)
wf.add_edge(START, "generate_query_or_respond")
wf.add_conditional_edges("generate_query_or_respond",
lambda s: "tools" if s["messages"][-1].tool_calls else END,
{"tools": "retrieve", END: END})
wf.add_conditional_edges("retrieve", grade_documents) # CRAG-style gate
wf.add_edge("rewrite_question", "generate_query_or_respond") # loop
wf.add_edge("generate_answer", END); graph = wf.compile()
vector | sql | web | none with structured output, so it cannot invent a source. Add an Adaptive-RAG complexity gate: simple questions skip retrieval, complex ones get a multi-step loop. Trace every route and evaluate routing accuracy separately from answer quality.Deep research agents spend many tokens on many searches to answer one hard question.
“browsing alone is not sufficient—models must also be capable of reasoning strategically, identifying relevant search paths, and interpreting retrieved content.”
import os
from google.adk.agents import Agent
from google.adk.tools.agent_tool import AgentTool
from google.adk.tools.retrieval.vertex_ai_rag_retrieval import (
VertexAiRagRetrieval)
from vertexai.preview import rag
ask_vertex_retrieval = VertexAiRagRetrieval(
name="retrieve_rag_documentation",
description="Retrieve documentation from the RAG corpus.",
rag_resources=[rag.RagResource(
rag_corpus=os.environ.get("RAG_CORPUS"))],
similarity_top_k=10, vector_distance_threshold=0.6)
kb_agent = Agent(model="gemini-flash-latest", name="rag_agent",
instruction="Fetch docs with the tool, then answer from them.",
tools=[ask_vertex_retrieval]) # the tool stays alone here
kb_tool = AgentTool(agent=kb_agent) # mixes with other tools
from google.adk.agents import LlmAgent, LoopAgent, SequentialAgent
from google.adk.tools.tool_context import ToolContext
def exit_loop(tool_context: ToolContext):
"""Call only when the evidence is sufficient."""
tool_context.actions.escalate = True # stops the LoopAgent
return {}
grader = LlmAgent(name="Grader", model="gemini-2.5-flash",
include_contents="none", output_key="critique",
instruction="Judge if {evidence} fully answers {question}. "
"Reply 'SUFFICIENT' or list gaps.")
researcher = LlmAgent(name="Researcher", model="gemini-2.5-flash",
output_key="evidence", tools=[exit_loop, kb_tool, web_tool],
instruction="If {critique} is 'SUFFICIENT' call exit_loop; "
"else search to fill the gaps.")
loop = LoopAgent(name="CorrectiveLoop",
sub_agents=[grader, researcher], max_iterations=4)
pipeline = SequentialAgent(name="AgenticRAG",
sub_agents=[initial_retriever, loop, writer])
AgentTool.o3-deep-research / o4-mini-deep-research with web_search_preview, file_search (max 2 vector stores), code_interpreter. Shut down 2026-07-23; replacement: gpt-5.6-sol.deep-research-preview-04-2026 and a -max- variant; 1,048,576 input / 65,536 output tokens; MCP servers and File Search.
import time
from google import genai
client = genai.Client()
interaction = client.interactions.create(
input="Research the history of Google TPUs.",
agent="deep-research-preview-04-2026",
background=True) # a long job: poll it
while interaction.status not in ("completed", "failed"):
time.sleep(10)
interaction = client.interactions.get(interaction.id)
print(interaction.steps[-1].content[0].text) # or .error
<search> when it needs evidence. BM25, E5 dense or web search returns <information>.<information> are masked from the loss. The model does not learn to write evidence.
$ conda activate retriever
$ bash retrieval_launch.sh # E5 over Wikipedia
$ conda activate searchr1
$ python infer.py # edit question, line 7
initialize handshake; version and capabilities ride in _meta.server/discover.Mcp-Session-Id in Streamable HTTP.subscriptions/listen replaces the GET stream.tools/list carries ttlMs and cacheScope, in a fixed order. Roots, Sampling, Logging: deprecated.
from google.adk.agents import LlmAgent
from google.adk.tools.mcp_tool import McpToolset
from google.adk.tools.mcp_tool.mcp_session_manager import (
StreamableHTTPConnectionParams)
kb = McpToolset(
connection_params=StreamableHTTPConnectionParams(
url="https://kb.example.com/mcp", timeout=5),
tool_filter=["search", "fetch"], # retrieval surface only
)
root_agent = LlmAgent(model="gemini-flash-latest",
name="kb_agent", tools=[kb],
instruction="Search the KB. Cite the fetched docs.")
server/discover. There is no session id. tools/list is deterministic and cacheable. So a retrieval server scales like a plain HTTP service, and the tool list stays in the prompt cache.Top-k similarity finds the chunks that look like the query. Two question shapes break it. Structure adds aggregation and traversal.
"What are the main themes across this 1M-token corpus?"
The answer is spread thin over all 100 chunks. The top-5 window (blue) sees 5% of the evidence. No single chunk holds the answer.
"Which Stanford professor's student founded the company that acquired X?"
Only chunk C shares words with the query. Chunks A and B do not look like the query, so top-k never returns them.
Recent studies "report that GraphRAG frequently underperforms vanilla RAG on many real-world tasks."
| Query mode | How it works | Result |
|---|---|---|
| global (default) | map-reduce over reports, level 2; dynamic selection: −77% tokens | 72–83% win vs vector RAG; C0 reports: "over 97% fewer tokens" |
| local | nearest entities → edges, text units, reports | "Who is X, how is X related to Y?" |
| DRIFT | primer over top-K reports, then local follow-ups | beats local: 78% comprehensiveness, 81% diversity |
| basic | vector top-k over text units | baseline |
$ python -m pip install graphrag # 3.3.0 (8 Oct 2026)
$ graphrag init # .env, settings.yaml, input/
$ curl https://www.gutenberg.org/cache/epub/24022/pg24022.txt \
-o ./input/book.txt # GRAPHRAG_API_KEY in .env
$ graphrag index # --method standard | fast
$ graphrag query "What are the top themes in this story?"
$ graphrag query --method local \
"Who is Scrooge and what are his main relationships?"
$ graphrag query --method drift \
"How does Scrooge change, and why?"
It "requires no prior summarization of the source data". Index: a noun-phrase co-occurrence graph; graph statistics make communities.
Query: 3–5 subqueries → best-first ranking → LLM relevance tests, breadth-first → deepen → stop at the budget → map-reduce claims.
5,590 AP articles, LLM judge. At 4% of GraphRAG global query cost, it beats all methods on local and global queries. Ships in Microsoft Discovery (Azure).
Index: LLM extracts entities and relations → profiles key-value pairs → dedupes. Update = graph union, no community rebuild.
from lightrag import LightRAG, QueryParam
from lightrag.llm.openai import gpt_4o_mini_complete, openai_embed
rag = LightRAG(working_dir="./rag_storage", embedding_func=openai_embed,
llm_model_func=gpt_4o_mini_complete)
await rag.initialize_storages() # inside async def main()
await rag.ainsert(f.read()) # incremental: graph union
for mode in ["naive", "local", "global", "hybrid", "mix"]:
print(mode, await rag.aquery("What are the top themes in this story?",
param=QueryParam(mode=mode)))
await rag.finalize_storages()
Bergum: "You don't need a graph database for GraphRAG." Han et al. (arXiv 2502.11371): combining both paradigms gives consistent gains.
RAPTOR clusters chunks by similarity; GraphRAG clusters entities by connectivity. Collapsed tree, GPT-4: +20% on QuALITY.
(1) link the schema with embeddings (2) retrieve few-shot pairs (3) generate (4) execute (5) self-correct on error (6) verbalize the rows.
leaderboard, 23 Oct 2026
632 tasks, BigQuery/Snowflake, >1,000 columns
| Question shape | Use |
|---|---|
| Specific facts | hybrid search + reranker first |
| Multi-hop entity chains | HippoRAG 2, or vector → Cypher expansion |
| Whole-corpus themes | GraphRAG global search, LazyGraphRAG |
| Corpus changes daily | LazyGraphRAG, LightRAG (incremental) |
| Long single documents | RAPTOR tree |
| Numbers in tables | text-to-SQL agent; graph DB: Text2Cypher |
| Mixed | agentic router over vector, graph and SQL tools |
from neo4j_graphrag.retrievers import Text2CypherRetriever
neo4j_schema = """
Node properties: Person {name: STRING, born: INTEGER};
Movie {title: STRING, released: INTEGER}
Relationship types: (:Person)-[:ACTED_IN]->(:Movie),
(:Person)-[:DIRECTED]->(:Movie)
"""
examples = ["USER INPUT: 'Who acted in The Matrix?' QUERY: "
"MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title: 'The Matrix'}) "
"RETURN p.name"]
retriever = Text2CypherRetriever(driver=driver, llm=llm,
neo4j_schema=neo4j_schema, examples=examples)
print(retriever.search(
query_text="Which movies did Hugo Weaving star in?"))
Text2CypherRetrievalError. VectorCypherRetriever: vector entry, Cypher expansion.Code needs exact identifiers, AST structure and a fresh index.
Memory is retrieval over the user's own past sessions.
The retrieval loop stays the same. The index changes.
pool_factor=4 78.8 MBMulti-vector costs about 40x a dense vector before compression. colpali-engine is deprecated for new projects; v6 needs transformers v5.
One vector instead: gemini-embedding-2 puts text, image, video (up to 120 s), audio and PDF (up to 6 pages) in one space, 128 to 3072 dims.
Video: cut 10–120 s clips, embed them, index them with timestamps, and cite the timestamp. VideoRAG uses LongerVideos: more than 160 videos, 134 h.
# pip install -U "sentence-transformers[image]"
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("vidore/colqwen2.5-v0.2")
BASE = ("https://huggingface.co/datasets/"
"sentence-transformers/example-documents/resolve/main/")
pages = [BASE + "doc1.jpg", BASE + "doc2.jpg"] # page images
# many patch vectors per page image, 128 dims each
doc_emb = model.encode_document(pages)
# one vector per query token
q_emb = model.encode_query(
["Total outlay is maximum in which year?"])
# MaxSim: max over patches, then sum over query tokens
scores = model.similarity(q_emb, doc_emb)
ViDoRe nDCG@5: colpali 81.3 · colpali-v1.3 84.8 · colqwen2-v1.0 89.3 · colqwen2.5-v0.2 89.4 · tomoro-colqwen3-embed-4b 90.6 · colqwen3.5-4.5B-v3 90.9 · colSmol-256M 80.1
cAST chunks along the AST: split large nodes, merge small siblings under a size budget. +4.3 Recall@5 on RepoEval, +2.67 Pass@1 on SWE-bench.
Graphiti: "Facts have validity windows. When information changes, old facts are invalidated — not deleted."
from google import adk
from google.adk.agents import Agent
from google.adk.memory import VertexAiMemoryBankService
from google.adk.tools import preload_memory
memory_service = VertexAiMemoryBankService(
project="PROJECT_ID", location="LOCATION",
agent_engine_id="AGENT_ENGINE_ID")
async def auto_save(callback_context):
await callback_context.add_session_to_memory()
agent = Agent(model=MODEL, name="Generic_QA_Agent",
instruction="Answer the user's questions",
tools=[preload_memory], # or load_memory: agent decides
after_agent_callback=auto_save)
runner = adk.Runner(agent=agent, app_name="app",
session_service=session_service,
memory_service=memory_service)
preload_memory retrieves on every turn. load_memory is a tool the agent calls when it needs it. InMemoryMemoryService() is for prototypes.
Working: context window, session state · Episodic: past sessions · Semantic: facts, preferences · Procedural: learned instructions
LongMemEval (ICLR 2025): 500 questions, 5 abilities (extraction, multi-session, temporal reasoning, knowledge updates, abstention). Assistants lose about 30% accuracy over sustained use. LaMP (ACL 2024) retrieves from the user profile to personalize.
user_id, or facts leak between users. Invalidate stale preferences. Memories are personal data.user_id. Invalidate the old city with a validity window (Graphiti) or choose UPDATE (Mem0). Keep the history, so "where did I live last year?" still works. Never write outside the user's scope.A RAG system is as good as its measured retrieval and as safe as its least trusted document.
“LLM-Integrated Applications blur the line between data and instructions.”
Groundedness splits the answer into claims and looks for evidence for each claim. TruLens 2.16.0 traces on OpenTelemetry.
Faithfulness = supported claims / all claims. Here 3 of 4 claims have evidence: 0.75.
Context Precision@K is rank-aware: swap a relevant and an irrelevant chunk and ContextUtilization drops from ≈1.0 to ≈0.5. No LLM: IDBasedContextPrecision (2/4 = 0.5). Cheap judge: FaithfulnesswithHHEM (HHEM-2.1-Open, CPU). LangSmith adds correctness, which needs a reference answer.
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from ragas.metrics.collections import Faithfulness
llm = llm_factory("gpt-4o-mini", client=AsyncOpenAI())
faith = Faithfulness(llm=llm)
result = await faith.ascore(
user_input="When was the first super bowl?",
response="The first superbowl was held on Jan 15, 1967",
retrieved_contexts=[
"The First AFL–NFL World Championship Game was an "
"American football game played on January 15, 1967, "
"at the LA Memorial Coliseum."])
print(result.value) # 1.0
The old ragas.metrics + evaluate() API is removed in v1.0.
Recipe: binary labels with a rationale, structured output, swapped answer order, a judge from another model family, 100–300 human labels.
Synthetic triples train small judges. PPI corrects their bias and gives confidence intervals from “only a few hundred human annotations”. RAGAS 0.4.3 adds DSPy judge-prompt tuning.
Accuracy on 824 questions (Google, NAACL 2025).
Golden set: 100–500 private Q / A / context-ID triples. Make them with RAGAS TestsetGenerator or ARES. Add labelled production traces.
$ adk eval <AGENT> <EVAL_SET> \
--config_file_path=test_config.json
Criteria: tool_trajectory_avg_score, response_match_score, hallucinations_v1, rubric-based quality.
The attacker writes into a page, email, PDF or ticket that your retriever reads. A normal query pulls it into the prompt.
OWASP: a résumé hides white-on-white text. The model obeys it. Data leaves through an image URL or a tool call.
Trusted sources only, with provenance and signatures. Review before ingestion. Detect duplicates and anomalies. Keep immutable retrieval logs.
Least-privilege tools. Human confirmation for side effects. No auto-rendered external URLs or images. Egress filters. NeMo Guardrails retrieval rails.
Embeddings drop the source document's ACL. Top-k returns what is similar, not what this user may see. The retriever becomes a confused deputy.
# illustrative Chroma-style filter: END USER's groups
hits = collection.query(query_texts=[q], n_results=5,
where={"acl_groups": {"$in": user.groups}})
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
text = "My phone number is 212-555-5555"
results = AnalyzerEngine().analyze(
text=text, entities=["PHONE_NUMBER"], language="en")
print(AnonymizerEngine().anonymize(
text=text, analyzer_results=results))
PII: redact or pseudonymize at ingest, before embedding, because embeddings can be inverted (OWASP LLM08). Redact again at output. Right-to-erasure deletes must reach vector stores, caches and traces.
Products: OpenSearch and Elasticsearch document-level security. Glean and Microsoft 365 Copilot advertise permission-trimmed retrieval. Red-team: user A asks for user B's data; PII probes; poisoning canaries.
Embed query → ANN search → rerank → LLM prefill (grows with context) → decode.
Tokens in context → reranker calls → agent loops: agents use ~4x, multi-agent systems ~15x the tokens of chat.
Source changes → index lags → cache keeps the old answer. Zombie chunks answer from deleted or changed documents.
Every answer spends time, tokens and trust. This chapter covers tracing and caching, incremental indexing, managed RAG services, a best-practice checklist and open challenges.
Langfuse 5.0.4 @observe · Phoenix 20.26.0 register(auto_instrument=True) · LangSmith @traceable
index() + RecordManager hashes each chunk and skips unchanged ones. Cleanup: incremental · full · scoped_full. Never point it at a store that another process filled. New embedding model = full blue/green re-embed.
from redisvl.extensions.cache.llm import SemanticCache
from redisvl.utils.vectorize import HFTextVectorizer
from redisvl.query.filter import Tag
cache = SemanticCache(name="llmcache",
redis_url="redis://localhost:6379",
distance_threshold=0.1, # Redis COSINE 0-2, lower = stricter
vectorizer=HFTextVectorizer("redis/langcache-embed-v1"),
filterable_fields=[{"name": "user_id", "type": "tag"}])
def answer(q: str, uid: str) -> str:
hit = cache.check(prompt=q,
filter_expression=Tag("user_id") == uid)
if hit: # ~0.05 s instead of ~1.8 s
return hit[0]["response"]
out = rag_answer(q) # retrieve, rerank, generate
cache.store(prompt=q, response=out, filters={"user_id": uid})
cache.set_ttl(3600) # freshness bound
return out
user_id or tenant, or user A gets user B's answer. At distance_threshold=0.5, a "Nice" question hit the cached "Paris" answer. Tune the threshold with CacheThresholdOptimizer.| Service | Primitive · API | Oct 2026 facts |
|---|---|---|
| Gemini API File Search | FileSearchStore + file_search tool (google-genai 2.33.0) | Storage and query-time embeddings free. Indexing at embedding rates ($0.15 / 1M tokens at launch). 100 MB per file; store 1 GB (Free) to 1 TB (Tier 3); keep stores under 20 GB. metadata_filter, file_citation. |
| Agent Platform RAG Engine | client.rag.create_corpus / import_files / retrieve_contexts (google-cloud-agentplatform 2.6.0) | Chunking 512 / 100, text-embedding-005. Bring your own vector DB (Pinecone, Vector Search). Agent Search backend. ADK tool. |
| OpenAI file_search | vector_stores + Responses API tool | Semantic + keyword search. max_num_results, filters, include=["file_search_call.results"]. |
| Amazon Bedrock Knowledge Bases | retrieve · retrieve_and_generate | overrideSearchType: HYBRID, rerankers, GraphRAG on Neptune Analytics. |
| Azure AI Search agentic retrieval | knowledge base retrieve or MCP endpoint | GA in REST 2026-04-01. LLM query planning, parallel subqueries, semantic rerank. 2,000 retrievals ≈ $4.32. Powers Foundry IQ. |
google/adk-samples → google/adk-recipes · genai-toolbox → mcp-toolbox. Do not mix the old vertexai.rag style and the new agentplatform client in one project.
import time
from google import genai # google-genai 2.33.0
client = genai.Client()
store = client.file_search_stores.create(config={
"display_name": "my-store",
"embedding_model": "models/gemini-embedding-2"}) # images need -2
op = client.file_search_stores.upload_to_file_search_store(
file="sample.txt", file_search_store_name=store.name,
config={"display_name": "display-file-name"})
while not op.done: # service parses, chunks, embeds
time.sleep(5); op = client.operations.get(op)
interaction = client.interactions.create(
model="gemini-3.8-flash", input="Can you tell me about ...",
tools=[{"type": "file_search", "file_search_store_names": [store.name]}])
for step in interaction.steps:
if step.type == "model_output":
for b in step.content:
if b.type == "text":
print(b.text, [a.file_name for a in (b.annotations or [])
if a.type == "file_citation"])
The survey also lists coordination, memory, efficiency and governance. GraphRAG-Bench asks when graphs beat vanilla RAG.
adk eval).max_iterations. Budget multi-agent search at ~15x tokens.doc_id when a document changes.