Most RAG evaluations test the average case and miss the hard ones. Needle-in-a-haystack testing systematically exposes where your retrieval breaks down — by document length, information position, and collection scale. Learn how to build, run, and interpret NIAH tests to harden your pipeline before your users find the gaps.

Imagine you've spent weeks building a RAG system for a legal firm. You've ingested hundreds of contracts, configured your vector database, tuned your embedding model, and run a handful of manual tests that looked great. You ship it. A week later, a paralegal reports that the system completely missed a critical indemnification clause buried on page 47 of a 90-page agreement. Your pipeline returned confidently wrong answers because it simply never surfaced that specific chunk.
This is exactly the kind of failure that basic evaluation misses. Most developers test their RAG systems by asking a few representative questions and eyeballing the results. That approach will catch obvious problems, but it leaves a whole category of subtle, position-dependent retrieval failures completely invisible. Needle-in-a-haystack (NIAH) testing is the systematic method for exposing those failures — it tells you not just whether your system can retrieve relevant information, but where in your documents and at what scale it starts to break down.
By the end of this lesson, you'll be able to design and run your own NIAH evaluation suite, interpret its results, and use those insights to harden your retrieval pipeline against real-world document complexity.
What you'll learn:
You should have a working familiarity with how RAG systems retrieve information — if you're new to the concept, start with RAG Fundamentals: Build Your First Retrieval-Augmented Generation System before continuing. You should also understand what chunks are and how they get stored in a vector database. Some Python familiarity is assumed for the code sections.
The metaphor is literal: you hide a specific fact (the needle) somewhere inside a large body of text (the haystack), then ask your system a question that can only be answered using that fact. If the system retrieves the right chunk and answers correctly, it found the needle. If it returns something plausible-but-wrong — or hallucinates entirely — it didn't.
The key word here is controlled. What makes NIAH testing valuable isn't just asking hard questions; it's systematically varying three independent dimensions to build a failure map:
1. Document length — Does your retrieval accuracy drop as a single document gets longer? A fact buried in a 500-word document is much easier to find than the same fact buried in a 50,000-word document, even if both produce the same number of chunks. Embedding drift, chunking boundary effects, and token window limitations all interact here.
2. Position within the document — Is your system better at finding facts near the beginning or end of a document than in the middle? This turns out to be one of the most reliable failure patterns across embedding models. Information in the middle of a long document often gets "averaged away" during the embedding process, making it harder to distinguish from neighboring text.
3. Collection size — Does retrieval accuracy degrade as you add more documents to your vector store? A needle that's easy to find in a 10-document collection might get buried when you scale to 10,000 documents, as the semantic neighborhood becomes denser and more competitive.
By testing all three dimensions, you build a performance map instead of a point estimate. That map tells you exactly where your system is fragile.
Key insight
A RAG system that achieves 90% accuracy in a small, controlled demo can drop to 60% accuracy in production because production has longer documents, messier content, and ten times as many of them. NIAH testing bridges that gap before your users discover it.
Standard RAG evaluation — covered in depth in Evaluating RAG Systems: Precision, Recall, and Faithfulness — typically measures metrics like context precision, context recall, and answer faithfulness using a fixed test set. These are genuinely useful metrics, but they have a structural blind spot: the test questions are usually distributed fairly evenly across the document collection, which means they implicitly test the average case, not the hard cases.
Think about what that average case looks like. Your test questions might draw facts from introductions (which are dense with key information), from well-indexed headings, or from frequently-repeated concepts that appear in multiple chunks and are therefore easy to retrieve even if one chunk is missed. The middle-of-document, long-document, large-collection failures get averaged away in aggregate metrics.
NIAH testing is adversarial by design. You construct cases specifically to stress the dimensions where retrieval is hardest. That means your NIAH scores will almost always be lower than your standard eval scores — and that's the point. You want to know your floor, not just your ceiling.
Note
NIAH testing is complementary to standard evaluation, not a replacement for it. Run both. Standard evals tell you how your system performs on realistic queries. NIAH tests tell you where your system breaks under adversarial but plausible conditions.
A well-designed NIAH test case has four components:
The needle — A specific, unambiguous fact that doesn't appear elsewhere in the corpus. "The contract renewal date is March 15, 2026" is a good needle. "The company values innovation" is a terrible needle because it's probably paraphrased in dozens of chunks.
The haystack — A surrounding body of text that's topically similar enough to create genuine retrieval competition. If your needle is about contract law, surround it with other contract-law text, not recipes.
The query — A natural language question that a real user might ask, which can only be answered correctly using the needle. "What is the contract renewal date?" works well.
The ground truth — The exact answer you expect, so you can score the response programmatically.
Here's a concrete Python structure for a test case:
from dataclasses import dataclass
from typing import Optional
@dataclass
class NIAHTestCase:
test_id: str
needle_text: str # The specific fact to hide
needle_position_pct: float # 0.0 = beginning, 1.0 = end
haystack_word_count: int # Total document length target
collection_size: int # Total docs in the vector store
query: str # The question to ask
expected_answer: str # Ground truth for scoring
retrieved_answer: Optional[str] = None
retrieved_needle: Optional[bool] = None # Did the correct chunk come back?
score: Optional[float] = None
The needle_position_pct field is what lets you build a position-versus-accuracy heatmap later. A value of 0.1 means the needle is placed in the first 10% of the document; 0.5 means the middle; 0.9 means near the end.
To control position precisely, you'll assemble the document by concatenating filler text, the needle sentence, and more filler text:
def build_haystack_document(
needle_text: str,
filler_sentences: list[str],
position_pct: float,
target_word_count: int
) -> str:
"""
Insert needle_text at position_pct through a document
built from filler_sentences, padded to target_word_count.
"""
total_filler_words = target_word_count - len(needle_text.split())
insert_at_word = int(total_filler_words * position_pct)
# Build filler corpus by cycling through sentences
filler_words = []
for sentence in filler_sentences * 100: # cycle as needed
filler_words.extend(sentence.split())
if len(filler_words) >= total_filler_words:
break
filler_words = filler_words[:total_filler_words]
# Insert needle at the calculated word position
doc_words = (
filler_words[:insert_at_word]
+ needle_text.split()
+ filler_words[insert_at_word:]
)
return " ".join(doc_words)
For your filler text, use domain-appropriate content. If you're testing a legal RAG system, use boilerplate contract language. If you're testing a technical documentation system, use API reference text. The more topically similar the haystack is to the needle, the harder the test — and the more realistic.
Tip
Don't use Lorem Ipsum as filler. Embedding models are trained on real text, and nonsense filler creates an artificially easy retrieval task because it's semantically distant from any real query. Use real domain text to create genuine competition.
Now let's wire this up into a full evaluation loop that tests across all three dimensions. We'll define ranges for document length, position, and collection size, then run the pipeline on each combination.
import itertools
import numpy as np
from your_rag_pipeline import chunk_document, embed_chunks, retrieve, generate_answer
# Define test dimensions
DOCUMENT_LENGTHS = [2_000, 10_000, 30_000] # words
POSITION_PERCENTAGES = [0.1, 0.3, 0.5, 0.7, 0.9]
COLLECTION_SIZES = [10, 100, 1_000, 5_000]
# Your needle
NEEDLE = "The indemnification cap for this agreement is USD 2.5 million."
QUERY = "What is the indemnification cap specified in the agreement?"
EXPECTED_ANSWER = "USD 2.5 million"
results = []
for doc_length, position_pct, coll_size in itertools.product(
DOCUMENT_LENGTHS, POSITION_PERCENTAGES, COLLECTION_SIZES
):
# 1. Build the target document with needle at the right position
haystack_doc = build_haystack_document(
needle_text=NEEDLE,
filler_sentences=load_filler_sentences("legal_boilerplate.txt"),
position_pct=position_pct,
target_word_count=doc_length,
)
# 2. Chunk and embed it along with (coll_size - 1) distractor documents
target_chunks = chunk_document(haystack_doc, chunk_size=512, overlap=64)
distractor_chunks = load_distractor_chunks(
count=coll_size - 1,
domain="legal"
)
all_chunks = target_chunks + distractor_chunks
vector_store = embed_chunks(all_chunks)
# 3. Query the pipeline
retrieved_chunks = retrieve(QUERY, vector_store, top_k=5)
answer = generate_answer(QUERY, retrieved_chunks)
# 4. Score: did the needle chunk appear in retrieved results?
needle_retrieved = any(NEEDLE in chunk for chunk in retrieved_chunks)
answer_correct = EXPECTED_ANSWER.lower() in answer.lower()
results.append({
"doc_length": doc_length,
"position_pct": position_pct,
"collection_size": coll_size,
"needle_retrieved": needle_retrieved,
"answer_correct": answer_correct,
})
print(f"Overall retrieval rate: {np.mean([r['needle_retrieved'] for r in results]):.2%}")
print(f"Overall answer accuracy: {np.mean([r['answer_correct'] for r in results]):.2%}")
Notice that we track two separate signals: whether the needle chunk was retrieved at all (needle_retrieved), and whether the final answer was correct (answer_correct). These can diverge. A powerful LLM might occasionally infer the right answer even without the exact chunk, which would mask a retrieval failure. Conversely, the needle chunk might be retrieved but the LLM might misread it. Tracking both lets you diagnose whether failures are in retrieval or in generation.
The real value of NIAH testing comes from the heatmap. You want to visualize retrieval accuracy as a function of position (x-axis) and document length (y-axis), separately for each collection size:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
df = pd.DataFrame(results)
for coll_size in COLLECTION_SIZES:
subset = df[df["collection_size"] == coll_size]
pivot = subset.pivot_table(
index="doc_length",
columns="position_pct",
values="needle_retrieved",
aggfunc="mean"
)
plt.figure(figsize=(10, 5))
sns.heatmap(pivot, annot=True, fmt=".0%", cmap="RdYlGn",
vmin=0, vmax=1,
xticklabels=[f"{p:.0%}" for p in POSITION_PERCENTAGES],
yticklabels=[f"{l:,}" for l in DOCUMENT_LENGTHS])
plt.title(f"NIAH Retrieval Accuracy — Collection Size: {coll_size:,} docs")
plt.xlabel("Needle Position in Document")
plt.ylabel("Document Length (words)")
plt.tight_layout()
plt.savefig(f"niah_heatmap_coll{coll_size}.png")
What you're looking for in the output: green cells mean reliable retrieval, red cells mean consistent failure. A common real-world pattern is a diagonal band of red running through the middle-position, long-document quadrant — exactly the place you'd least expect a problem.
Warning
If your heatmap looks uniformly green across all conditions, you've probably made your test too easy. Check that your filler text is genuinely similar to the needle's domain, and that your collection includes realistic distractors — not empty placeholder documents.
Here are the three patterns you'll most commonly encounter and what each one means for your system.
Retrieval accuracy is high at positions 10% and 90% but drops significantly at 50%. This is one of the most commonly reported findings across embedding models and was a well-known problem with LLM attention mechanisms (sometimes called "lost in the middle"). For RAG, the cause is subtler: chunks near the beginning of a document often appear in section headers and topic sentences, which are semantically rich. Chunks near the end often appear in summaries or conclusions. Middle chunks get less natural reinforcement.
Fix: Experiment with smaller chunk sizes for long documents so the needle occupies more of a chunk's semantic weight. Also consider chunking strategies that preserve structural context, such as parent-child chunking where small chunks are retrieved but larger context is returned.
Accuracy holds steady at 10 and 100 documents but falls sharply at 1,000 or more. This means your embedding model's nearest-neighbor search is hitting a density problem: there are enough semantically similar distractor chunks that the needle's embedding is no longer close enough to the query embedding to rank in the top-k results.
Fix: Look at increasing your top-k retrieval count and pairing it with a cross-encoder reranker that can more precisely score the expanded candidate set. Also consider whether metadata filtering could pre-filter the collection before vector search runs.
Short documents retrieve reliably regardless of position, but accuracy degrades uniformly as documents get longer — not just in the middle. This often points to a chunking problem: chunks near the boundaries of long documents may be getting clipped mid-sentence, producing semantically incomplete embeddings that score poorly against queries.
Fix: Revisit your chunk boundary detection. Sentence-aware chunking with proper overlap tends to perform better on long documents than fixed-token chunking. This is also a case where late chunking with long-context embeddings can help by embedding chunks with full document context rather than in isolation.
Key insight
These patterns aren't mutually exclusive. Many production systems exhibit all three simultaneously — the compound failure rate in the long-document, middle-position, large-collection corner can be dramatically worse than any single dimension suggests.
Build a minimal NIAH test suite for a topic you care about. Here's a structured walkthrough:
Step 1: Choose your domain. Pick a realistic scenario — product documentation, HR policies, research papers, financial filings. This determines your filler text and needles.
Step 2: Write five needles. Create five specific, unambiguous facts that don't appear elsewhere in your corpus. Example for a product documentation domain: "The maximum API rate limit for the Enterprise tier is 10,000 requests per minute."
Step 3: Create your filler corpus. Collect at least 20,000 words of real domain text. Do not use Lorem Ipsum.
Step 4: Build your test grid. Use three document lengths (short, medium, long — appropriate for your domain), three positions (10%, 50%, 90%), and three collection sizes (small, medium, large). That gives you 27 test cases per needle, 135 total.
Step 5: Run your existing RAG pipeline on each case. If you don't have a pipeline yet, build one using the guide in Building Your First RAG Pipeline before running this exercise.
Step 6: Compute and visualize your retrieval rate by dimension. Even a simple grouped bar chart — retrieval rate by position, then by document length, then by collection size — will reveal patterns.
Step 7: Write one paragraph of findings. "My system performs well on short documents regardless of position (above 90% retrieval), but drops to 55% retrieval for documents over 15,000 words when the needle is in the middle third. Collection size has minimal effect up to 500 documents." That sentence is actionable. It tells you exactly where to focus optimization effort.
Mistake: Using the same needle text as the query. If your needle says "The indemnification cap is USD 2.5 million" and your query is "What is the indemnification cap?", that's fine — but if your query is "The indemnification cap is USD 2.5 million — is this correct?", you're embedding the answer in the query, which makes retrieval artificially easy. Keep query and needle semantically related but lexically distinct.
Mistake: Testing only one needle. One needle tests one semantic neighborhood. Different needles will expose different failure patterns. Always use at least five needles covering different semantic themes in your domain.
Mistake: Forgetting that chunking affects position. If you insert your needle at word 5,000 in a 10,000-word document (position 50%), but your chunking creates 512-token chunks with 64-token overlap, the needle might land at the very start or end of a chunk depending on boundary alignment. Log the actual chunk that contains the needle and verify its relative position within the chunk.
Mistake: Only measuring answer correctness, not chunk retrieval. As mentioned earlier, these can diverge in both directions. Always track both metrics independently.
Warning
Be careful when comparing NIAH results across different embedding models. If you swap your embedding model, you must re-chunk and re-embed your entire test corpus, because the chunk boundary semantics are model-specific. Running NIAH on model A's chunks with model B's embeddings will produce misleading results.
Troubleshooting — all results are 0%: Check that your needle text actually survived the chunking process intact. Long needles (more than a few sentences) may get split across chunk boundaries. Also verify that your vector store is actually being populated with the test chunks before querying.
Troubleshooting — all results are 100%: Your haystack is probably too easy. Check that distractor documents are topically similar to the needle, not completely unrelated content. Also check your top-k value — retrieving 20 chunks from a 10-document collection means you're effectively returning everything.
Needle-in-a-haystack testing is a precise, adversarial methodology for understanding exactly where your RAG retrieval breaks down. By independently varying document length, information position, and collection size, you build a three-dimensional failure map that aggregate metrics simply cannot reveal.
The workflow is:
From here, there are several natural directions to explore. If your NIAH results revealed large-collection failures, explore hybrid search combining keyword and semantic signals — the lexical component often rescues exact-match needles that semantic search misses at scale. If you saw long-document failures, understanding how LLM context windows shape RAG design will help you make smarter decisions about how much context to retrieve and how to present it.
If you want to build NIAH testing into a continuous evaluation pipeline rather than running it one-off, the patterns from Production RAG: Caching, Monitoring, and Continuous Improvement apply directly — you can schedule nightly NIAH runs against your production collection and alert when retrieval rates drop below threshold.
Tip
Run NIAH tests every time you make a significant change to your pipeline — new embedding model, new chunk size, new vector database configuration. Each change shifts your failure map, and what was a strength before might become a weakness after. Treat NIAH scores as first-class regression metrics.
The goal isn't a perfect heatmap. The goal is knowing exactly where your system is reliable and where it isn't — so you can be honest with your users, prioritize your engineering work, and ship a system that finds the needle every time it matters.