VALID: Answer from the docs for the generation you run
and refuse rather than borrow from another
Disclaimer. This is a portfolio design document. Google has not engaged, sponsored or endorsed this work. The sample content is adapted from the Cloud TPU documentation, which Google publishes under the Creative Commons Attribution 4.0 licence1. The adapted versions are modified from the originals. The question set, runs, similarity scores and costs in this document are modelled, not measured. Stakeholders are job roles, not real people. The two readers in §3 are fictional.
version-sensitive questions
right answers to one question
which content is in scope
Ask a documentation assistant how many chips are in one Cloud TPU host or VM and the right answer depends on the generation: 4 for v5p, 8 for v6e, 4 for TPU7x234. Ask which software version to pass when creating a TPU7x VM and the right answer is that the Cloud TPU API doesn't support TPU7x at all5. Retrieval ranks text by meaning, not by generation, so it returns all of these side by side. In a published benchmark, standard RAG answered only 58% of version-sensitive questions correctly6.
VALID (Version-Aware Lookup In DITA) is an agent that answers only from content profiled for the reader's generation. The scope of every chunk comes from what writers already maintain: profiling attributes, map cascading and a subject scheme810. Firestore filters on scope before the vector search runs12. Gemini writes the answer, and plain Python checks that every citation is in scope and every value appears in its source. If no in-scope content exists, VALID says so and opens a gap issue. It never widens the search to another generation.
VALID is rebuilt from VaultRAG, a deployed RAG service with guardrails that refuse when unsure25. It keeps that service's retrieval stack and refusal design, and replaces plain-text chunking with DITA-aware chunking that carries scope.
§1 Client Brief
The client is Google's data center products and services documentation. The sample content is the Cloud TPU documentation for three hardware generations: TPU v5p, TPU v6e (Trillium) and TPU7x (Ironwood)234, plus the TPU software versions page that covers all of them5. The MANIFEST project converted this content to DITA, with generation-specific content marked by profiling attributes and built per generation with DITAVAL27.
It is a good test for two reasons. First, the generations share most of their concepts and procedures, so retrieval can't tell them apart by meaning alone. Second, the differences are small, exact and easy to get wrong: a chip count, a version string, a supported API. An answer that is almost right is a wrong answer here.
| Term | Meaning in this document |
|---|---|
| Generation | One TPU hardware version: v5p, v6e or TPU7x. VALID's scope dimension. For networking content, the same role is played by a platform or a software release. |
| Profiling attribute | A DITA attribute that marks content as applying to a condition. @platform, @product, @audience and @otherprops are used for filtering9. Here, platform="tpu7x". |
| DITAVAL | A file that tells a DITA processor which profiled content to include or exclude for one build8. |
| Cascading | Profiling attributes on a map entry apply to the topics and entries under it10. A whole TPU7x branch is scoped once, in the map. |
| Subject scheme | A DITA map that defines the allowed values of an attribute11. It is VALID's list of generations and their other names, such as Ironwood for TPU7x. |
| Chunk | One retrievable unit: a DITA section, a table, or a group of task steps, with its text, its vector and its scope. |
| Scope | The set of generations a chunk applies to, resolved from the map and the element. Stored as one true or false field per generation. |
| Pre-filter | A filter that runs before the nearest-neighbour search, so only in-scope chunks can be returned12. |
§2 Problem Statement
A documentation assistant is only worth its cost if readers trust its answers enough not to ask a person. Hardware generations make that hard. Most of the text is shared, the differences are small, and a retriever can't see which generation a sentence belongs to.
2.1 One question, three right answers
Figure 2 shows three questions a reader might ask about a Cloud TPU VM. Each has a different correct answer for each generation, as Google publishes them today.
v2-alpha-tpuv5 for v5p and v2-alpha-tpuv6e for v6e. On the same page, it says the Cloud TPU API is not supported on TPU7x and later, and points to Compute Engine or GKE instead5. A reader who asks "which version do I use for my Ironwood VM?" is closest in meaning to the version table. A retriever returns the table. A model then does what it was asked and picks a version string.Figure 3 shows why. These are the five chunks nearest in meaning to that question, in an index with no scope. The two that are correct for TPU7x rank fourth and fifth.
2.2 How often RAG gets it wrong, and what it costs
The VersionRAG study measured this failure on versioned technical documentation: Apache Spark and Bootstrap release notes, and Node.js reference pages, 34 documents and 100 curated questions6. Standard RAG answered 58% correctly. On questions about what changed between versions, it answered 25% correctly (Figure 4).
The cost of an almost-right answer is not the answer. It is what the reader does next. In the 2025 Stack Overflow Developer Survey, the most common frustration with AI tools was answers that are almost right, but not quite, at 66%. Three in four developers said they still ask a person when they don't trust an AI answer7. Each wrong-generation answer turns a self-service answer back into a question for an engineer.
2.3 Why the current options fail
There are six common ways to make an assistant respect versions. Each one either leaves wrong-generation text in front of the model, or rebuilds structure the DITA source already has.
| Option | What it does | What it misses | Result |
|---|---|---|---|
| Standard RAG on published pages | Indexes HTML. Returns the top-k chunks by similarity. | No scope field exists. Wrong generations rank first (Figure 3). | 58% accurate |
| Tell the model the generation | Adds "the reader uses TPU7x" to the prompt. | The context still holds v6e and v5p chunks. The model must choose, and can choose wrong. | Unchecked |
| Boost or rerank by version | Raises in-scope chunks in the ranking. | Wrong-generation chunks drop lower but stay in the context. | Softer, not safe |
| Filter after the search | Runs kNN on everything, then drops out-of-scope results. | Can leave one result or none. The easy fix is to fall back to unfiltered results. | Fails open |
| One index per generation | Builds a separate index for each build output. | Copies shared content into every index. Questions that compare generations need several searches. Unscoped values still slip in. | Costly to run |
| Model-built version graph (VersionRAG) | Extracts versions and changes from documents with a model, then routes queries6. | Builds with a model what DITA authors already wrote. Every edge is a model guess. | 90%, at a build cost |
| VALID: scope from the source, filter first | Reads scope from profiling, cascading and the subject scheme. Pre-filters on scope. Checks every citation. Refuses when nothing in scope answers. | Depends on correct profiling. Content that states a value without a scope must be marked, so VALID reports it. | Right or refused |
§3 User Profiles
Five roles at Google decide whether VALID is useful. Two readers at Google use its answers.
Readers
§4 User Stories
Each story has acceptance criteria that a tool or check in §5 tests.
log_question writes one row per question with the resolved scope, cited chunk IDs, check results, tokens and cost; the trap-set report in §7 reads only that table.retrieve_in_scope sends an equality pre-filter on scope with every query; check_answer fails any answer that cites a chunk outside the scope.resolve_profiles fails the index build on any profiling value not in the subject scheme; audit_scope opens one issue per unscoped chunk that contains a generation-specific value.check_answer matches every command, flag and version string, character for character, against the cited chunk text.scope field; every link resolves to a chunk whose scope includes it.resolve_scope maps "Ironwood" to tpu7x from the subject scheme; no v6e or v5p chunk reaches the model.check_answer, not by the model. embed_chunks serves every story and isn't tested by any of them.§5 Proposed Design
VALID v0.1 is an agent built with Agent Development Kit (ADK), Google's open-source agent framework19. It uses Gemini 3.8 Flash (gemini-3.8-flash)15 with structured output, so the answer must match a JSON Schema18, and Gemini Embedding 2 (gemini-embedding-2) for vectors14. Plain Python tools, called through function calling20, do everything else. They decide the scope, run the filtered search and check the answer. The checks decide pass or fail, and the agent can't override them.
VALID has two parts. The index runs on every docs merge: it reads the DITA source, resolves the scope of each chunk and writes it to Firestore. The answer runs on every question: it decides the scope, searches only inside it and checks the result.
5.1 How it works
Figure 7 is the same context as Figure 1, with VALID added. The assistant now reads the DITA source, where the scope still exists, instead of the published pages.
5.2 Scope comes from the source
VALID doesn't ask a model which generation a chunk belongs to. Writers already answered that question when they profiled the content for DITAVAL builds. resolve_profiles reads three things, in this order:
| DITA feature | What VALID reads | Index result |
|---|---|---|
| Map cascading | platform="tpu7x" on a <topicref> applies to every topic under it10 | The whole TPU7x branch is in scope for TPU7x only |
| Element profiling | platform on a step, row or section inside a shared topic | That chunk's scope narrows to the listed values |
| No attribute | An omitted attribute can't evaluate to "exclude", so the content is in every build8 | In scope for every generation, and checked by audit_scope |
| Subject scheme | The allowed @platform values and their other names11 | One field per value; any other value fails the build |
The third row is the risk. DITA treats unprofiled content as shared, which is right for a build and dangerous for an answer. A shared step that says --version=v2-alpha-tpuv6e appears in every build, and in Figure 3 it ranked third for a TPU7x question. audit_scope finds these: it looks for any value that belongs to one generation (a version string, a machine type, a key value) inside a chunk that is in scope for all of them. Each finding becomes an issue for the information architect. The writer fixes the profiling, and the next build reads the fix.
5.3 Filter first, then search
Firestore vector search can combine a nearest-neighbour query with other filters, so the filter runs before the search. The filtered query needs a composite vector index12. VALID uses only equality filters on the scope fields, the pattern the Firestore documentation shows (where("in_tpu7x", "==", True)), with one composite index per generation. It doesn't depend on range or array filters inside a vector query, which the documentation doesn't describe.
Exact terms matter in this content. Version strings, machine types such as tpu7x-standard-4t, and flags are rare tokens that dense vectors handle poorly. VALID also runs a BM25 search with SQLite FTS524 over the same scope, and merges the two lists with reciprocal rank fusion30. This is the hybrid retrieval pattern from QueryForge26, with the scope filter applied to both lists.
5.4 Agent tools
VALID has nine tools. Three run at index time and six at question time. One calls the model.
1. Resolve profiles resolve_profiles
Parses the maps and topics with lxml. Applies map cascading, then element profiling, to give each chunk its scope. Chunks follow DITA structure: one section, one table or one group of steps. Fails the build on any value not in the subject scheme.
2. Audit the scope audit_scope check
Finds shared chunks that contain a value owned by one generation: a version string, a machine type, or the resolved value of a generation key. Opens one issue per finding for the information architect.
4. Decide the scope resolve_scope
Matches the question against the subject scheme: generation names, other names such as Trillium and Ironwood, accelerator types and machine types. No model call. Returns one generation, several, or none.
5. Search inside the scope retrieve_in_scope
Embeds the question, runs Firestore find_nearest with the scope pre-filter, runs BM25 with the same scope, and fuses the lists. For two generations, it runs once per generation and labels each list.
6. Write the answer generate_answer model
Calls Gemini 3.8 Flash with structured output. The schema requires a scope field and a citation on every step, and the citation must come from an enum of the retrieved chunk IDs, so Gemini can't cite anything it wasn't given. AlignR uses the same enum method for keys and sources29.
7. Check the answer check_answer check
Three plain Python checks. Every citation is in scope. Every number, unit, flag, command and version string appears in its cited chunk. No value owned by an out-of-scope generation appears anywhere in the answer. The grounding check comes from KnowledgeFlow28.
8. Refuse or ask refuse_or_ask
No scope and a scope-dependent question: asks the reader to choose a generation. Scope but nothing in scope, or checks still failing: refuses, says what is missing, and opens a gap issue for the writer.
9. Log the question log_question
Writes one row per question to BigQuery: question, scope, chunk IDs and scopes, check results, outcome, tokens and cost.
5.5 Agent loop
The model decides how to phrase the answer. The checks decide whether it can be sent. A failed step goes back to Gemini with its reason, up to two times, with the same in-scope chunks. VALID never widens the search to fix a failure.
5.6 One question, end to end
Figure 11 follows Priya's question from §3. It is the run in the second simulator scenario (§6).
--version flag from its own training, not from the context. The filter can't stop that. The grounding check can. Two model calls, zero chunks from another generation.5.7 Sample output
This is the answer VALID sends for Priya's question, as JSON from the answer schema. The ask page renders it with the scope at the top.
{
"scope": ["tpu7x"], "matched": "Ironwood", // from the subject scheme, not the model
"answer": "The Cloud TPU API doesn't support TPU7x, so the software versions page lists no version for it.",
"steps": [
{ "text": "Create TPU7x capacity with Compute Engine or Google Kubernetes Engine (GKE).",
"cite": "runtimes#tpu7x-note" },
{ "text": "Use the tpu7x-standard-4t machine type. Each TPU7x VM has 4 chips.",
"cite": "tpu7x#vm" },
{ "text": "Use JAX or PyTorch. TensorFlow isn't supported on TPU7x.",
"cite": "tpu7x#frameworks" }
],
"not_used": "v6e and v5p version strings: out of scope for this question",
"checks": { "citations_in_scope": "3/3", "values_grounded": "4/4", "out_of_scope_values": 0 },
"run": { "model": "gemini-3.8-flash", "calls": 2, "tokens_in": 6410, "tokens_out": 690 }
}
resolve_scope from the subject scheme. The model fills the field but its enum allows only the resolved value.cite comes from an enum of retrieved chunk IDs, so the model can't cite a chunk it didn't see.5.8 What VALID keeps from VaultRAG
VaultRAG is a deployed, voice-first RAG service for manufacturing procedures. It runs on Cloud Run and Firestore Vector Search, refuses at five guardrail stages, and passed 17 of 20 queries in its own guardrail evaluation, with the three misses analysed25. VALID keeps its spine and replaces what was specific to plain text.
| VaultRAG part | In VALID | Why |
|---|---|---|
| Firestore Vector Search, cosine, normalised vectors | Kept plus a scope pre-filter | The store already supports filters before kNN12 |
| Scope guard and confidence threshold (similarity floors 0.62 and 0.70) | Kept recalibrated on the TPU question set | Out-of-domain questions still need a refusal |
| Citation enforcement with one strict retry | Changed to citation enums plus scope and grounding checks | A regex for "Section 18.4" can't prove a citation is in scope |
| Query normalisation with a model | Changed to subject scheme matching, no model | The scope must be deterministic and owned by the IA |
| Heading-pattern chunker for plain text | Changed to DITA-aware chunks with scope | DITA structure gives clean chunk edges and the scope |
| Wider retrieval when a hazard keyword fires | Dropped | Widening is the move VALID forbids |
| Voice input, air-gapped production path | Dropped | A second job. The Cloud TPU docs are public. |
5.9 Where it runs
| Part | Technology | Free tier or licence |
|---|---|---|
| Agent framework | Agent Development Kit19 | Apache 2.0 |
| Answer model | Gemini 3.8 Flash (gemini-3.8-flash), structured output | Free of charge on the Gemini API free tier16 |
| Embeddings | Gemini Embedding 2 (gemini-embedding-2), 1,536 dimensions14 | Gemini API; priced on the same page16 |
| Vector store and scope filter | Firestore vector search with composite vector indexes12 | 50,000 reads and 20,000 writes a day; one read per 100 kNN index entries13 |
| Exact-term search | SQLite FTS5 with BM2524 | Public domain |
| Profiling and audit | Python and lxml | Open source |
| Index trigger | GitHub Actions | Free for public repositories23 |
| Agent runtime | Cloud Run service, us-central1 | 180,000 vCPU-seconds and 2 million requests a month21 |
| Question log | BigQuery | 10 GiB storage and 1 TiB of queries a month22 |
Data handling. The sample content is public, so the Gemini API free tier is acceptable for the POC. Free-tier content is used to improve Google products, and paid-tier content is not, so private documentation needs the paid tier16. The free tier is offered in the EEA, Switzerland and the UK17. The POC runs in us-central1, because the Cloud Run free tier is defined on Tier 1 pricing21.
Known limits. VALID is only as right as the profiling. It finds unscoped values that it can recognise, such as version strings and key values, but not a sentence that is wrong for one generation in plain words. Questions about what changed between generations are out of scope for the MVP; VALID answers each generation separately and doesn't compute the difference.
§6 Answer Simulator
Three questions. The first passes cleanly. In the second, the filter keeps out the wrong generations and a check stops a flag the model invented. In the third, the question has no generation, so VALID asks.
// choose a sample question and run it
§7 Success Metrics
The MVP is measured on a trap set: questions whose correct answer differs between v5p, v6e and TPU7x. The set has 60 scoped questions: four facts (the three in Figure 2, plus supported frameworks) for each of three generations, in five wordings each. It also has 10 questions with no generation, and 10 that name two generations. Each question has a known answer and known source chunks. The same set runs against an unfiltered baseline built from the same chunks.
| Metric | How it is measured | MVP target |
|---|---|---|
| Wrong-generation answers that reach a reader | Trap-set answers whose value or procedure belongs to another generation | 0 |
| Citations in scope | check_answer: cited chunks whose scope includes the resolved scope | 100% |
| Values grounded in the cited chunk | check_answer: numbers, units, flags and commands found in their source | 100% |
| Scoped questions answered correctly | Correct answers out of 60, against the unfiltered baseline on the same set | 54 or more (90%); the rest refused, none wrong |
| Unscoped questions handled | Questions with no generation that get a choice, not an answer | 10 of 10 |
| Unscoped generation values in the index | audit_scope findings left open after IA review | 0 |
| Time to answer | Question received to answer sent, median | Under 5 seconds |
| Questions with cost logged | Rows in the question log with tokens and cost | 100% |
Scope
- Cascaded from the map: 377 chunks. Narrowed by element profiling: 21. Shared, no attribute: 14.
- Profiling values not in the subject scheme: 0.
Finding
create-tpu-vm.dita, line 41: shared step containsv2-alpha-tpuv6e, owned byv6e. Issue #87 opened for the information architect.
Indexes
Firestore: 18 documents written. Composite vector indexes: in_v5p, in_v6e, in_tpu7x. BM25: 18 rows replaced. Run time 41 s.
Sources
Checked on 20 October 2026.
- Google Developers, "Site Policies." developers.google.com/site-policies
- Google Cloud, "TPU7x (Ironwood)," last updated 19 October 2026. docs.cloud.google.com/tpu/docs/tpu7x
- Google Cloud, "TPU v6e," last updated 13 October 2026. docs.cloud.google.com/tpu/docs/v6e
- Google Cloud, "TPU v5p," last updated 13 October 2026. docs.cloud.google.com/tpu/docs/v5p
- Google Cloud, "TPU software versions," last updated 9 October 2026. docs.cloud.google.com/tpu/docs/runtimes
- D. Huwiler, K. Stockinger and J. Fürst, "VersionRAG: Version-Aware Retrieval-Augmented Generation for Evolving Documents," arXiv:2510.08109. arxiv.org/html/2510.08109v1
- Stack Overflow, "2025 Developer Survey: AI." survey.stackoverflow.co/2025/ai
- DITA 1.3 specification, "Filtering based on metadata attributes." dita-lang.org/1.3/dita/archspec/base/filtering
- DITA 1.3 specification, "Conditional processing attributes." dita-lang.org/1.3/dita/archspec/base/conditional-processing-attributes
- DITA 1.3 specification, "Cascading of metadata attributes in a DITA map." dita-lang.org/1.3/dita/archspec/base/cascading-in-a-ditamap
- DITA 1.3 specification, "Defining controlled values for attributes." dita-lang.org/1.3/dita/archspec/base/controlled-values-for-attributes
- Google Cloud, "Search with vector embeddings" (Firestore), last updated 19 October 2026. docs.cloud.google.com/firestore/native/docs/vector-search
- Google Cloud, "Firestore pricing." cloud.google.com/firestore/pricing
- Google AI for Developers, "Embeddings," last updated 9 October 2026. ai.google.dev/gemini-api/docs/embeddings
- Google AI for Developers, "Gemini models," last updated 19 October 2026. ai.google.dev/gemini-api/docs/models
- Google AI for Developers, "Gemini Developer API pricing." ai.google.dev/gemini-api/docs/pricing
- Google AI for Developers, "Billing," last updated 28 September 2026. ai.google.dev/gemini-api/docs/billing
- Google AI for Developers, "Structured outputs." ai.google.dev/gemini-api/docs/structured-output
- Google, Agent Development Kit. github.com/google/adk-python
- Google AI for Developers, "Function calling with the Gemini API." ai.google.dev/gemini-api/docs/function-calling
- Google Cloud, "Cloud Run pricing." cloud.google.com/run/pricing
- Google Cloud, "BigQuery pricing." cloud.google.com/bigquery/pricing
- GitHub Docs, "GitHub Actions billing." docs.github.com/en/billing/concepts/product-billing/github-actions
- SQLite, "FTS5 Extension: the bm25() function." sqlite.org/fts5.html
- Siddharth Rao, "VaultRAG," repository and design document. github.com/raosiddharthp/vaultrag
- Siddharth Rao, "QueryForge," design document. github.com/raosiddharthp/queryforge
- Siddharth Rao, "MANIFEST," design document. siddharthraop.github.io/Manifest
- Siddharth Rao, "KnowledgeFlow," design document. siddharthraop.github.io/KnowledgeFlow
- Siddharth Rao, "AlignR," design document. siddharthraop.github.io/AlignR
- G. V. Cormack, C. L. A. Clarke and S. Büttcher, "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods," SIGIR 2009. plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf