A raw LLM invents plausible test cases. RAG makes it read your actual specs, tickets, and past bugs first, so every case it writes traces back to a real requirement. This guide walks the whole pipeline with a playable diagram and a practical roadmap.
Pick a stage and press Play. The diagram lights up each component as your QA corpus is indexed once, then queried per question to pull the exact passages that ground the answer.
RAG is two loops: an offline ingest that turns your docs into a searchable vector memory, and an online query that embeds a question, retrieves the closest passages, and hands them to the LLM as pinned context.
RAG rewards a small start. One folder of real docs beats a grand ingestion plan that never ships. Build the index, prove grounding, then operate it.
Retrieval augmented generation (RAG) is a small idea with a large payoff: instead of asking a language model to answer from memory, you fetch the relevant slices of your own documents at the instant the question is asked, then hand them to the model as context. The model answers from what you supplied, not from whatever it happened to absorb during training. Strip away the jargon and RAG is two moves stapled together: a search over your content, followed by a generation that is told to stay inside the search results. The retriever finds the right pages, the model writes the prose, and the interesting engineering lives in making the first step return the pages that actually matter.
That distinction matters more for QA than for almost any other function, because the entire value of a tester's knowledge is that it is specific. A model trained on the public internet knows what a checkout flow generally looks like. It does not know that your checkout grew a guest path in the March release, that ticket BUG-4471 proved the coupon field double-applies under a race, or that your smoke suite has three known-flaky specs that fail on cold starts. None of that is in the weights. RAG is how you get it into the answer, on demand, without retraining anything.
Ask a bare LLM to "write the regression cases for our password reset" and you get a fluent, plausible, generic list. It invents an email template you do not send, a lockout threshold you never set, and a rate limit that does not exist. This is not a defect in the model; it is doing exactly what it was built to do, which is produce the most likely text. The trouble is that "most likely" is averaged over a million other apps, not yours. In QA that failure mode is expensive, because a confidently wrong test case looks identical to a correct one until it fails in review or, worse, quietly passes against behavior nobody asked for.
RAG changes the input, not the model. Before it writes a word, a retriever pulls the actual password-reset acceptance criteria, the two past reset bugs, and the current spec file, then pastes them into the prompt with instructions to answer only from that context and to cite the source of each claim. Now the output is anchored. When it is wrong you can trace the error to a specific document; when it is right it points at the requirement it came from. That traceability is what turns a novelty demo into something you would let near a release. This is the core promise behind rag for qa: grounded output you can audit.
| Dimension | Plain LLM | RAG-grounded answer |
|---|---|---|
| Source of truth | Training data (a million other apps) | Your retrieved documents |
| Knowledge of your app | None; it guesses the shape | Whatever is in your index |
| Freshness | Frozen at the training cutoff | As fresh as your last sync |
| Citations | Invented or absent | Points at the exact document |
| Typical failure | Confident hallucination | "Not in the context" (a safe failure) |
| Updating knowledge | Retrain or fine-tune | Add a file to the store |
The word "documents" throws people. In a rag for qa setup, a document is any durable artifact that encodes how your system is supposed to behave, or how it has actually behaved. You already own all of these; they are just scattered across five tools and nobody's head:
Pulled into one searchable index, these become your rag test knowledge base: the corpus a retriever draws from at answer time. Everything downstream is capped by the quality of this corpus. A retriever that indexes stale requirements will confidently generate tests for a feature you shipped differently. Curating the rag test knowledge base, deciding what goes in, how it is chunked, and how often it re-syncs, is the real work of RAG testing, far more than the model choice.
| Your artifact | What it encodes | What RAG does with it |
|---|---|---|
| Requirements / acceptance criteria | Intended behavior | Generate test cases that match the spec |
| Past bugs + root causes | How it actually broke | Triage a new failure against known ones |
| Existing test suites | Current coverage | Surface gaps and duplicate cases |
| Runbooks / postmortems | Operational reality | Ground reliability and incident checks |
| Flaky history / CI logs | Instability patterns | Flag likely-flaky new specs before merge |
You do not need an exotic agent to justify standing up retrieval augmented generation QA tooling. Three plain workflows earn it back inside a sprint:
Generate test cases from requirements. Feed a story key, retrieve its acceptance criteria plus any linked bugs, and ask for positive, negative, and boundary cases. Because the context is your spec, the cases reference your real fields and states, and each one cites the criterion it covers, so the gaps are visible immediately.
Triage a failure against past bugs. When a run goes red, retrieve the closest historical failures by stack trace and error text. The model answers "this looks like BUG-4471, same coupon race, here is the prior fix" instead of you re-diagnosing a known issue for the third time. This is where RAG testing quietly saves the most hours.
Find coverage gaps. Retrieve the requirements alongside the existing specs and ask what is asserted nowhere. The overlap between "what the spec demands" and "what the suite checks" is exactly the kind of set difference retrieval makes tractable across hundreds of files.
# 1. RETRIEVE - semantic search over your QA corpus query = "regression cases for guest checkout coupon handling" hits = index.search(embed(query), top_k=6) # 2. AUGMENT - build a grounded prompt from what came back context = "\n\n".join(f"[{h.source}] {h.text}" for h in hits) prompt = f"""You are a QA engineer. Use ONLY the context below. Cite the [source] for every case. If the context is silent, say so. Context: {context} Task: {query}""" # 3. GENERATE - the model writes from your docs, not its memory answer = llm.complete(prompt, temperature=0.2)
Notice what the snippet refuses to do: it never trusts the model to recall your system. Every fact it can use arrives through index.search, and the prompt explicitly licenses the answer "the context is silent" so that a miss surfaces as an honest gap rather than a fabrication. That single instruction is most of the safety in a retrieval augmented generation QA pipeline.
[source] and confirm it. If your setup produces ungrounded prose, tighten the prompt to require a source per claim and reject answers that lack one.Two alternatives always come up. Fine-tuning bakes knowledge into the model's weights: powerful for teaching a fixed style or output format, but wrong for facts that change every sprint. Your bug list moves faster than any retrain schedule, and a fine-tune cannot cite where an answer came from. RAG keeps the knowledge in an external store you can edit in seconds, which is why it fits the churn of real QA work.
The other alternative, pasting your whole corpus into a long context window, breaks on cost and recall. You re-pay for those tokens on every call, and retrieval quality degrades as you stuff more in, models reliably lose facts buried in the middle of a huge prompt. Retrieval sends the six chunks that matter instead of six thousand that do not.
| Approach | Where knowledge lives | Update speed | Best when |
|---|---|---|---|
| Fine-tuning | Model weights | Hours to days per retrain | Stable style, not facts |
| Long context (paste all) | The prompt, every call | Instant but re-paid each time | Small, one-off corpora |
| RAG | An external index | Seconds; edit one file | Large, changing QA corpora |
That is the whole case for QA. A plain model gives you an eloquent stranger's opinion about a checkout it has never seen. RAG gives you an assistant that reads your requirements, your bug history, and your existing suite before it speaks, and shows its work. For a discipline whose currency is specific, verifiable truth about one particular system, retrieval augmented generation QA is less a novelty than the obvious way to make an LLM useful. The rest of this guide is about building that rag test knowledge base and the RAG testing workflows on top of it, well enough to trust.
Strip away the framework names and RAG is five stages in a straight line: chunk, embed, store, retrieve, generate. The first three run once, at ingest time, and produce a searchable index. The last two run on every query. If you have ever built a CI pipeline the shape is familiar: a build phase that emits an artifact, and a run phase that consumes it. That split matters for RAG for QA, because most of the answer quality is decided at ingest time, long before anyone types a question. Get chunking or embedding wrong and the symptom only surfaces at retrieval, two stages downstream, which is why teams so often debug the wrong thing. Walk each stage with a QA lens and the failure modes stop hiding.
Retrieval works on passages, not whole documents. Chunking splits your source material into pieces of a few hundred tokens each, so a search can return one relevant acceptance criterion instead of an entire forty-page PRD. Your sources are the artifacts you already own: a requirements doc, a Confluence page, a Jira epic, an OpenAPI or GraphQL schema, last quarter's regression suite. A naive splitter cuts every N characters. A better one splits on structure, so a heading stays glued to its paragraph and each REST endpoint in a spec becomes its own chunk. Two knobs control the outcome: chunk size and overlap. Small chunks retrieve precisely but can sever a rule from its exception; large chunks preserve context but dilute the match and burn tokens. A sane starting point is 300 to 500 tokens with 10 to 15 percent overlap, so a sentence split across a boundary still lands intact in one piece. For QA, aim for one testable idea per chunk: one acceptance criterion, one endpoint, one business rule. This is the least glamorous stage and the one that most often decides whether RAG for QA works at all.
An embedding model reads a chunk and returns a fixed-length list of numbers, a vector, that encodes what the text means rather than which exact words it uses. This is the idea most engineers skim past and then cannot debug later, so state it plainly: embeddings place text in a high-dimensional space where semantic neighbors sit close together. The passage "the system rejects an expired reset link" and the query "test that stale tokens fail" share almost no words, yet their vectors land near each other because they mean nearly the same thing. That is the whole trick behind retrieval augmented generation QA: matching on meaning, not on keywords, so a tester can ask in plain English. The models are concrete and swappable. OpenAI text-embedding-3-small emits 1536 dimensions; all-MiniLM-L6-v2 from sentence-transformers emits 384 and runs locally for free. Higher dimensions cost more to store and query, but they are not automatically more accurate: retrieval quality depends on the embedding model you pick and on using that same model at query time, not on the raw dimension count.
The vectors, the original text, and metadata go into a vector database: pgvector on Postgres, Chroma, Qdrant, Pinecone, or Weaviate. This store is your RAG test knowledge base, the durable index everything else queries. Two things make it more than a table. First, an approximate-nearest-neighbor index (HNSW or IVFFlat) keeps similarity search fast as the corpus grows past thousands of chunks; an exact scan of a million vectors per query does not scale. Second, metadata columns you can filter on: feature, release version, source system, author. Metadata is what lets a tester scope a retrieval to "only the checkout epic, only the current sprint" and keep last year's deprecated rules out of the generated tests. Treat your RAG test knowledge base like any other test asset: version it, re-ingest in CI when a spec changes, and delete chunks for removed features so stale requirements cannot leak into new cases.
from openai import OpenAI import psycopg from psycopg.rows import dict_row from pgvector.psycopg import register_vector client = OpenAI() db = psycopg.connect(DSN, row_factory=dict_row) # rows come back keyed by column register_vector(db) # teach psycopg to adapt a Python list into a pgvector value def embed(text: str) -> list[float]: # 1536-dim vector from ONE embedding model, reused for docs and queries resp = client.embeddings.create(model="text-embedding-3-small", input=text) return resp.data[0].embedding # INGEST ONCE: chunk the requirements doc, embed, store for chunk in split_into_passages(prd_markdown, size=400, overlap=60): vec = embed(chunk.text) db.execute( "INSERT INTO kb (feature, source, content, embedding) VALUES (%s,%s,%s,%s)", (chunk.feature, chunk.source, chunk.text, vec), )
Now the query path. Take the engineer's request, embed it with that same model, and ask the store for the nearest vectors. Nearness is a number: cosine similarity measures the angle between two vectors and ignores their length, which is why it is the default for text. Two passages about invalid logins score near 1.0; an unrelated billing rule scores near 0. The store returns the top-k closest chunks, where k is usually 4 to 8. A query like "generate negative and boundary tests for the password reset flow" retrieves the handful of criteria that actually govern reset, and nothing about avatars or invoices. This retrieval step is the heart of a RAG testing workflow: it decides which facts the model is even allowed to see. Tune it with three knobs (top-k, a similarity floor that drops weak matches, and a metadata filter) and measure it directly, because a generation that hallucinates is usually a retrieval that missed.
The final stage assembles a prompt: the retrieved passages as CONTEXT, plus your instruction, handed to an LLM. The model no longer invents a generic login checklist; it writes cases anchored to your actual rules, and a good system prompt tells it to use only the context and to flag anything the spec does not cover. Ask it to emit Gherkin, a test matrix, or ready-to-run Playwright, and point those tests at a real target such as the login and form pages on app.thetestingacademy.com/playwright, so the output is executable rather than theoretical. This closing loop is retrieval augmented generation QA in practice: grounded input in, grounded tests out. When a teammate asks why RAG for QA beats pasting a spec into a chat window, the answer is this pipeline. It scales to hundreds of documents, filters to the relevant slice, and keeps the model honest by feeding it your truth instead of its training-data average.
# QUERY EVERY TIME: embed the question, retrieve top-k, generate query = "Generate negative and boundary tests for the password reset flow" qvec = embed(query) # <=> is pgvector's cosine-distance operator; ORDER BY gives nearest first rows = db.execute( "SELECT content FROM kb WHERE feature = %s " "ORDER BY embedding <=> %s LIMIT 6", ("password-reset", qvec), ).fetchall() context = "\n\n".join(r["content"] for r in rows) answer = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "Write tests only from CONTEXT. If a rule is missing, say so."}, {"role": "user", "content": f"CONTEXT:\n{context}\n\nTASK: {query}"}, ], )
Because the stages form a line, you debug them as a line: a wrong or invented test case is almost always a retrieval that surfaced the wrong passages, which is almost always a chunk that was too big or a spec that was never ingested. This five-stage shape is the RAG testing pattern you will reuse for every knowledge source you add. Here it is in one table, with the QA translation for each stage.
| Stage | What it does | QA example |
|---|---|---|
| Chunk | Splits sources into passages of a few hundred tokens | One acceptance criterion per chunk from the login PRD; one endpoint per chunk from the OpenAPI spec |
| Embed | Converts each chunk to a vector that encodes meaning | "Reject reused passwords" becomes a 1536-dim vector near "prevent password history" |
| Store | Indexes vectors + text + metadata for fast search | pgvector rows tagged by feature and release = the RAG test knowledge base |
| Retrieve | Embeds the query, returns the top-k nearest passages | "Tests for reset link expiry" pulls the 5 reset rules, skips billing |
| Generate | LLM writes an answer using only the retrieved context | Playwright negative tests citing the exact criteria, targeting the /playwright practice pages |
The retrieve and generate stages are where you spend tuning time, because they run on every query. These are the knobs that move quality the most, and each maps to a concrete QA concern.
| Knob | What it controls | QA effect and starting point |
|---|---|---|
| top-k | How many passages feed the model | Too low misses rules; too high adds noise and cost. Start at 4 to 8. |
| Similarity floor | Drops matches below a score | Stops an empty or thin knowledge base from returning irrelevant junk as if it were a spec. |
| Metadata filter | Restricts the search to a slice | Scope to one feature or the current sprint; exclude deprecated requirements before searching. |
| Chunk size + overlap | Passage granularity at ingest | One testable idea per chunk; 300 to 500 tokens with 10 to 15 percent overlap. |
Treat rag for qa as an incremental build, not a big-bang platform. The six stages below each carry a goal, a concrete do, and an exit criterion you can check before moving on. The common failure is standing up a vector store, wiring an LLM, and calling it finished before anyone has confirmed that a single generated test traces back to a real requirement. Ship the narrowest slice that works end to end, prove it against the exit gate, then widen. Do not skip a gate to look busy. Here is the whole path at a glance.
| Stage | Goal | Exit criteria |
|---|---|---|
| 1. Pick a corpus | Bounded, high-signal specs a tester would cite | Every doc answers "is this behavior correct"; no noise files |
| 2. Build the index | A searchable knowledge base | A known-answer query returns the right chunk in the top five |
| 3. First grounded test-gen | Tests grounded in retrieved specs | Every test cites a chunk; ten spot-checks match the spec |
| 4. Add more sources | Bugs and runbooks widen coverage | A run surfaces a past regression; context is mixed |
| 5. Evaluate faithfulness | Prove the output stays grounded | Faithfulness clears threshold; CI fails on drift |
| 6. Put it in the workflow | A standing SDLC step | Merged spec reaches tests within an SLA; owned and capped |
Goal. Choose a bounded, high-signal set of documents that a tester would actually cite when deciding whether behavior is correct. Narrow beats complete: one feature area, not the whole product. The first rag test knowledge base you build should be small enough that you can eyeball every document in it.
Do. Gather the artifacts a human reads before writing tests for that feature: the user story or requirement, its acceptance criteria, the API contract (OpenAPI or a Postman collection), and any design notes that pin down edge cases. Pull them out of Confluence, Jira, PDFs, and repos, and normalize everything to plain text or markdown so one chunker can handle it. Delete duplicates and superseded drafts now, because a corpus holding three conflicting copies of the same spec will retrieve the wrong one at the worst moment.
Exit. Every file in the collection is something you would open to answer "is this behavior correct." No slide decks, no launch emails, no stale drafts.
Resist ingesting the whole wiki on day one. Twenty sharp documents for one feature produce better grounded tests than two thousand mixed-quality pages, and they keep embedding cost and retrieval noise low while you are still tuning. Widen only after stage 3 proves the loop works.
Goal. Turn that corpus into a searchable rag test knowledge base.
Do. Chunk by structure rather than by fixed character count: one heading, one endpoint, or one acceptance-criterion bullet per chunk, roughly 300 to 800 tokens with a small overlap so a rule split across two bullets is not severed mid-sentence. Embed each chunk with an embedding model and store the vector, the original text, and metadata (source path, feature, version, doc type) so retrieval can filter and citations can name a real file.
import chromadb from openai import OpenAI client = OpenAI() db = chromadb.PersistentClient(path="./qa_kb") col = db.get_or_create_collection("requirements") def embed(text): r = client.embeddings.create(model="text-embedding-3-small", input=text) return r.data[0].embedding # one chunk = one heading or one acceptance-criterion bullet for chunk in chunks: col.add( ids=[chunk["id"]], embeddings=[embed(chunk["text"])], documents=[chunk["text"]], metadatas=[{"source": chunk["source"], "feature": "checkout", "version": "v3"}], )
Exit. A retrieval sanity check passes. Ask a question whose answer you already know, for example the max length of a field, and confirm the top hit is the exact chunk that states it. If the right chunk is not in the top five, fix chunking or swap the embedding model before you write any generation code.
Goal. Produce test cases grounded in retrieved requirements instead of the model's imagination. This is your first real taste of retrieval augmented generation qa.
Do. Embed the request, retrieve the top k chunks, and pass them into the prompt with a hard instruction: use only the provided context, and cite the chunk id behind every assertion. Have it emit a concrete artifact, a Gherkin scenario, a Playwright spec, or a boundary-value table. Point the pipeline at a feature you can actually exercise, for example the login and form-validation pages at app.thetestingacademy.com/playwright, so you can run the generated test and watch it pass or fail against observable behavior.
# retrieve, then generate ONLY from what came back q = "max length and allowed characters for the promo-code field" hits = col.query(query_embeddings=[embed(q)], n_results=5) context = "\n\n".join( # carry the id next to its text so the model can cite it f"[{cid}] {doc}" for cid, doc in zip(hits["ids"][0], hits["documents"][0]) ) tests = generate(prompt=GROUNDED_PROMPT, context=context) # cites the [chunk id] behind each case
Exit. Every generated test cites at least one source chunk. Spot-check ten by hand: the assertions match the spec, and there are no invented fields, endpoints, or status codes.
Goal. Widen the knowledge base so tests reflect real failure modes, not just the happy path the spec describes.
Do. Ingest bug history, runbooks, incident postmortems, and prior test cases, tagged by doc type so you can filter or weight them. Add hybrid retrieval (a lexical BM25 pass merged with vector search), because bug ids, error codes, and exact field names are keyword-shaped and pure semantic search often misses them. A requirement tells you what should happen; a closed bug tells you what already broke.
| Source | What it adds | Retrieval note |
|---|---|---|
| Requirements / specs | Intended behavior, acceptance criteria | Semantic vector search |
| API contracts (OpenAPI) | Fields, types, status codes | Exact names; favor hybrid |
| Bug history (Jira) | Real failure modes, regressions | Ids and errors are lexical; BM25 helps |
| Runbooks / postmortems | Operational edge cases, recovery | Semantic; tag by doc type |
| Prior test cases | Existing coverage, naming | Dedup against these to avoid repeats |
Exit. A generation run for a risky area surfaces a past regression from the bug corpus, and the retrieved context blends requirement and defect sources instead of one type crowding out the other.
Goal. Prove the generation stays grounded. This stage is rag testing turned on your own pipeline, the same rigor you would apply to any system under test.
Do. Build a small eval set of requests paired with the context that should answer them, then score it. Faithfulness is the fraction of claims in the generated output that are supported by the retrieved context; context recall measures whether retrieval actually pulled the chunks the answer needed. Frameworks like DeepEval and RAGAS compute these with an LLM judge and hand back a reason string naming the unsupported claims, which is what makes a low score actionable rather than mysterious.
from deepeval.metrics import FaithfulnessMetric from deepeval.test_case import LLMTestCase case = LLMTestCase( input="Generate boundary tests for the promo-code field", actual_output=generated_tests, # what the model wrote retrieval_context=retrieved_chunks, # the spec chunks it was handed ) metric = FaithfulnessMetric(threshold=0.9, model="gpt-4o-mini", include_reason=True) metric.measure(case) print(metric.score, metric.reason) # 0.0-1.0 plus the unsupported claims
| Metric | What it catches | Starting target |
|---|---|---|
| Faithfulness | Claims not supported by context (hallucinated fields, endpoints) | >= 0.9 |
| Context recall | Retrieval missed a needed chunk | >= 0.8 |
| Context precision | Top ranks cluttered with irrelevant chunks | >= 0.7 |
| Answer relevancy | Output drifts off the actual request | >= 0.8 |
Exit. Faithfulness clears your threshold (0.9 is a reasonable start), context recall covers the requirements in the eval set, and a drop below either gate breaks CI instead of shipping silently.
Two different things get called rag testing, and conflating them hides bugs. One is using RAG to generate tests for your product; the other is testing the RAG pipeline itself: retrieval quality, faithfulness, and index freshness. You need both. A stale index is a silent poison: retrieval returns a superseded requirement, the model faithfully grounds on it, faithfulness scores high, and the generated test asserts last quarter's behavior. Re-index on change and version the index so this stays traceable.
Goal. Make retrieval augmented generation qa a standing step in the SDLC, not a notebook someone runs by hand when they remember.
Do. Wire test-gen into the places tests are already authored and reviewed: a command in the test repo, a suggestion on the pull request, a scheduled CI job. Re-index on document change through a webhook or a nightly run so the knowledge base never lags the spec. Version the index alongside the code it describes, assign a human owner, and cap embedding and judge spend the way you cap any metered LLM call.
Exit. A merged requirement flows to an index refresh and shows up in generated tests within a defined SLA, and ownership, refresh cadence, and cost caps are written down where the next engineer will find them.
Make re-indexing a build step, not a chore. Hook it to the same merge that changes the spec, and the rag test knowledge base stays honest by default. An owner, a refresh cadence, and a spend cap are what turn a clever rag for qa prototype into infrastructure the team can actually trust.
Every RAG system is only as good as what it can retrieve. The model does not "know" your product; it re-reads a slice of your documents on every question. So the real engineering in rag for qa is not the prompt, it is the corpus: which QA artifacts you gather, how you cut them into chunks, and how you index those chunks for fast semantic lookup. This section builds the retrieval half of a retrieval augmented generation qa workflow: a searchable index over your own testing knowledge, wired so that even a smaller model can answer as if a senior tester had briefed it first.
Start by listing the documents a new SDET would need on day one, because those are exactly the documents an assistant needs. A useful rag test knowledge base pulls from five families of artifacts, each answering a different kind of question:
Be deliberate about what you exclude. Stale Confluence spaces, marketing decks, and duplicated exports dilute retrieval and quietly waste embedding calls. If you are practicing rather than working from a real corpus, export your own test cases to markdown, or capture the flows on app.thetestingacademy.com/playwright as documents so you have realistic QA text to index. Quality of the corpus caps the quality of everything downstream, so curate before you ingest.
Chunking is the single decision that most affects rag testing quality, and the common default of "cut every 1000 characters" is usually wrong for QA content. Your documents already carry structure: a test case is atomic, a bug report has a title and reproduction steps, a runbook is a sequence of named procedures. Split along those natural seams so each vector represents one coherent idea rather than a random window that starts mid-sentence and ends mid-assertion.
| Source | Chunk unit | Size / overlap | Metadata to attach |
|---|---|---|---|
| Requirements / stories | Per acceptance criterion | 600-900 chars, 100-150 overlap | epic, component, story id |
| Test cases | One case per chunk (atomic) | Whole case, no overlap | suite, priority, area |
| Bug reports | One bug (title + repro + expected/actual) | Whole issue | severity, component, status |
| Runbooks / SOPs | Per procedure (split on H2/H3) | Header-based, 150 overlap | system, owner, last-updated |
| API specs (OpenAPI) | Per endpoint / operation | One operation per chunk | path, method, version |
Overlap matters because a naive split can separate an assertion from its precondition, or cut the "expected result" away from the steps that produce it. Carrying the last 10 to 20 percent of one chunk into the next keeps that context stitched together, which is why every splitter exposes a chunk_overlap parameter. Size is the second lever: the smaller the chunk, the tighter the semantic match but the more likely an answer is scattered across several hits.
| Chunk size | Retrieval precision | Context per hit | Main risk |
|---|---|---|---|
| Small (100-200 tokens) | High | Low | Answer split across chunks |
| Medium (200-400 tokens) | Balanced | Good | Sweet spot for most QA prose |
| Large (500-1000 tokens) | Lower (diluted) | High | Signal buried, context wasted |
Practical rule: aim for medium chunks for narrative documents, and keep atomic artifacts (one test case, one bug) as a single chunk regardless of size. A test case cut in half retrieves as two useless fragments.
Once chunked, each piece of text becomes a vector through an embedding model, and those vectors go into a vector database built for nearest-neighbor search. A minimal langchain-style pipeline reads as loader, then splitter, then embeddings, then vector store:
from pathlib import Path from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings from langchain_chroma import Chroma # 1. Load: pull QA docs from folders, tagging each with its source type docs = [] sources = {"requirements": "req", "test-cases": "test", "bugs": "bug", "runbooks": "runbook"} for folder, kind in sources.items(): for path in Path(f"kb/{folder}").glob("**/*.md"): loaded = TextLoader(str(path), encoding="utf-8").load() for d in loaded: d.metadata.update({"doc_type": kind, "source": path.name}) docs.extend(loaded) # 2. Split: keep chunks around a section, with overlap so context is not cut splitter = RecursiveCharacterTextSplitter( chunk_size=800, # ~200 tokens; length is counted in characters chunk_overlap=120, # carry the last ~15% into the next chunk separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "], ) chunks = splitter.split_documents(docs) # 3. Embed + store: one vector per chunk, metadata preserved for filtering embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 1536 dims store = Chroma.from_documents( chunks, embedding=embeddings, collection_name="qa_kb", persist_directory="./qa_kb_index", ) print(f"indexed {len(chunks)} chunks from {len(docs)} documents")
Two choices carry weight. First, the embedding model. text-embedding-3-small returns 1536-dimensional vectors and is cheap enough to embed an entire regression suite and its bug history for a few cents. For an offline or privacy-sensitive setup, a local sentence-transformers model such as all-MiniLM-L6-v2 runs on CPU at 384 dims with no API calls. Whichever you choose, you must embed queries with the same model you used for the documents, or the vectors live in different spaces and retrieval returns noise.
Second, metadata. The doc_type, source, and component fields attached above are not decoration: they let you filter at query time, so a question about a checkout defect searches only bug reports for that component instead of the whole corpus. That metadata filtering is what turns a generic vector search into a targeted rag for qa retrieval. For storage, Chroma persists locally with rich metadata filters, FAISS is fast and file-based, and pgvector fits teams already running Postgres.
A rag test knowledge base is not build-once. Requirements churn every sprint, bugs get closed, runbooks are corrected after every incident. A stale index is a confident liar: it will happily cite a test case that was deleted two releases ago. Freshness is therefore a first-class part of any serious rag testing setup, not an afterthought.
The naive fix, re-embedding everything nightly, wastes money and time. Instead, hash content so only new or edited chunks are re-embedded, and delete the vectors whose source document changed. LangChain's indexing API does exactly this with a record manager that tracks what has already been indexed:
from langchain.indexes import SQLRecordManager, index record_manager = SQLRecordManager( "chroma/qa_kb", db_url="sqlite:///qa_kb_record.sql", ) record_manager.create_schema() # Re-run on every docs change. Unchanged chunks are skipped; # chunks from an edited source are re-embedded and the stale ones deleted. result = index( chunks, record_manager, store, cleanup="incremental", source_id_key="source", ) print(result) # {'num_added': 12, 'num_updated': 0, 'num_skipped': 480, 'num_deleted': 3}
Wire that call into CI or a nightly job. When a document merges, the pipeline re-indexes only the diff, and the num_deleted count confirms that outdated content actually left the index rather than lingering alongside its replacement. That closed loop, ingest, chunk, embed, and incrementally refresh, is the durable foundation the rest of a retrieval augmented generation qa system stands on.
The previous sections built the index. This one spends it. The query side of rag for qa is where a one-line request such as "generate test cases for the checkout coupon story" becomes a set of grounded, citable test artifacts. The pattern is retrieve-then-generate: turn the request into a query, pull the requirement chunks and the similar past cases that actually govern the behavior, then hand only those chunks to the model under a strict instruction to cite what it used. Done well, a retrieval augmented generation QA pipeline reads like a test analyst with the spec open on the second monitor. Done badly, it reads like a confident new hire who never opened the ticket.
Five steps sit between the request and the output. First, resolve the request into a real query: fetch the story by key so you are working from its summary, description, and acceptance criteria, not the user's paraphrase. Second, embed that query with the exact model you used at index time (a mismatch here silently wrecks recall). Third, retrieve from two sources at once: the authoritative requirement chunks, and a handful of similar past test cases that double as few-shot examples and regression memory. Fourth, assemble a prompt that carries each retrieved chunk with its identifier intact. Fifth, generate under a contract that forces every produced case to name the chunk that justifies it. Retrieval is the grounding; generation is only the phrasing. If a fact is not in the retrieved set, it has no business in the output.
"Generate test cases for the checkout coupon story" is a routing instruction, not a query. Expand it before it touches the vector store. Pull CHK-88, concatenate its acceptance criteria, and, when you have them, the titles of past coupon regressions linked to the epic. Then narrow with metadata filters so you retrieve from the right neighborhood of your rag test knowledge base: the checkout epic, the current release, requirement and test-case types only. Filtering is not optional polish. Without it, "coupon" pulls loyalty-points chunks, gift-card chunks, and a two-year-old promo spec that was superseded, and the model dutifully grounds cases in stale text. The two-source retrieval below is what separates useful rag for qa from a generic prompt with some context stapled on the end.
| Source | What it contributes | Typical filter |
|---|---|---|
| Requirement chunks | The authoritative behavior to assert against | type=requirement, epic, release |
| Acceptance criteria | Given / When / Then seeds for steps | type=ac |
| Similar past cases | Few-shot style plus regression memory | type=test_case, status=passing |
| Defect history | Known failure modes worth probing | type=defect, component=checkout |
| Test style guide | Naming and structure conventions | type=guide |
The whole loop is small. The discipline is in the details: the same embedder as indexing, explicit where filters, chunk ids preserved through formatting, and a low temperature so the model stays close to the retrieved text rather than drifting toward its training priors.
def generate_tests(story_key, k=6): story = jira.issue(story_key) # pull the source story, not the paraphrase query = f"{story.summary}\n{story.description}\n{story.acceptance_criteria}" qvec = embed(query) # SAME model + version as index time # two-source retrieval: governing spec + similar passing cases reqs = store.query(qvec, k=k, where={"type": "requirement", "epic": "checkout"}) cases = store.query(qvec, k=3, where={"type": "test_case", "status": "passing"}) context = "\n".join(f"[{c.id}] {c.text}" for c in reqs + cases) messages = build_prompt(story.key, context) return llm.chat(messages, temperature=0.2, response_format="json")
Note the two store.query calls. The first fetches the governing requirements; the second fetches a few passing cases so the model mirrors your house style instead of inventing its own format. Everything is stitched into one context string where each line is prefixed with its chunk id in brackets. That bracketed id is not decoration: it is the anchor the model echoes back, and the value you audit against.
Retrieval decides what the model may say; the prompt decides that it must show its work. The contract has three clauses that make or break rag testing quality: use only the numbered chunks, cite the chunk id behind every case, and when the context is silent, emit an explicit gap rather than a guess. That last clause converts the model's most dangerous instinct, filling silence with plausible fiction, into a coverage finding you can act on.
SYSTEM You are a senior test analyst. Use ONLY the numbered context chunks below. For every case, set "grounded_in" to the chunk id(s) that justify it. If the context does not define a behavior, do NOT invent one: emit a case with "grounded_in": "GAP" and describe the missing requirement. CONTEXT [REQ-412] A coupon reduces the order subtotal before tax. [REQ-415] Expired coupons are rejected with error CPN_EXPIRED. [REQ-418] The minimum cart value for SAVE10 is Rs 500. [TC-2207] Past case: apply SAVE10 to a Rs 600 cart, expect Rs 60 off. TASK Generate positive, negative, and edge cases for story CHK-88. Return JSON: [{id, type, title, steps, expected, grounded_in}]
Ask for structured JSON, not prose. A grounded_in field per case turns traceability from a promise into a machine-checkable field: a downstream script can reject any case whose citation is empty or points at a chunk id that was never in the retrieved set. That single validation catches most hallucinations before a human ever reads the output.
Given the four chunks above, the generator returns cases that each point back at their evidence. Here are two of them verbatim, one positive and one edge:
[
{
"id": "T1", "type": "positive",
"title": "SAVE10 applies to an eligible Rs 600 cart",
"expected": "Subtotal reduced by Rs 60 before tax",
"grounded_in": ["REQ-412", "REQ-418", "TC-2207"]
},
{
"id": "T4", "type": "edge",
"title": "Cart exactly at the Rs 500 minimum",
"expected": "Coupon accepted; inclusivity of boundary NOT stated in REQ-418",
"grounded_in": ["REQ-418"]
}
]
The full set spans the three classes you expect from a competent analyst, plus one row that earns its keep by admitting what the spec does not cover.
| Case | Type | Title | Grounded in |
|---|---|---|---|
| T1 | Positive | SAVE10 applies to an eligible Rs 600 cart | REQ-412, REQ-418, TC-2207 |
| T2 | Negative | Expired coupon rejected with CPN_EXPIRED | REQ-415 |
| T3 | Negative | Cart below Rs 500 minimum rejects SAVE10 | REQ-418 |
| T4 | Edge | Cart exactly at the Rs 500 boundary | REQ-418 (inclusivity not stated) |
| T5 | Edge | Two coupons stacked on one order | GAP - stacking undefined |
T1 through T3 are grounded and shippable. T4 is the interesting one: the model applied SAVE10 to a cart of exactly Rs 500 and flagged that REQ-418 never states whether the minimum is inclusive. That is a boundary the analyst should have specified and did not. T5 is a pure gap: nothing in the retrieved context defines stacking, so the model refused to assert a result and returned GAP. Both are findings you route back to the product owner, not tests you silently keep or quietly drop.
grounded_in: none as a defect in the generation run, not as a test to review later. Render the chunk id beside every generated case in your report and make it clickable back to the source requirement, so a reviewer can confirm the assertion in a single hop.Traceability is what makes a retrieval augmented generation QA workflow auditable instead of merely fast. When a generated case is wrong, the citation tells you at once whether the fault is retrieval (it grounded in the wrong or a stale chunk) or generation (it grounded in the right chunk but misread it). That split is the difference between a five-minute fix and an afternoon of re-reading model output. It also protects you at review time: a lead can accept twenty generated cases in the time it takes to skim twenty chunk ids, because each one carries its own evidence. Build the citation into the data model from day one. Retrofitting provenance after the fact, once cases have been copied into a test manager and edited by hand, is far harder than emitting it during generation.
Watch the similarity scores that come back with your chunks. When the top match for a request sits well below your usual threshold, the honest output is not a confident suite but a flag: the rag test knowledge base does not contain the behavior being asked about. That is genuinely useful. A retrieval augmented generation QA run that reports "no requirement chunk scores above 0.6 for gift-card plus coupon" has found a documentation hole before a customer did. Set a floor, and have the generator degrade to gap-reporting rather than fabricate below it. This is the same discipline that keeps rag testing honest at the input side: garbage retrieval must produce a visible gap, never a confident lie.
app.thetestingacademy.com/playwright, and have the model emit Playwright steps alongside the grounded assertions. A citable test that also runs is the payoff of rag for qa: grounded in the spec, and green in CI.A RAG for QA assistant has two independent places to fail, and if you cannot tell them apart you will burn days tuning the wrong half. Point one at your RAG test knowledge base and it will answer questions like "have we already automated expired-card checkout?", "what selector do we use for the search box on the Playwright practice page?", or "which past defects touched the payment webhook?". The retriever can pull the wrong chunk out of that knowledge base, or the generator can ignore a perfectly good chunk and answer from the base model's stale training memory instead. A polished demo hides both problems behind one confident paragraph. The only way to trust the assistant is to score it against a fixed set of questions on every change to the prompt, the model, or the index. That measurement discipline is exactly what separates real RAG testing from vibes.
Keep the two failure modes distinct in your head. A retrieval failure means the right passage never reached the model: ask "have we covered expired-card checkout?" and the search returns a chunk about password reset. A generation failure means retrieval worked but the model did not use it: the correct locator for the practice page at app.thetestingacademy.com/playwright was sitting in context, yet the model "improved" it into a selector that does not exist on the page. Different bugs, different fixes, and one aggregate accuracy number tells you nothing about which one you are looking at.
Query, retrieved context, and answer form a triangle. Each edge is one thing you can measure, and together they are often called the RAG triad. You want all three because a system can score well on one edge and fail badly on another, and only the combination localizes the fault.
Context relevance scores the query against the retrieved context: did the search fetch passages that actually bear on the question? A low score points at embeddings, chunking, or the retriever's top-k, not at the LLM. Faithfulness (also called groundedness) scores the answer against the retrieved context: is every claim in the answer supported by the chunks you fed in? A low score is hallucination even when retrieval was perfect. Answer relevance scores the answer against the query, ignoring correctness: asked for one locator, a model that returns three paragraphs on locator strategy is off target even if each sentence is true. That is the metric that catches evasive and padded output.
There is a fourth dimension worth tracking once the first three are stable: context recall, which asks whether retrieval fetched everything the answer needed, not just something relevant. If a requirement spans two chunks and your top-k only returned one, context relevance can look fine while the answer is quietly half-right. Recall is the metric that exposes a chunk size or a top-k that is too small.
| Metric | What it measures | What it catches |
|---|---|---|
| Context relevance | query vs retrieved chunks | retriever fetched noise, the wrong doc, or an off-topic passage |
| Context recall | retrieved chunks vs ground-truth chunks | a needed chunk was missing, so the answer is incomplete |
| Faithfulness (groundedness) | answer vs retrieved chunks | model hallucinated or added claims the context never supported |
| Answer relevance | answer vs query | correct-but-off-topic, evasive, or padded answers |
Every metric above needs reference questions to run against. Curate 30 to 100 records, each a (question, ideal answer, ideal source chunks) triple, drawn from real material: the questions new hires ask in Slack, the recurring "where is X tested" queries, the onboarding doc your team keeps re-explaining. Store this golden set in version control right next to the RAG test knowledge base so it evolves with the index instead of rotting beside it.
Not every metric needs a written ideal answer. Faithfulness and answer relevance are reference-free: they judge the live output against the query and the retrieved context, so they need only the question. Context recall and answer correctness are reference-based: they compare what was retrieved or answered against a ground truth you supplied, so those records need the expected chunks or the expected answer. Mark each record with which metrics apply, so a thin question does not silently fail a check it was never meant to satisfy.
DeepEval is the natural entry point for SDETs because it runs under pytest. Each metric is an LLM-as-judge that returns a 0 to 1 score plus a written reason, and assert_test fails the case when any metric falls below its threshold. A bad answer now breaks the build like any other failing test.
# test_rag_kb.py - runs under pytest, fails the build on a bad answer from deepeval import assert_test from deepeval.test_case import LLMTestCase from deepeval.metrics import ( ContextualRelevancyMetric, FaithfulnessMetric, AnswerRelevancyMetric, ) from rag_pipeline import retriever, generate_answer def test_locator_lookup_is_grounded(): query = "Which locator targets the search box on the Playwright practice page?" chunks = retriever.search(query, k=4) # the retriever leg answer = generate_answer(query, chunks) # the generator leg case = LLMTestCase( input=query, actual_output=answer, retrieval_context=[c.text for c in chunks], # the seam both metrics read ) assert_test(case, [ ContextualRelevancyMetric(threshold=0.7), # fetched the right chunk? FaithfulnessMetric(threshold=0.8), # stuck to that chunk? AnswerRelevancyMetric(threshold=0.7), # addressed the question? ])
The load-bearing line is retrieval_context: it is the exact list of chunks you passed to the model, which is what lets the framework judge context relevance and faithfulness independently of the answer text. Run this file in CI and a regression in your embeddings, or a prompt tweak that invites hallucination, shows up as a red test with the judge's reason string naming the chunk it faulted on.
Three tools cover most retrieval augmented generation QA work, and a later pillar in this series walks through each of them hands-on. Pick by where your team already lives rather than by feature count.
| Tool | Interface | Best for | Core metrics |
|---|---|---|---|
| DeepEval | pytest / Python | eval inside the existing test suite and as a CI gate | contextual relevancy, faithfulness, answer relevancy, and more |
| RAGAS | Python / dataset | offline batch scoring and reporting over a whole golden set | faithfulness, answer relevancy, context precision, context recall |
| promptfoo | YAML config / CLI | comparing prompts, models, or chunking side by side | model-graded context-relevance, context-faithfulness, context-recall |
DeepEval suits engineers who want RAG testing to feel like the pytest suite they already own. RAGAS gives you a research-grade metric set and shines at scoring a full dataset offline and producing a report. promptfoo is config-first, provider-agnostic, and unbeatable when you want to run the same golden questions through two models and diff the scores in one CI job.
# promptfooconfig.yaml - same questions, two models, one CI run providers: - openai:gpt-4o-mini - anthropic:messages:claude-3-5-haiku-20241022 tests: - vars: query: "What selector targets the login button on the practice page?" context: "{{retrieved_chunks}}" # the top-k passages your RAG step returned; the graders score against these assert: - type: context-relevance # retrieval quality threshold: 0.7 - type: context-faithfulness # groundedness of the answer threshold: 0.8
Whichever you choose, the diagnostic loop is identical. Read the lowest metric first, then look at the seam it points to and make one change at a time.
| Symptom | Metric that flags it | First move |
|---|---|---|
| Answer invents a selector not on the page | Faithfulness low | tell the prompt to answer only from context, drop temperature, allow "I don't know" |
| Answer is correct but ignores the question | Answer relevance low | fix task framing and any query rewriting step |
| Right topic never appears in context | Context relevance low | revisit embeddings, chunk size, top-k, metadata filters |
| Answer covers half the requirement | Context recall low | raise k, keep complete units together when chunking |
An eval you glance at once is theatre. Wire the golden run into CI so it fires on any change to the prompt, the model, the embedding config, the chunk size, or the contents of the knowledge base, and block merges on it the way you block on unit tests. A RAG for QA system without this gate is just a demo that happened to work the day you filmed it; the gate is what turns retrieval augmented generation QA from that demo into a maintained system.
A retrieval layer only earns its keep once it sits inside the work you already do. The goal is not a chat window a QA engineer visits when they happen to remember it. The goal is to wire retrieval augmented generation QA assistants into the exact points where test artifacts get created: when a ticket enters the sprint, when a job goes red in CI, and when a release branch is cut. In each of those moments there is already a corpus that holds the answer and a human who would otherwise reconstruct it by hand. That gap is the opening for RAG for QA. Three placements pay for themselves fast, and each one grounds a generation call in a specific slice of your rag test knowledge base so the model works from your acceptance criteria and your bug history instead of its training priors.
Test-plan and test-case generator, grounded in the requirements index. When a feature ticket lands, embed its title and description, retrieve the top matching requirement chunks (PRD sections, acceptance criteria, linked epics), and pass those chunks as context to a prompt that drafts a test plan plus a list of candidate cases. Because retrieval pins the model to the real acceptance criteria, the cases reference actual states and edge conditions instead of invented ones. When the generator emits an executable Playwright spec, point it at a stable target such as the practice pages at app.thetestingacademy.com/playwright, so a reviewer can run the draft against a page that will not move under them before deciding whether to accept it.
Failure-triage assistant, grounded in the bug history. When a job fails, the assistant takes the failure signature (test name, exception type, the top frames of the stack, and the assertion diff) and runs a similarity search over a corpus of closed bugs, prior CI failures, and known-flaky logs. It returns a verdict with a citation: known flake (link the quarantine ticket), known bug (link the open issue), or new (nothing above the similarity threshold, escalate). This is the highest-leverage RAG testing use, because on-call SDETs spend much of triage rediscovering failures someone already diagnosed. A metadata filter on the retrieval (same suite, same service) keeps the neighbors relevant instead of superficially similar.
"What changed" helper, grounded in release notes. Before a regression run, retrieve the release notes, changelog entries, and merged PR descriptions that touch the build under test, then summarize them into a risk-ranked list of areas to exercise first. Grounding on the actual diff descriptions is what separates this from a generic prompt: the model can say the checkout mutation changed and the profile page did not, so the smoke pass leads with payments. That keeps retrieval augmented generation QA output tied to the specific commit range under test rather than to a vague sense of the product.
Every artifact these assistants produce is a draft, and the pipeline must treat it as one. Retrieval lowers the hallucination rate; it does not zero it. The retriever can surface a stale chunk, rank a near-duplicate above the correct source, or return nothing useful and let the model fall back to its priors. So put a human gate on every generated artifact: a generated test case is a pull request a person reviews and merges, never an auto-committed file; a triage verdict is a suggestion the on-call engineer confirms before a ticket is linked or closed; a "what changed" summary is an input to the test lead's plan, not the plan itself. Make the gate cheap by shipping citations with the artifact. When each suggestion carries the exact source chunk and its link, a reviewer accepts or rejects in seconds instead of re-researching, and a wrong retrieval gets caught because the cited source visibly fails to support the claim.
| Use case | Corpus retrieved | Generated output | Who reviews |
|---|---|---|---|
| Test-plan / test-case generator | Requirements index: PRDs, acceptance criteria, Jira epics | Draft test plan + candidate cases (some as runnable specs) | Feature QA owner / test lead |
| Failure-triage assistant | Bug history + past CI failures + flaky-quarantine logs | Verdict (flake / known bug / new) with a cited ticket | On-call SDET |
| "What changed" helper | Release notes + changelog + merged PR descriptions | Risk-ranked list of areas to regress first | Release manager / QA lead |
| Coverage-gap finder | Requirements index + existing test titles | Requirements with no matching test | Test lead |
A rag test knowledge base decays the moment your docs move on without it. A requirement gets rewritten, a bug is closed with its real root cause, a release note is added, and until you re-embed those documents the retriever keeps answering from the old world with full confidence. Staleness is the dominant failure mode of RAG for QA in practice, and it is silent: nothing errors, the answers just drift wrong. The fix is to make re-indexing a CI job that fires on document change, not a chore someone runs by hand.
# .github/workflows/reindex-rag-kb.yml name: reindex-rag-kb on: push: paths: - "docs/requirements/**" - "docs/release-notes/**" - "docs/bugs/**" schedule: - cron: "0 2 * * *" # nightly catch for out-of-band edits workflow_dispatch: {} # manual full rebuild after a model change jobs: reindex: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 2 } # need HEAD~1 for the diff - run: node scripts/reindex.mjs --changed env: EMBEDDINGS_API_KEY: ${{ secrets.EMBEDDINGS_API_KEY }} VECTOR_DB_URL: ${{ secrets.VECTOR_DB_URL }}
Two properties matter. First, index incrementally. Diff the changed files against the previous commit, chunk only those, and upsert by a content-hash id so an unchanged chunk is a no-op and a reworded one replaces exactly one vector. A full re-embed of the whole corpus on every push burns your embedding budget and slows the pipeline for no benefit. Second, cover the out-of-band edits: docs that live outside the repo (Confluence specs, a Jira export) never trigger a push, so a nightly cron pulls those sources and re-embeds anything whose hash changed, and a closed-bug webhook can upsert a single triage document in near real time.
// scripts/reindex.mjs - incremental refresh of the RAG test knowledge base import { execSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { embed, store, chunkMarkdown } from "./rag-lib.mjs"; // only the docs that changed in this push, not the whole corpus const changed = execSync("git diff --name-status HEAD~1 HEAD -- docs/") .toString().trim().split("\n").filter(Boolean); for (const line of changed) { const [status, file] = line.split(/\s+/); await store.deleteWhere({ file }); // drop this file's old vectors first, so an edit or delete never leaves stale chunks searchable if (status === "D") continue; // deleted doc: nothing left to re-index for (const c of chunkMarkdown(readFileSync(file, "utf8"))) { const id = createHash("sha256").update(file + "::" + c.text).digest("hex"); await store.upsert({ id, vector: await embed(c.text), metadata: { file, heading: c.heading } }); } } console.log(`reindexed ${changed.length} file(s)`);
| Trigger | Fires when | Scope | Freshness target |
|---|---|---|---|
| Push to docs path | A requirement or spec merges | Changed files only | Minutes |
| Nightly cron | Out-of-band edits (Confluence, Jira) | Full corpus, diffed by hash | Overnight |
| Bug-closed webhook | Triage corpus grows | Single doc upsert | Near real time |
| Manual dispatch | Embedding model or chunker changes | Full rebuild | On demand |
Treat the index as a build artifact with the same rigor as compiled code: versioned, rebuilt on change, and observable. Wired this way, retrieval augmented generation QA stops being a demo you show once and becomes a quiet part of the pipeline that drafts the plan, names the flake, and flags the risk, while a human still signs every artifact that leaves the gate.
A retrieval pipeline that answers confidently and wrongly is more dangerous than no pipeline at all, because a QA engineer will act on the answer: file the bug, write the assertion, close the ticket. This section covers the three guardrails most teams skip when they first wire up rag for qa, then regret in production: keeping the model inside its evidence, keeping the bill proportional to the value, and keeping sensitive requirements and defect data from leaking through the embedding layer. Each one is an engineering concern with tests, budgets, and access rules attached, not a nice-to-have you bolt on after launch.
A retrieval augmented generation QA assistant hallucinates when it asserts more than the retrieved chunks support. The fix is not a smarter model, it is a tighter contract between retrieval and generation. Five mechanisms cost almost nothing to add and remove most of the risk:
You do not take these on faith, you measure them. This is where rag testing meets QA's home turf: build a small labeled set of questions with known-correct source chunks and score the pipeline on faithfulness (does every sentence follow from the retrieved context), context precision (are the retrieved chunks actually relevant), and context recall (did retrieval find the chunk that holds the answer). Frameworks like RAGAS or DeepEval compute these automatically. Run them in CI on every prompt, chunker, or embedding-model change, the same way you gate application code on a test suite.
# refuse-on-empty-retrieval gate: the cheapest hallucination control you can ship SIMILARITY_FLOOR = 0.78 # tune per corpus against a labeled eval set def answer_from_kb(question, top_k=5): hits = store.query(embed(question), top_k=top_k, filter=acl_for(user)) # score here is cosine similarity in [0,1], higher is closer; if your store returns distance, gate on 1 - distance if not hits or hits[0].score < SIMILARITY_FLOOR: return {"answer": "NOT_IN_KB", "sources": []} context = "\n\n".join(f"[{h.id}] {h.text}" for h in hits) reply = llm.chat(GROUNDED_PROMPT.format(context=context, question=question), temperature=0) # deterministic, no creative drift return {"answer": reply.text, "sources": [h.id for h in hits]}
The gate above enforces the floor and the per-user ACL; the prompt enforces the grounding contract itself:
GROUNDED_PROMPT (the system contract)
You answer QA questions using ONLY the facts in CONTEXT.
- Do not use prior knowledge. Do not guess.
- Put the [chunk_id] after every sentence you assert.
- If CONTEXT does not contain the answer, reply exactly: NOT_IN_KB.
CONTEXT:
{context}
QUESTION: {question}
NOT_IN_KB a first-class, testable output. A rag for qa system that admits "I do not have that" earns trust; one that guesses gets switched off after the first bad bug report. Alert on the not-found rate too: a sudden spike usually means your index broke, not that users changed the questions.Three cost centers show up on the invoice, and they scale differently. Embedding is a mostly one-time cost you pay when you index a document and again when it changes. Storage is continuous and grows with corpus size times vector dimension. Generation is the recurring cost that dominates once you have traffic, because every single query stuffs top_k chunks of context into the prompt. The table below maps each center to what drives it and the lever that moves it.
| Cost center | When it hits | Driver | Lever |
|---|---|---|---|
| Embedding | Index time + on change | Tokens embedded | Batch; re-embed only changed chunks |
| Storage | Continuous | Vectors x dimensions | Lower dimension, quantize, prune stale docs |
| Query embed | Per query | Question tokens | Negligible; ignore |
| Generation | Per query | top_k x chunk size context tokens | Smaller chunks, lower top_k, cheaper model |
| Re-index | On model/chunk change | Whole corpus | Pin the embedding model, version the index |
The two knobs that most move both cost and quality are chunk size and top-k, and they trade off against each other. Larger chunks carry more context per hit but drag in more irrelevant text, which raises token cost and can lower faithfulness by feeding the model distractors. Smaller chunks match more sharply but risk splitting a single fact across a boundary, so you compensate with a higher top-k, which pushes token cost back up. There is no universal setting: for a test-case and requirements corpus, chunks of roughly 300 to 800 tokens with a top-k of 3 to 8 is a sane starting window that you then tune against the eval set from the rag testing step.
| Knob | Turn it up | Turn it down |
|---|---|---|
| Chunk size | Fewer chunks, more context per hit, more noise and cost per hit | Sharper matches, may split a fact, needs higher top_k |
| Top-k | Better recall, more distractors, more tokens, latency, cost | Cheaper and faster, may miss the one relevant chunk |
Caching is what takes a retrieval augmented generation QA deployment from expensive toy to sustainable tool. Cache at three layers: hash each chunk and skip re-embedding anything unchanged (most of your corpus is static between releases); keep a semantic cache so a question close to one already answered returns the stored answer without an LLM call; and use provider prompt caching to discount the static system-prompt prefix you send on every request. For a RAG test knowledge base that many engineers query with overlapping questions, the semantic cache alone often removes a large share of generation calls.
Your requirements, test plans, and bug reports are among the most sensitive artifacts your company owns: they describe exactly how the product breaks. The moment you index them you create two new copies of that risk, the embeddings and the vector store, and both live wherever you put them. If you call a hosted embedding API, the raw text of every chunk leaves your network at index time and the raw question leaves it at query time, so read the vendor's retention and training terms and prefer an enterprise tier that contractually does neither. If that is unacceptable, self-host the embedding model and the store.
pgvector, Qdrant, Weaviate) keeps every vector inside your VPC. Vectors are not anonymized data: embedding-inversion research shows source text can be partially reconstructed from them, so treat the store as sensitive as the documents themselves.The failure that bites hardest is access leakage. RAG will happily retrieve a chunk the current user is not allowed to see and then read it back to them in the answer, so the permission check has to happen at retrieval time, as a metadata filter on the vector query (by tenant, team, or document id), not merely in the UI. This is the same principle that governs the rest of a serious platform: the server decides access, the interface only reflects it. Finally, treat every retrieved chunk as untrusted input. A poisoned document in the rag test knowledge base can carry prompt-injection text, so retrieved content is data, never instructions to obey.
The whole section collapses into one reference table. Ship none of these as TODOs: each is a cheap control that prevents an expensive, and sometimes public, failure.
| Risk | Failure mode | Mitigation |
|---|---|---|
| Hallucination | Model asserts beyond the evidence | Ground-only prompt, cite chunk ids, temperature 0, faithfulness score in CI |
| Answered on empty | Model improvises when retrieval misses | Similarity floor plus a NOT_IN_KB refusal token |
| Cross-tenant leak | User receives another tenant's chunk | ACL metadata filter on the vector query, tested per tenant |
| PII or secrets in corpus | Credentials or customer data indexed | Redact and scrub at ingestion; block known secret patterns |
| Vendor trains on your data | Requirements feed a third-party model | Enterprise no-train, zero-retention tier, or self-hosted embeddings |
| Embedding inversion | Source text reconstructed from vectors | Treat the store as sensitive as the source; encrypt and access-control it |
| Prompt injection via a chunk | Poisoned doc hijacks the model | Retrieved text is data not instructions; keep the human gate |
| Runaway cost | Traffic or retry loops blow the budget | Per-user rate limit, platform daily quota, and a semantic cache |
By now you have every moving part of a working retrieval augmented generation QA system: a curated corpus, an embedding and chunking strategy, a retriever, a grounded generation step, and an evaluation harness that fails the build when quality drops. This closing section pulls the roadmap back into one view, then answers the questions engineers ask most often once they start wiring rag for qa into real workflows instead of demos.
If you are starting from zero, follow the same order the sections above used. Each step produces an artifact the next one depends on, so skipping ahead usually means backtracking.
Not always. If your rag test knowledge base is a few hundred to a few thousand chunks, an in-memory index (a FAISS flat index, a plain numpy cosine-similarity loop, or Chroma running locally) is plenty and far easier to debug. Reach for a dedicated store when you cross tens of thousands of chunks, need metadata filtering at scale, want persistence with concurrent access, or share one index across a team and CI. Options range from pgvector (vector search bolted onto Postgres you already run) to Qdrant, Weaviate, and Pinecone. Start with the simplest thing that returns the correct chunks, and swap in a real vector DB when corpus size or latency actually forces it, not on day one.
Big enough to hold one complete idea, small enough that a match is precise. For most QA content that lands around 200 to 500 tokens with 10 to 20 percent overlap, but raw token count is the wrong primary lever. Chunk on structure first: one Gherkin scenario, one API endpoint, one bug ticket, or one test-run failure per chunk. A chunk that splits a scenario's Given from its Then retrieves half an answer and scores poorly no matter how you tune k.
| Content type | Chunk on | Rough size |
|---|---|---|
| Test cases / Gherkin | One scenario per chunk | 100-300 tokens |
| API / endpoint docs | One endpoint per chunk | 200-500 tokens |
| Bug reports / tickets | Title + repro + resolution | 150-400 tokens |
| Specs / requirements | Split by heading, 15% overlap | 300-500 tokens |
| Test-run logs | One failure + stack per chunk | variable |
Oversized chunks dilute the embedding and bury the one relevant sentence in noise; undersized chunks strip the context the model needs to reason. Test both directions against your golden set and let contextual recall pick the winner.
Retrieval is grounding, not a guarantee. The model can still invent when the retrieved context is thin or off-target, so treat hallucination control as a stack, in order. First, fix retrieval: if the right chunk never reaches the prompt, no instruction saves you, so this is where most of the payoff is. Second, constrain the prompt: tell the model to answer only from the provided context, to quote the source chunk, and to reply "not covered by the knowledge base" when the context lacks the answer. Third, keep temperature low for factual QA tasks. Fourth, measure faithfulness on every release so a regression trips a gate instead of reaching a stakeholder.
Yes, and this is where rag for qa earns its keep. Retrieve your own page objects, existing selectors, fixture setup, and naming conventions, then ask the model to write a new test in that exact shape. Because the context carries your real patterns, the output reuses your locators and helpers instead of generic guesses scraped from training data. Point it at a concrete target such as the practice pages at app.thetestingacademy.com/playwright and it can draft a Playwright spec that matches your repo's structure and style. The non-negotiable rule: generated code is a draft until it runs green. Wire the model's output into your test runner, execute it, and feed the failures back as context. RAG closes the gap between "plausible" and "matches our codebase"; the runner closes the gap between "matches" and "actually correct."
Never on vibes. Split evaluation into retrieval and generation, because a good answer built on bad retrieval is luck you cannot repeat. Build a golden dataset of representative questions, each with a known-good answer and the chunks that should have been retrieved, then score with DeepEval, RAGAS, or PromptFoo.
| Metric | What it catches | Layer |
|---|---|---|
| Contextual recall | Did retrieval fetch the chunk holding the answer | Retrieval |
| Contextual precision | Are the top-ranked chunks the relevant ones | Retrieval |
| Faithfulness | Does the answer stay inside the retrieved chunks | Generation |
| Answer relevancy | Does the answer address the actual question | Generation |
| Correctness | Does the answer match the expected output | End to end |
A minimal DeepEval faithfulness check reads like a unit test:
from deepeval import evaluate from deepeval.test_case import LLMTestCase from deepeval.metrics import FaithfulnessMetric case = LLMTestCase( input="What is the expected result when checkout runs on an empty cart?", actual_output=rag_answer, # what your pipeline returned retrieval_context=retrieved_chunks, # the chunks you fed the model ) evaluate(test_cases=[case], metrics=[FaithfulnessMetric(threshold=0.8)])
Run this suite in CI on any change to chunking, prompts, the embedding model, or the generator, and set thresholds that block the merge. rag testing is only real when a regression fails the build instead of surfacing in production.
They solve different problems. Retrieval augmented generation qa handles knowledge that changes: your growing suite, this week's bugs, the latest spec revision. Fine-tuning handles behavior that should stay constant: output format, tone, a house style for test names and assertions.
| Dimension | RAG | Fine-tuning |
|---|---|---|
| Best for | Fresh, changing knowledge | Fixed behavior and format |
| Update cost | Re-index, minutes | Retrain, hours plus data prep |
| Citations | Yes, returns source chunks | No, knowledge is baked in |
| Data need | A test knowledge base | Hundreds of labeled examples |
| Main failure | Bad retrieval to bad answer | Stale or overfit weights |
For most QA teams RAG wins first, because your knowledge base changes daily and re-indexing takes minutes while retraining takes hours plus curated data. The strongest setups combine them: fine-tune a small model for consistent structure, then use retrieval to feed it current facts. Start with RAG, and add fine-tuning only when format drift, latency, or token cost proves the case.
From here, two directions build on everything above. To let these pipelines act rather than just answer, continue with our AI Agents for QA guide, which wires retrieval into tool-using agents that run tests and file findings on their own. To harden the evaluation layer you sketched in question five, work through the dedicated DeepEval and PromptFoo pages, where the metrics in this section become a full regression suite guarding your rag test knowledge base on every commit.
Series siblings: MCP for QA and AI Agents for QA. See also LLM vs AI Agent and the free QA skill suite.
Explore the free QA skill suite →