Back to LlamaIndex

LlamaIndex Anti-Patterns

Ten ways teams get LlamaIndex wrong — what gets built instead of the pattern, why it demos beautifully, and exactly when it bites.

TextAudio

Each mistake below has a right answer one rung up on LlamaIndex Patterns, and a seam it refuses to cut in LlamaIndex Architecture.

Put this into practiceRecalling beats rereading — retrieval practice is the best-supported technique in the evidence base.

The patterns page is a ladder and the architecture page is the object graph underneath it. This page is the other thing entirely: the ten ways teams actually get LlamaIndex wrong. Not gaps in knowledge — gaps in judgment. Every one of these is a decision that looked correct at the time, shipped without complaint, and then charged you later.

That is what separates an anti-pattern from mere ignorance. Nobody wires a bad pipeline on purpose. They wire a seductive one: the quickstart works, so it ships. The reranker improves scores, so a threshold on top must improve them more. The answers are wrong, so a better model must make them right. Every mistake below is locally reasonable and globally wrong, and every one of them fails silently — LlamaIndex's defaults are tuned for a notebook that runs, not a service that is correct, so the framework will let you do all ten and raise nothing.

Read this as the debugging index for a RAG system that is already in production and already disappointing you. Each section names the mistake, says why you made it, shows the mechanism of the damage, and gives you the fix.

Anti-patternYou're doing this when
The Quickstart Is Not a ProductYour prod query path is still index.as_query_engine().query(q) — five lines, no cutoff, no reranker — and it has never once said "I don't know."
Paying Twice for the Same CorpusThe nightly job re-reads and re-embeds all 40k documents whether or not any of them changed, and your embedding bill is a flat line that only ever goes up.
Tuning Chunk Size Against Generation QualityYou bumped chunk_size until the answers read better, and you have no hit-rate number from before or after.
Thresholding After the RerankerYou put a similarity cutoff at the end of node_postprocessors to "keep only the good ones," and now half your queries return nothing at all.
Tenancy in the PromptYour system prompt says "only answer using documents belonging to tenant acme" — and that sentence is the entire access-control layer.
Trusting an Empty RetrieveNothing in your code ever checks len(nodes), because a failed retrieval raises, right?
Reaching for a Bigger ModelQuality is bad, so the plan is to upgrade the LLM — and nobody in the room can say what the retriever's hit rate is.
Letting the Globals DriftIngest and query are two different processes, each configures Settings in its own module, and no startup check compares them.
An Agent Where a Query Engine Would DoEvery question goes through a tool-calling loop, so the same query retrieves different chunks on different days — and your eval set has quietly stopped meaning anything.
Grading Your RAG With a Golden Set the Model WroteYour eval fixture is the raw output of a question generator, no human ever read it, and every threshold passes.

The inverse map — down the left, what you reached for, in the order the pipeline runs; on the arrow, what it silently charges you; on the right, what you should have reached for instead:

The dotted spine is the order to read them in, not a chain of cause and effect — each of the ten is independently shippable and independently silent. What they share is the arrow: nothing on the left ever raises, so the cost is only ever visible as a number nobody is looking at.

The Quickstart Is Not a Product

Five lines. A directory, an index, a query engine, an answer. It is the best onboarding experience in the ecosystem and that is precisely the problem — it works, so it ships, and it ships carrying every default the tutorial author chose to keep the snippet short.

Unpack what you actually deployed. as_query_engine() builds a VectorIndexRetriever with similarity_top_k=DEFAULT_SIMILARITY_TOP_K, and that constant is literally 2 in llama_index/core/constants.py. There is no SimilarityPostprocessor — score cutoffs are opt-in, passed by hand through node_postprocessors. There is no reranker. There is no metadata filter. So the whole of your retrieval layer is: embed the question, cosine-rank the corpus, take the two nearest vectors, synthesize.

The consequence is not "sometimes the answers are mediocre." The consequence is structural: this system has no representation of failure. Top-2 always returns two chunks. It returns two chunks when the corpus contains the answer, and it returns two chunks when the corpus has never heard of the subject — the same two nearest-but-irrelevant neighbours, handed to an LLM that will write a fluent paragraph over them because that is what you asked it to do. It cannot abstain, because nothing in the chain is capable of producing zero nodes.

The fix is to give the pipeline a floor and a ceiling. A cutoff so it can return nothing, a reranker so the nodes that survive were actually read against the query, and a top_k wide enough that the reranker has something to choose from.

python
from llama_index.core.postprocessor import SentenceTransformerRerank, SimilarityPostprocessor

engine = index.as_query_engine(
    similarity_top_k=20,                 # wide: the reranker's candidate pool
    node_postprocessors=[
        SimilarityPostprocessor(similarity_cutoff=0.5),   # BEFORE the rerank — see below
        SentenceTransformerRerank(
            model="cross-encoder/ms-marco-MiniLM-L-6-v2",  # name it: the class default is an STS model
            top_n=5,                                       # name it: the class default is 2
        ),
    ],
)
resp = engine.query("What changed in the Q3 pricing policy?")
if not resp.source_nodes:            # the pipeline can now say "I don't know"
    return "No supporting documents found."

Note the two named arguments in the reranker. SentenceTransformerRerank.__init__ defaults to top_n=2 and model="cross-encoder/stsb-distilroberta-base" — a semantic textual similarity model, not a relevance reranker. Leave both at their defaults and you have added a hop that keeps two nodes and ranks them with the wrong objective. Defaults in this framework are for notebooks. Name everything on the query path.

Fails when: the corpus doesn't contain the answer at all. similarity_top_k=2 returns the two nearest vectors regardless — there is no threshold in the default chain, because SimilarityPostprocessor is opt-in via node_postprocessors and nothing else in as_query_engine() looks at node.score — so the synthesizer receives two irrelevant chunks and confabulates over them. The operator sees a confident, well-cited, entirely fictional answer, and there is no error, no low-confidence signal, and no log line anywhere that distinguishes it from a good one.

⟳ Grounded onpostprocessor.nodepostprocessor.sbert_rerankpostprocessor.node_recencypostprocessor.optimizer
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Recall check

When does The Quickstart Is Not a Product stop working, and why?

Show answer

the corpus doesn't contain the answer at all.

Interleave

The Quickstart Is Not a Product — this mistake — pairs with Indexes: a build strategy, not a data structure on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: Indexes: a build strategy, not a data structure

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a dimly lit data center and see a blackboard mounted on the wall. In bold white chalk, someone has written 'DEFAULT_SIMILARITY_TOP_K = 2', with the '2' circled in red and underlined three times.

Encodes: that constant is literally 2

Paying Twice for the Same Corpus

The ingest script works. It runs nightly, it takes forty minutes, and nobody has looked at it since the week it was written. It reads every document, splits every document, and embeds every chunk — every night, whether or not a single byte changed. This is the largest avoidable line item in most production RAG budgets, and it survives because it is invisible: the job succeeds, the index is correct, and the only symptom is a number on an invoice that someone else reads.

The seduction here is that VectorStoreIndex.from_documents(docs) is the call the tutorial gave you, and it has no memory. It does not know what it saw last run because you never gave it anywhere to remember. That is what the docstore is for — doc_iddoc.hash — and it is what IngestionPipeline exists to drive. docstore_strategy defaults to DocstoreStrategy.UPSERTS, which processes a document only when its ID is new or its hash has moved, and deletes the old nodes from both the docstore and the vector store before inserting the new ones. IngestionCache sits behind that, keyed per transformation step, so the expensive steps (LLM extractors, embeddings) are skipped for content you have already processed.

Now the trap, and it is the one that catches almost everyone who does know about the docstore. Attaching a docstore to a StorageContext does not populate it. VectorStoreIndex._add_nodes_to_index gates the docstore write behind if not self._vector_store.stores_text or self._store_nodes_override: — and Qdrant, Pinecone, Weaviate and pgvector all set stores_text=True, while store_nodes_override defaults to False. So with a real vector database, from_documents(docs, storage_context=sc) writes the nodes to the vector store and nowhere else. Your docstore is empty. index.ref_doc_info returns {}. The dedup you carefully wired is a no-op that raises nothing, because the index queries perfectly — the vector store has the text.

Stop trying to make from_documents remember things. Drive the pipeline directly:

python
from llama_index.core.ingestion import IngestionPipeline, IngestionCache, DocstoreStrategy
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.storage.docstore import SimpleDocumentStore

pipeline = IngestionPipeline(
    transformations=[SentenceSplitter(chunk_size=512), embed_model],
    docstore=SimpleDocumentStore(),      # here it IS written to
    vector_store=vector_store,           # required, or UPSERTS silently degrades
    docstore_strategy=DocstoreStrategy.UPSERTS,
    cache=IngestionCache(),
)
nodes = pipeline.run(documents=docs, num_workers=4)
pipeline.persist("./pipeline_storage")   # cache + docstore; .load() next run

And make doc_id stable and source-derived — a URL, a primary key, a file path. A reader that mints a fresh UUID per run turns every sync into a corpus of brand-new documents, and the docstore buys you exactly nothing.

Fails when: you attach the docstore to a StorageContext and hand it to from_documents() instead of to an IngestionPipeline. The write is gated on not self._vector_store.stores_text or self._store_nodes_override, both of which are false against any real vector DB, so the docstore stays empty and index.ref_doc_info returns {}. Nothing raises — the index queries fine — and the operator's only symptom is that the re-embed bill never drops and the same document accumulates a fresh copy of its chunks on every run.

⟳ Grounded oningestion.pipelineindices.document_summary.baseindices.empty.baseindices.tree.base·faithfulness 1.00
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Paying Twice for the Same Corpus fails when you attach the docstore to a  ____  and hand it to  ____  instead of to an IngestionPipeline.

Show answer

StorageContext, from_documents()

Interleave

Paying Twice for the Same Corpus — this mistake — pairs with The StorageContext: where an index actually lives on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: The StorageContext: where an index actually lives

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a data center and see a bookshelf labeled 'docstore' with no books at all, while a glowing server labeled 'vector store' hums with text.

Encodes: Your docstore is empty.

Tuning Chunk Size Against Generation Quality

You read a blog post about chunk size. You tried 512, then 1024, then 2048. At 2048 the answers read better — longer, more coherent, fewer half-sentences — so you shipped 2048. This is one of the most common tuning loops in RAG and it optimizes the wrong variable, because a chunk is simultaneously the unit of retrieval and the unit of context, and those two want opposite things.

Generation wants big chunks: more surrounding prose, fewer severed sentences, an answer that isn't cut in half at a boundary. Retrieval wants small ones: a 2048-token chunk that covers four topics embeds as the average of four topics, and that average vector is not close to any specific question. So as you turn the knob up, the answers you're reading get more fluent while the recall underneath them collapses — and you cannot see the collapse, because you are grading the output of the queries that still worked.

SentenceSplitter defaults are chunk_size=DEFAULT_CHUNK_SIZE (1024) and chunk_overlap=SENTENCE_CHUNK_OVERLAP (200) — not the 20 that half the blog posts quote. They are a fine starting point for dense prose. The point is not which number is right; the point is that you cannot answer that question by reading answers. You answer it with a retrieval metric, in isolation, with no LLM in the loop.

RetrieverEvaluator is that measurement and it takes three lines. Its metric registry in llama_index/core/evaluation/retrieval/metrics.py gives you hit_rate, mrr, precision, recall, ap, ndcg. Hit rate is the ceiling on everything downstream: at 0.6, no prompt and no model gets you past 60% correct.

python
from llama_index.core.evaluation import RetrieverEvaluator

for chunk_size in (256, 512, 1024, 2048):
    nodes = SentenceSplitter(chunk_size=chunk_size, chunk_overlap=chunk_size // 5).get_nodes_from_documents(docs)
    idx = VectorStoreIndex(nodes)
    ev = RetrieverEvaluator.from_metric_names(
        ["hit_rate", "mrr"], retriever=idx.as_retriever(similarity_top_k=5)
    )
    results = [
        await ev.aevaluate(query=q, expected_ids=ids)
        for q, ids in qa_dataset.query_docid_pairs
    ]
    hit = sum(r.metric_vals_dict["hit_rate"] for r in results) / len(results)
    print(chunk_size, round(hit, 3))     # THIS is the number you tune against

If your chunks have structure, stop guessing at it entirely: MarkdownNodeParser splits on headers, CodeSplitter on AST boundaries. Free structure beats inferred structure, and it beats a knob.

Fails when: you turn chunk_size up until the prose reads well. A 2048-token chunk averages several topics into one vector that matches no specific query, so hit_rate silently drops while answer fluency — the only thing you were looking at — improves. The operator sees confident, well-written answers that are increasingly about the wrong document, and blames the retriever, or the embedding model, or the LLM. All three are innocent.

⟳ Grounded onloadingnode parsersnode_parser.text.sentenceevaluation.retrieval.metrics
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Tuning Chunk Size Against Generation Quality fails when you turn  ____  up until the prose reads well.

Show answer

chunk_size

Interleave

Tuning Chunk Size Against Generation Quality — this mistake — pairs with Chunking & Node Parsing on the patterns page: that rung is the one this mistake gets wrong. Read the two back to back, then say in one sentence what makes them different.

Pair with: Chunking & Node Parsing

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a workshop and see a stone chunk labeled 'chunk' with two ropes pulling it in opposite directions: one rope labeled 'retrieval' pulls left, the other labeled 'context' pulls right.

Encodes: a chunk is simultaneously the unit of retrieval and the unit of context, and those two want opposite things.

Thresholding After the Reranker

This is the single most destructive one-line mistake in LlamaIndex, and it is destructive precisely because it is such good reasoning. You added a cross-encoder reranker. It sorts nodes by real relevance. So the obvious next move is to keep only the relevant ones — put a SimilarityPostprocessor(similarity_cutoff=0.5) at the end of the list and drop everything below the bar. Sensible. Defensible in review. And it deletes your entire context.

Here is the mechanism. node_postprocessors is an ordered list, and the stages are asymmetric about the score field: a reranker writes it, a filter only reads it. Read SentenceTransformerRerank._postprocess_nodes and the write is explicit: node.score = score — unconditionally, destroying the retrieval score unless you pass keep_retrieval_score=True, which stashes the old value in node.metadata["retrieval_score"] and defaults to False. SimilarityPostprocessor never writes anything back; it just reads whatever is in node.score and drops the node if it falls under the cutoff. And what replaces it is not a cosine similarity. It is a cross-encoder logit: unbounded, uncalibrated, and routinely negative. A genuinely excellent match can score -1.2. So SimilarityPostprocessor(similarity_cutoff=0.5) running after the reranker compares -1.2 < 0.5, drops the node, drops all of them, hands the synthesizer an empty list, and — see the next section — gets back the literal string "Empty Response" with no exception raised anywhere.

Order the list so each stage reads a score it understands:

python
from llama_index.core.postprocessor import SimilarityPostprocessor, SentenceTransformerRerank

engine = index.as_query_engine(
    similarity_top_k=20,
    node_postprocessors=[
        SimilarityPostprocessor(similarity_cutoff=0.5),   # 1. cosine scale: cutoff is meaningful
        SentenceTransformerRerank(                        # 2. overwrites score with a logit
            model="cross-encoder/ms-marco-MiniLM-L-6-v2",
            top_n=5,
            keep_retrieval_score=True,   # old score -> node.metadata["retrieval_score"]
        ),
        # 3. NOTHING that reads node.score numerically goes here. top_n is your filter now.
    ],
)

The general law: NodeWithScore.score is not comparable across producers. A cosine from a vector store, a BM25 relevance score, and a cross-encoder logit all land in the same float field with wildly different scales. If you ever write if node.score > 0.7, you have hard-coded a threshold to one specific model's score distribution and it will not survive the next model upgrade. Filter by rank (top_n) after a reranker, never by value.

The same field bites from the other direction, too: SimilarityPostprocessor treats a missing score as a failure — if similarity is None: should_use_node = False — so any retriever that doesn't populate scores (SummaryIndexRetriever, most hand-rolled ones) has every node silently dropped by a postprocessor whose cutoff you set to 0.0 thinking it was a harmless no-op.

Fails when: a SimilarityPostprocessor sits after a reranker in node_postprocessors. SentenceTransformerRerank._postprocess_nodes executes node.score = score, replacing the bounded cosine with an unbounded cross-encoder logit that is frequently negative, so the cutoff you set at 0.5 now rejects every node including the perfect ones. The operator sees "Empty Response" — or a model answering from its weights over an empty context — on exactly the queries the reranker just ranked most confidently.

⟳ Grounded onpostprocessor.nodememory.memory_blocks.vectorpostprocessor.node_recencypostprocessor.sbert_rerank·faithfulness 1.00
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Thresholding After the Reranker fails when a  ____  sits after a reranker in  ____ .

Show answer

SimilarityPostprocessor, node_postprocessors

Interleave

Thresholding After the Reranker — this mistake — pairs with Hybrid Search + Reranking on the patterns page: that rung is the one this mistake gets wrong. Read the two back to back, then say in one sentence what makes them different.

Pair with: Hybrid Search + Reranking

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a dim workshop and see a device labeled 'SimilarityPostprocessor' with a threshold dial stuck at 0.5. Beside it, a node with a glowing score of -1.2 rolls past and is instantly kicked into a bin labeled 'Empty Response'.

Encodes: A genuinely excellent match can score -1.2.

Tenancy in the Prompt

The system prompt reads: "Only use documents belonging to tenant acme. Never reveal information from other tenants." It works in testing. It works in the demo. It is not access control — it is a request, addressed to a stochastic text generator, about text you have already placed inside its context window.

Understand what happened before that prompt was ever read. The retriever ran an ANN search across the whole collection, pulled the top-k chunks, and those chunks — some of them another tenant's — were serialized into the prompt. The data has already left the store. The only thing standing between another customer's contract and your user's screen is a sentence, and that sentence is competing for attention with everything else in a 40k-token context. Any prompt injection in any retrieved document is a peer of your instruction, not a subject of it.

Filters are not a relevance hint. A MetadataFilters is a correctness constraint that changes the candidate set before the ANN search runs. VectorStoreQuery in llama_index/core/vector_stores/types.py carries a filters: Optional[MetadataFilters] field, and VectorIndexRetriever passes whatever you handed its filters= argument straight into it — which the store then translates into its native pre-filter. Qdrant, Pinecone, Weaviate and pgvector all do this in the engine. You get five results out of the right thousand rows, not five out of the wrong million.

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

filters = MetadataFilters(filters=[
    MetadataFilter(key="tenant_id", value=request.tenant_id, operator=FilterOperator.EQ),
])
engine = index.as_query_engine(filters=filters, similarity_top_k=10)


retriever = VectorIndexAutoRetriever(
    index,
    vector_store_info=info,
    extra_filters=filters,   # AND-ed onto whatever the model infers; must not be OR
)

Two rules follow and neither is negotiable. Anything that must be true of an answer — tenancy, ACL group, freshness window, legal jurisdiction — goes in the pre-filter and never in the prompt. And where an LLM is allowed to infer filters, the security-bearing ones go in extra_filters, which is AND-ed on top, because you must never let a model decide whether to scope a query to the caller's tenant. Then assert it: after retrieval, check that every returned node's metadata["tenant_id"] actually equals the caller's. A filter that silently didn't apply and a filter that correctly returned nothing look identical from the outside.

Fails when: the tenant scope lives in the prompt instead of in MetadataFilters. The ANN search has already pulled another tenant's chunks into the context window by the time the model reads your instruction, so a single prompt injection in any indexed document — or just an ordinary long-context attention lapse — leaks them. The operator sees nothing: no error, no anomaly, a perfectly fluent answer. The customer sees another customer's contract.

⟳ Grounded onvector_stores.__init__vector_stores.typesindices.vector_store.retrievers.retrievervector_stores.utils·faithfulness 1.00
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Tenancy in the Prompt fails when the tenant scope lives in the prompt instead of in  ____ .

Show answer

MetadataFilters

Interleave

Tenancy in the Prompt — this mistake — pairs with Metadata Filtering & Auto-Retrieval on the patterns page: that rung is the one this mistake gets wrong. Read the two back to back, then say in one sentence what makes them different.

Pair with: Metadata Filtering & Auto-Retrieval

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a vast data center hallway and see a heavy steel gate labeled 'MetadataFilters' blocking the path to the ANN search engine, with a sign reading 'Must pass through here before any documents are retrieved.'

Encodes: A MetadataFilters is a correctness constraint that changes the candidate set before the ANN search runs.

Trusting an Empty Retrieve

Nowhere in your code do you check len(nodes). Why would you? Every other data-access layer you have ever used raises on a miss, or returns something falsy you would trip over immediately. So you wrote answer = engine.query(q).response and moved on.

LlamaIndex does neither. Read BaseSynthesizer.synthesize in llama_index/core/response_synthesizers/base.py: the first thing it does is if len(nodes) == 0:, and in that branch it constructs Response("Empty Response") and returns it — without ever calling the LLM, and without raising anything. That is a plain Response object. It has a .response attribute. It is a string. It is truthy. It will be JSON-serialized into your API payload, stored in your chat history, passed back to an agent as a tool observation, and scored by your eval harness as if it were content.

The reason this is an anti-pattern rather than a mere gotcha is how many upstream mistakes funnel into it. An over-narrow metadata filter — a hallucinated service="cart", a mis-cased "Checkout", year=2025 against a 2024 corpus, an AND chain no node satisfies. A SimilarityPostprocessor placed after a reranker, per the section above. A cutoff tuned against a different embedding model. Every one of these produces zero nodes, and every one of them arrives at the caller as the same cheerful two-word string.

So make the empty case a case:

python
nodes = retriever.retrieve(query)

if not nodes:
    # Do NOT synthesize. Decide.
    nodes = fallback_retriever.retrieve(query)      # relax the filters, widen top_k
    if not nodes:
        return "I don't have documents covering that."

resp = engine.synthesize(query, nodes=nodes)


assert all(n.node.metadata.get("tenant_id") == tenant for n in resp.source_nodes)

Decompose the query engine when you need this — retrieve, inspect, then synthesize — rather than letting query() collapse the whole thing into one call whose failure mode is a string. And note the genuinely dangerous sibling of the empty retrieve: the near miss. Right service, wrong year. Correct filter, missing tenant. That returns a full, plausible node list from the wrong partition and produces a confidently wrong answer with citations. Neither trusting an empty retrieve nor trusting a non-empty one is safe; assert on both.

Fails when: retrieval returns zero nodes and you never check. BaseSynthesizer.synthesize short-circuits on len(nodes) == 0 and returns Response("Empty Response") — a literal two-word string, no LLM call, no exception — which your API serializes, your agent consumes as a tool observation, and your eval harness scores as a real answer. The operator sees a 200, a normal-looking response body, and green dashboards, while every filtered query in production is returning a placeholder.

⟳ Grounded onresponse_synthesizers.basebase.response.schemaresponse_synthesizers.generation
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Recall check

When does Trusting an Empty Retrieve stop working, and why?

Show answer

retrieval returns zero nodes and you never check.

Interleave

Trusting an Empty Retrieve — this mistake — pairs with Retrievers: the narrowest useful interface on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: Retrievers: the narrowest useful interface

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a data center and see a large illuminated sign that says 'Empty Response' hanging above a silent server rack, while a small robot holding a 'Response' object stands idle.

Encodes: Response("Empty Response")

Reaching for a Bigger Model

The answers are wrong. The meeting concludes that the model isn't smart enough. Somebody upgrades the LLM, the bill triples, quality moves by roughly nothing, and the meeting reconvenes.

This is the most expensive mistake on this page and it is the most understandable one, because it is the only lever that requires no understanding of the system. It is a one-line diff. It requires no eval set, no chunking decision, no filter schema, no rerank latency budget. And it is wrong for a reason that should be a poster on the wall: a bigger model cannot read a chunk you never retrieved. If the answer was not in the context window, generation quality is irrelevant — you have paid a premium for a more articulate way of not knowing.

Almost every quality problem in the first year of a RAG system is a retrieval problem wearing a generation problem's clothes. The chunk boundary cut the answer in half. Similarity search pulled last year's tenant. The exact error code was invisible to the embedding because embeddings capture topic, not tokens — ERR_CONN_RESET embeds as "some error-ish thing," and cosine returns twenty neighbours about the right subject and none about the right string.

You settle this with two numbers, not an argument. Measure retrieval in isolation — no LLM in the loop — with RetrieverEvaluator and hit_rate. If hit rate is 0.6, then 40% of your queries are unanswerable no matter what generates over them, and every dollar spent on the model is spent on the wrong half of the pipeline. Then, and only then, split the generation failures with the LLM judges in llama_index.core.evaluation:

python
from llama_index.core.evaluation import FaithfulnessEvaluator, RelevancyEvaluator


# relevant but unfaithful -> the model ignored the context   -> prompt/model bug
faith = FaithfulnessEvaluator(llm=judge)     # needs no labels: runs on live traffic
rel = RelevancyEvaluator(llm=judge)

Those two are orthogonal, and holding them apart is the whole diagnostic. High faithfulness plus low relevancy is a retriever that pulled plausible, on-topic, wrong documents — a bigger model makes that worse, because it summarizes the wrong evidence more convincingly. Low faithfulness with high relevancy is the only case where the model is genuinely the problem. Climb the patterns ladder — chunking, filters, hybrid, rerank — before you climb the price list.

Fails when: you upgrade the LLM without ever computing hit_rate. Retrieval hit rate is a hard ceiling on answer correctness — at 0.6, no model reaches 61% — so the upgrade buys fluency and costs money while the actual defect (a chunk that never entered the context window) is untouched. The operator sees the bill move and the eval scores not move, and reaches for an even bigger model.

⟳ Grounded onevaluation.__init__evaluation.multi_modal.__init__evaluation.faithfulnessevaluation.multi_modal.faithfulness·faithfulness 1.00
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Reaching for a Bigger Model fails when you upgrade the LLM without ever computing  ____ .

Show answer

hit_rate

Interleave

Reaching for a Bigger Model — this mistake — pairs with Response synthesizers: how N chunks become one answer on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: Response synthesizers: how N chunks become one answer

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a data center and see a towering machine labeled 'bigger model' with a glowing price tag. Its input pipe is empty because the retrieval chunk never arrived, so all its power is wasted on generating fluent nonsense.

Encodes: a bigger model cannot read a chunk you never retrieved.

Letting the Globals Drift

Settings is a process-global singleton and a genuine ergonomic win — configure the models once and forty components stop asking you for them. It is also a global, in a system whose two halves run in two different processes, and that is a trap with no floor.

Your ingest job is a batch script. Your query service is a web server. They share a vector store and nothing else. The ingest job sets Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-small-en-v1.5") because someone wanted to stop paying OpenAI for embeddings. The query service was written six weeks earlier, in a different file, and never sets it at all.

Now trace what happens. VectorStoreIndex.from_vector_store(vector_store) takes an embed_model: Optional[EmbedType] = None. None resolves to Settings.embed_model, whose getter is if self._embed_model is None: self._embed_model = resolve_embed_model("default") — and resolve_embed_model("default") constructs an OpenAIEmbedding(). So the query service embeds every incoming question with OpenAI, against a collection written by BGE.

And here is why this is the quietest failure in the entire framework: the collection carries no record of which model wrote it. There is no dimension mismatch to save you if the two models happen to agree on width — and if they don't, you get a loud error, which is the lucky outcome. When the widths match, the ANN index accepts the query vector, cosine comes back in the 0.6–0.8 band that looks exactly like healthy retrieval, and the nearest neighbours are noise. No exception. No warning. Just a system that has quietly become a random-chunk generator with citations.

Stop treating the embedding model as ambient configuration. Pin it in one place both processes import, pass it explicitly, and hard-fail on startup:

python

EMBED_MODEL_ID = "BAAI/bge-small-en-v1.5"
EMBED_DIM = 384

def embed_model():
    return HuggingFaceEmbedding(model_name=EMBED_MODEL_ID)

# query service, at startup — explicit, not via Settings:
index = VectorStoreIndex.from_vector_store(vector_store, embed_model=embed_model())

# and assert the store agrees, before you serve a single request:
info = client.get_collection("corpus")
assert info.config.params.vectors.size == EMBED_DIM, "embedding model drift"

Write the model ID into the collection's payload or name at ingest time (corpus_bge_small_v1) and check it at boot. The rule that keeps Settings sane in general: globals for the boring defaults, explicit arguments for anything a reviewer would want to see. An embedding model is the second kind.

Fails when: ingest and query processes disagree about Settings.embed_model. from_vector_store() defaults embed_model=None, which falls through to resolve_embed_model("default") and silently constructs OpenAIEmbedding() — and the collection records nothing about which model wrote it, so if the dimensions happen to match, queries return plausible-looking cosine scores over semantically meaningless neighbours. The operator sees normal latency, normal scores, and answers that are subtly, inexplicably about the wrong documents.

⟳ Grounded onembeddings.utilsindices.multi_modal.basememory.vector_memoryindices.vector_store.base·faithfulness 0.50
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Letting the Globals Drift fails when ingest and query processes disagree about  ____ .

Show answer

Settings.embed_model

Interleave

Letting the Globals Drift — this mistake — pairs with Settings: the global injection container on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: Settings: the global injection container

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk past a two-sided billboard in a factory hallway. One side reads 'Settings.embed_model = HuggingFaceEmbedding' and the other side reads 'Settings.embed_model = default -> OpenAIEmbedding', but both sides are connected by a single pole that wobbles silently.

Encodes: the collection carries no record of which model wrote it.

An Agent Where a Query Engine Would Do

Agentic RAG is the most fun thing in the framework, and that is exactly the problem. Wrapping your query engine in a QueryEngineTool and handing it to a FunctionAgent from llama_index.core.agent.workflow takes ten minutes, demos beautifully, and handles the multi-hop question your PM asked in the meeting. So it becomes the default path — every question, easy or hard, goes through the loop.

What you gave up is not obvious until you try to improve the system. A query engine's retrieval is a pure function of the question: the same query embeds to the same vector, hits the same ANN index, and returns the same nodes. That determinism is what makes RetrieverEvaluator possible — you can hand it (query, expected_ids) pairs and get a hit_rate because there is a stable set of retrieved ids to compare against. An agent has no such thing. The same question takes a different path on a different day: it may search once, or three times with reworded queries, or reason its way to an answer without searching at all. There are no fixed expected_ids because there is no fixed retrieval. The moment you make retrieval agentic, you make it unevaluable by the only cheap, deterministic, LLM-free metric you had.

The cost model inverts too, and nobody notices. One-shot RAG is one embedding plus one generation with a bounded p99. An agent with two query-engine tools routinely makes four to six LLM calls per question — a routing call, one generation inside each query engine's synthesizer, and a final synthesis over observations that already contain the retrieved chunks. Context grows with each observation and you re-send the whole history every turn, so cumulative tokens grow quadratically in tool calls. A question that a single retrieval would have answered now costs 10x and takes twenty seconds.

Climb this ladder deliberately. Route the easy 80% to a plain query engine and let only the questions that provably need multiple hops reach the agent:

python
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import PydanticSingleSelector
from llama_index.core.tools import QueryEngineTool

router = RouterQueryEngine(
    selector=PydanticSingleSelector.from_defaults(),
    query_engine_tools=[
        QueryEngineTool.from_defaults(
            query_engine=index.as_query_engine(similarity_top_k=5),
            name="lookup",
            description="Single-fact lookups: one clause, one date, one number, one passage.",
        ),
        QueryEngineTool.from_defaults(
            query_engine=agent_engine,
            name="multi_hop",
            description="Questions spanning several corpora, or requiring a comparison or a follow-up query.",
        ),
    ],
)

And if you do need the agent, evaluate the trajectory, not just the final string — subscribe to ToolCallResult on the handler and log every query it actually issued. You will be surprised, and the surprise is the point.

Fails when: you make every query agentic and then try to run RetrieverEvaluator over it. Agent retrieval is not a pure function of the question — the same input searches once today and three times tomorrow — so there are no stable expected_ids to score against and hit_rate stops being computable at all. The operator loses the one deterministic, LLM-free quality metric they had, and every subsequent retrieval change becomes a coin flip resolved in production.

⟳ Grounded onquery_engine.transform_query_enginetools.query_engineindices.struct_store.sqlevaluation.retrieval.metrics
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

An Agent Where a Query Engine Would Do fails when you make every query agentic and then try to run  ____  over it.

Show answer

RetrieverEvaluator

Interleave

An Agent Where a Query Engine Would Do — this mistake — pairs with Query engines, chat engines, agents on the architecture page: that layer is the seam this mistake tears. Read the two back to back, then say in one sentence what makes them different.

Pair with: Query engines, chat engines, agents

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into a testing room and see a giant mechanical scale named RetrieverEvaluator. Its left pan is labeled 'query' and holds a steady weight, but its right pan, labeled 'expected_ids', is shattered because the agent's retrieval changes every time.

Encodes: The moment you make retrieval agentic, you make it unevaluable by the only cheap, deterministic, LLM-free metric you had.

Grading Your RAG With a Golden Set the Model Wrote

You did the responsible thing. You built an eval set. You ran generate_qa_embedding_pairs(nodes=nodes, llm=llm) (from llama-index-finetuning, or DatasetGenerator in llama_index.core.evaluation.dataset_generation), got back an EmbeddingQAFinetuneDataset of a few hundred question/node pairs, checked the JSON in, wired the assertion into CI, and shipped. Every threshold passes. It has passed every day since.

It will keep passing while your users suffer, and the reason is worth being precise about, because "the model graded itself" is only half of it.

A generator that reads chunk N and writes a question answerable from chunk N produces questions that are paraphrases of chunk N in the embedding model's own vocabulary. The retriever's job is then to find chunk N given a lightly-rewritten copy of chunk N — which is the easiest possible retrieval task, and one that dense vector search is structurally guaranteed to be good at. Your hit rate is 0.94, and it is 0.94 because the questions were derived from the answers.

Real questions aren't like that. Real users ask in vocabulary the corpus never uses ("why did the deploy blow up?" against a runbook that says "rollout aborted: readiness probe exceeded initialDelaySeconds"). They ask questions that span three chunks. They ask about the exact error code, the invoice number, the thing the embedding cannot see. They ask questions your corpus cannot answer — and a synthetic set contains none of those, because every question was generated from a node that answers it, so your suite has zero examples of the case where the right behaviour is to say "I don't know." You have built a fixture that certifies the exact failures your users are hitting.

So use the generator for what it is — a scaffold, not a golden set:

python

# 3. Then add the two classes the generator structurally cannot produce:
#      - real logged production queries, labelled by hand
#      - unanswerable questions, with expected_ids = []  (the abstention set)
qa = EmbeddingQAFinetuneDataset.from_json("eval/qa_dataset.json")   # checked in, hand-curated

hit = mean(r.metric_vals_dict["hit_rate"] for r in results)
assert hit >= 0.85, f"retrieval regressed: {hit:.2f}"
assert abstain_rate >= 0.9, "the system answered questions it should have refused"

Grow the set from real failures: every bug report becomes a case, forever. And keep the abstention set — it is the only thing that ever tests whether the pipeline you built in the first section can actually say "I don't know."

Fails when: the golden set is the unedited output of generate_qa_embedding_pairs. Every question was written from the node that answers it, so it is a vocabulary-matched paraphrase of the chunk and the retriever finds it trivially — and the set contains zero unanswerable questions, because the generator cannot produce one. The operator sees a hit rate of 0.94 and a green CI gate while production is confabulating over irrelevant chunks, and the eval suite certifies the exact failure the users are reporting.

⟳ Grounded onevaluatingretrieversbase.embeddings.base_sparseinstrumentation.events.embedding·faithfulness 1.00
Guessing where it breaks — before you read on — makes the answer stick.
STUDY AIDSevidence-backed memory techniques
Cloze

Grading Your RAG With a Golden Set the Model Wrote fails when the golden set is the unedited output of  ____ .

Show answer

generate_qa_embedding_pairs

Interleave

Grading Your RAG With a Golden Set the Model Wrote — this mistake — pairs with Evaluation & Observability: The Gate on the patterns page: that rung is the one this mistake gets wrong. Read the two back to back, then say in one sentence what makes them different.

Pair with: Evaluation & Observability: The Gate

From the research: Interleaving / discriminative contrast Similarity matters: A meta-analysis of interleaved learning and its moderators (2019)

Memory palace

You walk into the evaluation lab and see a golden trophy labeled 'Golden Set' on a pedestal; inside, a stack of identical papers each repeat the same chunk, and a mirror reflects the paper back at itself.

Encodes: the golden set is the unedited output of generate_qa_embedding_pairs

⟳ This essay is regenerated by a faithfulness-gated pipeline (see the provenance note in the footer of the repo).

The learning science behind it

The ten anti-patterns on this page are not just engineering craft — several of them are, structurally, working applications of memory science. A retrieval system and a remembering mind are solving the same problem, and they keep arriving at the same answers. Each lens below pairs one mechanism from this page with the documented memory-science principle it mirrors, states the mechanism in plain terms, and links to the full write-up on the learning-science principles page. The prose is generated by LlamaIndex, grounded ONLY in that principles corpus — not paraphrased from memory.


Memory is a byproduct of processing depth: semantic processing beats phonemic and orthographic, and elaboration helps most when it forms an integrated, coherent structure around the target. When code is merely recognized by surface rather than processed for meaning, it stays shallow—no integrated relation to prior knowledge. Retrieval collapses because the cue fails congruity: meaning isn’t recoverable from the remembered surface, exactly as the principle warns.

Mirrors The Quickstart Is Not a Product

Machine-learning analog Masked / self-supervised pretraining

Balepur et al. (2024) · Craik & Lockhart (1972) · Craik & Tulving (1975) · Devlin et al. (2019)


Distributed repetition flattens the forgetting curve, making memory stick by re-exposing material at optimal intervals. In retrieval, re-embedding a full corpus from scratch in one massed rebuild ignores this effect. Instead, spaced incremental passes that revisit only changed items—like neural replay fighting catastrophic forgetting—leverage the spacing effect to maintain retrieval quality with far less compute.

Mirrors Paying Twice for the Same Corpus

Machine-learning analog Experience replay (continual learning)

Cepeda et al. (2006) · Cepeda et al. (2008) · Ebbinghaus (1885) · Rolnick et al. (2019) · Settles & Meeder (2016) · Spens & Burgess (2024) · Xiao & Wang (2024)

Structure

The chunking principle states that working memory is limited in chunks, not bits, so recoding raw material into meaningful units multiplies capacity. In a retrieval system, tuning chunk size to keep a single coherent idea inside one small, self-contained group applies this: the bounded unit matches the chunk limit, making retrieval efficient and recall reliable.

Mirrors Tuning Chunk Size Against Generation Quality

Machine-learning analog Subword tokenization (BPE)

Cowan (2001) · Elsner et al. (2026) · Ericsson et al. (1980) · Lee et al. (2025) · Miller (1956) · Sennrich et al. (2016)


Encoding specificity and transfer-appropriate processing makes memory stick because a retrieval cue works only if it was part of the original encoding. In a retrieval system, this means the query’s cues must match the form in which documents were stored. When cues are mismatched—like a wrong filter key or stale namespace—the probe fails to retrieve present material, a cue failure not a storage failure.

Mirrors Trusting an Empty Retrieve

Machine-learning analog Retrieval-augmented generation

Godden & Baddeley (1975) · Kang et al. (2025) · Lewis et al. (2020) · Tulving & Thomson (1973)


Desirable difficulties holds that effortful processing builds storage strength, enhancing long-term retention. In retrieval systems, choosing the harder path—performing effortful reranking, crafting better cues, or honest evaluation—rather than swapping in a bigger easier model applies this principle. The easy upgrade merely masks retrieval failures, while the deliberately harder retrieval work strengthens the system’s ability to retrieve durably.

Mirrors Reaching for a Bigger Model

Machine-learning analog Curriculum learning

Bengio et al. (2009) · Bjork (1994) · Hwang et al. (2026) · Lee et al. (2026) · Shu et al. (2026)


Retrieval practice (the testing effect) works because successful effortful retrieval strengthens and multiplies retrieval routes, unlike re-exposure. A LlamaIndex system that retrieves against independently written queries, rather than re-reading its own generated golden set, forces genuine effortful retrieval. This external test reveals what the retriever actually gets right, mirroring the principle that testing on outside material builds stronger associative memory than self-testing.

Mirrors Grading Your RAG With a Golden Set the Model Wrote

Machine-learning analog Associative memory / pattern completion

Eldho Paul & Sunar (2026) · Karpicke & Blunt (2011) · Ramsauer et al. (2020) · Roediger & Karpicke (2006)

The shared mechanism is that these principles are general constraints for any system storing and retrieving under interference. Encoding principles (1–7) set the starting strength of each trace; retrieval practice (9–10) governs how that strength grows; spacing (8) determines whether growth compounds or decays; and encoding specificity (13) keeps the entire loop connected by ensuring the retrieval cue matches the encoded trace. Together, they form a dependency structure where each layer reinforces the others, so a system employing all of them recalls far more reliably than any single principle alone.

Explore the memory principles →

Glossary — the domain terms, grounded in the code

16terms, each defined from this subsystem’s real source.

VectorIndexRetriever

VectorIndexRetriever is the retriever built by as_query_engine() that performs an ANN search across the whole collection with similarity_top_k defaulting to 2 in llama_index/core/constants.py, and it passes any MetadataFilters from its filters= argument directly into the vector store to constrain the candidate set before the search runs.

Memory hook VectorIndexRetriever fetches exactly two nearest neighbors, but only from the pen fenced by MetadataFilters.

From llamaindex_anti_patterns_seed.md

SimilarityPostprocessor

SimilarityPostprocessor is a node postprocessor that filters out retrieved nodes by reading their `node.score` and dropping any node whose score is below a specified `similarity_cutoff`, and it treats a missing score as a failure; it should be placed before a reranker in the `node_postprocessors` list so it operates on cosine-scale scores rather than on the unbounded cross-encoder logits that a reranker writes.

Memory hook SimilarityPostprocessor is a bouncer who checks the original cosine score and rejects low ones before the reranker changes the score language.

From llamaindex_anti_patterns_seed.md

SentenceTransformerRerank

SentenceTransformerRerank is a LlamaIndex node postprocessor that replaces each node's score with a cross-encoder logit (unbounded and frequently negative) and defaults to keeping the top 2 nodes, so it must be placed after any threshold-based filter and configured with `keep_retrieval_score=True` if the original retrieval score is needed.

Memory hook SentenceTransformerRerank is a score graffiti artist that spray-paints unbounded logits, obliterating retrieval scores.

From llamaindex_anti_patterns_seed.md

IngestionPipeline

IngestionPipeline is a class that manages document ingestion with deduplication and caching by accepting a docstore, vector store, and docstore_strategy (defaulting to UPSERTS), processing documents only when their ID is new or hash has changed, and persisting both cache and docstore for reuse across runs.

Memory hook IngestionPipeline is a conveyor that stamps "already processed" on known docs, skipping re-computation.

From llamaindex_anti_patterns_seed.md

DocstoreStrategy

DocstoreStrategy is a parameter of IngestionPipeline, defaulting to UPSERTS, that controls document processing: it only processes a document when its ID is new or its hash has changed, deleting old nodes from both the docstore and vector store before inserting new ones.

Memory hook DocstoreStrategy is the doorman that checks each document's ID and hash, only admitting new or changed docs and evicting old nodes.

From llamaindex_anti_patterns_seed.md

stores_text

stores_text is a boolean property on vector store implementations (set True by Qdrant, Pinecone, Weaviate, and pgvector) that, when True, causes VectorStoreIndex._add_nodes_to_index to skip writing nodes to the docstore, leaving the docstore empty and preventing deduplication when using from_documents.

Memory hook When stores_text says “I’ve got it,” the docstore gets no copy, so dedup never runs.

From llamaindex_anti_patterns_seed.md

store_nodes_override

store_nodes_override is a boolean flag inside VectorStoreIndex._add_nodes_to_index that, when True, forces the docstore write even if the vector store already stores text; it defaults to False, so with real vector databases like Qdrant or Pinecone the docstore remains empty because the condition `not self._vector_store.stores_text or self._store_nodes_override` is false.

Memory hook store_nodes_override is the "copycat" flag that forces the docstore to mirror nodes even when the vector store already stores them.

From llamaindex_anti_patterns_seed.md

hit_rate

Hit_rate is a retrieval metric from llama_index's RetrieverEvaluator that measures the fraction of queries for which the retriever returned at least one relevant document, and it acts as a hard ceiling on downstream answer correctness.

Memory hook Batting average for your retriever – hit rate sets the ceiling that no model can raise.

From llamaindex_anti_patterns_seed.md

metadata filters

In the LlamaIndex subsystem, `MetadataFilters` is a correctness constraint that changes the candidate set before the ANN search runs, carried as the `filters` field in `VectorStoreQuery` and passed directly from `VectorIndexRetriever` to the vector store's native pre-filter; it must be used for security‑bearing conditions like tenancy and never placed in the prompt.

Memory hook MetadataFilters are the bouncer that checks your tenant ID at the ANN door, not the prompt table.

From llamaindex_anti_patterns_seed.md

source-derived doc_id

source-derived doc_id means the document identifier is computed from a stable external reference such as a URL, primary key, or file path, so that when IngestionPipeline runs with DocstoreStrategy.UPSERTS, it can detect unchanged documents and skip reprocessing, preventing the nightly cron from re-embedding the same corpus.

Memory hook A source-derived doc_id is a permanent address for each document, stopping the nightly re-embed bill.

From llamaindex_anti_patterns_seed.md

top_k

In the excerpts, top_k is the integer value of the `similarity_top_k` parameter that controls how many nearest-neighbor nodes the `VectorIndexRetriever` fetches from the vector store before any postprocessing or reranking, with a default of 2 from `DEFAULT_SIMILARITY_TOP_K` in `llama_index/core/constants.py`.

Memory hook top_k is the size of your initial fishing net; widen it to give the reranker enough candidates to choose from.

From llamaindex_anti_patterns_seed.md

similarity_cutoff

similarity_cutoff is a parameter of the `SimilarityPostprocessor` in the `node_postprocessors` list that sets a numeric threshold on `node.score`, dropping any node whose score is below that cutoff; it must be placed before a reranker because the reranker overwrites scores with unbounded cross-encoder logits that may be negative and thus fail the cutoff.

Memory hook Similarity_cutoff is the bouncer that cuts low scores before the reranker’s logits turn them negative.

From llamaindex_anti_patterns_seed.md

node_postprocessors

node_postprocessors is an ordered list of postprocessing stages in the query engine pipeline where a reranker may unconditionally overwrite node.score with a cross-encoder logit and a filter such as SimilarityPostprocessor reads that score to drop nodes, so the order determines whether a cutoff applied after the reranker rejects all nodes.

Memory hook node_postprocessors is an ordered gauntlet: reranker rewrites scores, then filter drops all if placed after.

From llamaindex_anti_patterns_seed.md

abstention set

The abstention set is a hand-edited addition to the golden set—a collection of queries designed to test whether the pipeline can return zero nodes or say "I don't know," because the original generator-output golden set contained no unanswerable questions and thus produced a false 0.94 hit rate that masked retrieval failures in production.

Memory hook Abstention set: the hand-crafted questions the generator couldn't produce, testing if your pipeline can say 'I don't know'.

From llamaindex_anti_patterns_seed.md

golden set

A golden set is the unedited output of `generate_qa_embedding_pairs`, used as an eval fixture that certifies a high hit rate (e.g., 0.94) but contains only answerable questions written from the chunk and zero unanswerable questions, so it silently passes thresholds while production confabulates over irrelevant chunks.

Memory hook Golden set: a mirror that always smiles back – it never says 'I don't know'.

From llamaindex_anti_patterns_seed.md

Settings globals

Settings is a process-global singleton that configures models like the embedding model once, but when ingest and query processes set it differently, `VectorStoreIndex.from_vector_store()` falls through to a default `OpenAIEmbedding()` without recording which model wrote the collection, causing silent retrieval drift.

Memory hook Settings globals are like two radio stations on the same frequency — one BGE, one OpenAI — producing static.

From llamaindex_anti_patterns_seed.md