← All work

Case study · Personal project · open source

VA Building Code Chat: answers from the building code, with every citation checked

Agents that answer plain-English questions about the 2021 Virginia Construction Code from a graph of the whole code. Every section or table an answer names is looked up in the graph by code, so a made-up citation can't pass as real.

Role
Sole builder: ingestion, graph, agents, evals, app
Stack
LangGraph, Gemini, Neo4j, Redis, FastAPI, React
Headline result
Right section ranked first 57% → 98% (49 golden-set questions)

1The problem

The Virginia Construction Code is hundreds of pages of sections that point at other sections. A rule in one place often depends on a table three chapters away. A chatbot that answers from loose text chunks will confidently cite sections that don't say what it claims, or don't exist. For this kind of question, a wrong citation is worse than no answer.

2What I built

All of it: the pipeline that turns the code PDF into a graph, the agent team, the retrieval layer, the eval suite and CI gates, and the backend the web app talks to.

From a question to a cited answer

ModelCode
01 · TriageModel
Pick a route

Greeting, follow-up, direct lookup ("show me 1607.1") or real research. A cached answer returns at once.

02 · PlannerModel
Split the question

Two to four research steps, each a smaller question.

03 · ResearchCode, in parallel
Search the graph

A named section ranks first; otherwise semantic search over passages, tables and diagrams. Web search only if a relevance check fails.

04 · WriterModel
Write the answer

From the passages found, streamed to the browser as it writes.

05 · CitationsCode, no model
Check every citation

Each section and table number is looked up in the graph. Real ones become links to their text; the rest stay plain text.

3Decisions that mattered

Keep the code's structure: a graph, not flat chunks

I parsed the whole code into a Neo4j graph that keeps what contains what, what comes next, and what cites what. Tables, equations and diagrams are their own nodes; passages, tables and diagrams carry embeddings with the section heading built in.

Why: the graph lets a named section be found directly, lets the citation check be a lookup instead of a judgment, and lets a reader open the exact text an answer relies on. The rebuild script is idempotent and verifies itself against the source before reporting success.

CONTAINSCONTAINS REFERENCESHAS_CHUNK CONTAINSREFERENCES Chapter 16 §1607 §1604 §1607.12 Table 1607.1 Passageembedded for search Equation · Diagram
The code browser showing section 1607 as a graph of sections, passages and tables
Open in a new tab ↗
The real graph from the live app, opened on §1607 Live Loads. Click Graph to see the section as nodes. In total: 5,961 nodes and 8,421 relationships across 33 chapters.
What the graph holds
NodeCountNode or linkCount
Sections317Tables118
Subsections1,953Equations69
Passages3,405Diagrams49
Embedded for search3,572Cross-references between sections242

Let the model write, let code check

The writer is a model; the citation check is not. After the answer is written, code pulls out every section and table number and looks each one up. Why: asking a second model "is this citation real?" just moves the problem. A lookup can't be talked into a yes.

Say when the answer came from the web

When nothing in the graph passes a quick relevance check, research falls back to web search, and the answer is labelled that way instead of dressed up as code text. Answers are cached by question for 30 days, versioned so a pipeline change never serves a stale answer.

4How I measured it

The first version often ranked the wrong section first, so I built a golden set of 51 questions with answers taken from the code text and every expected section confirmed to exist in the graph. Two of the 51 ask about things the code doesn't cover, to test refusals; the retrieval score uses the other 49. I then measured seven retriever variants on those 49, changing one thing at a time.

What it found: one embedding setting (embedding the question as a query rather than as a document) was worth about 31 points of hit@1 by itself. Mixing in keyword search, which sounds like it should help, made ranking worse (88% to 65%), so keyword search became a fallback only. Putting section headings into each embedding added the last few points.

Retrieval on 49 golden-set questions (hit@1 = right section ranked first)
Retriever varianthit@1BarMRR
Original57%
0.69
Keyword only55%
0.66
Vector + keyword fused65%
0.76
Question embedded as a query88%
0.92
+ named-section lookup94%
0.97
+ headings in each embedding98%
0.99
End to end through the whole app, same 12 questions before and after the rewrite
MeasureBeforeAfter
Answers citing the right section8 of 12 (67%)12 of 12 (100%)
Web fallback on questions the graph covers67%33%
Expected facts present100%100%

Gated in CI

Every backend push runs the retrieval eval and fails below hit@3 0.95 or MRR 0.90.

Checked nightly

A scheduled job asks the deployed app real questions and fails if fewer than 75% cite the right section.

Live metrics

An endpoint reports p50/p95 latency, web-fallback rate, no-citation rate and errors, storing no question or answer text.

5What fails, and what's next

  • The eval sets are small and the settings were tuned on the same 49 questions, so differences of two to four points are noise. The end-to-end comparison is 12 questions.
  • The matching is lenient in places. 21 of the 51 questions accept any match under a whole top-level section.
  • It's slow. Median answer time was 79 seconds on the 12-question run and 39 seconds on a 4-question check of the deployed app, almost all of it in model calls, not retrieval.
  • Calculations aren't wired in yet. The app suggests calculation questions, but the step that works the numbers from the code's own equations isn't connected to the agent flow.
  • Next: grow the golden set from answers users flag as wrong, cut model latency, and read a sample of judge verdicts before trusting any average.

Screens

Next case study: Between-Visit →