Slides: How ARD works (PDF)

Life of a query: the ARD agent finder

Design document, 2026-10-09, revised 2026-10-30. Covers ard-finder main 8d8d3d68 as deployed in revision ard-00082-dar: 118,703 entries (built-in 25,049 + open-data catalog 93,654). Every claim carries a file and line reference (on main 8d8d3d68); the measurements in section 4 are from 2026-10-09. Sibling documents: NeuralKG2 (the caller that depends on ARD most), QDFeeds (QDFeeds), NLWeb samples (the NLWeb samples).

0. How to read this

ARD (the Agent Resource Discovery finder, at ard.neuralweb.dev) answers one question: which published tables describe this quantity? You give it a description ("median household income", "gdp india") and it returns the OKF table documents that describe it, ranked, each naming a variable, a source and how to ask for it. It never fetches data itself.

Callers: NeuralKG2 (one search per piece of data a question needs, plus exact /variable and /variables lookups), the ARD browser page at neuralweb.dev/4dc/ard/, and any MCP client (POST /mcp).

Words used throughout

1. Where ARD sits

flowchart LR
  NK[NeuralKG2<br/>catalog client] -->|POST /search<br/>GET /variable, /variables| ARD[ARD finder<br/>ard.neuralweb.dev]
  PG[ARD page<br/>neuralweb.dev/4dc/ard] -->|POST /search| ARD
  MCP[MCP clients] -->|POST /mcp, /ask| ARD
  ARD --> IDX[(Index in memory<br/>118,703 vectors + metadata)]
  ARD --> LEAF[(Leaf files on disk<br/>OKF markdown)]
  ARD <-->|embed query, rerank| OR[(OpenRouter)]

The service is one Starlette app on Cloud Run (ard, us-central1). The index and the leaf files ship inside the image; there is no database. Models: embeddings openai/text-embedding-3-small (1536 dimensions), rerank openai/gpt-oss-20b, both through OpenRouter.

2. The life of a search

ARD answers one question: which published tables describe this quantity? It never fetches data. Its output is a ranked list of OKF table documents, each naming a variable, a source, and how to ask for it. Source: ard-finder (agent_finder.py, registry/index.py, registry/places.py, filters.py, llm.py).

2.1 What exists before any query (the build)

The index is built offline by registry/index.py:build (212-309) and shipped inside the image as registry/current/.

  1. Collect every *.md under sources/ and corpora/gcp-bigquery/okf/ whose front matter has representativeQueries (_collect_docs, 158-203). These leaves are written by the tools/gen_*_okf.py generators from each publisher's catalog (Data Commons variables, SEC filings, World Bank indicators, Census tables and so on).
  2. Embed one text per entry (index_text, 120-131): title, all representative queries, "Describes {scope}" (the source's entity type), then the full description, joined with ". ". Model openai/text-embedding-3-small, 1536 dimensions, 8000-character input cap.
  3. Publish vectors.npy (float16, 76 MB on disk, about 150 MB as float32 in memory), meta.json (26.9 MB) and manifest.json to registry/current. Vectors are reused when an entry's sig (md5 of text plus model) is unchanged, so a rebuild embeds only what changed. Batches of 96 are checkpointed.

Built-in index (built 2026-10-11): 25,049 entries. Data Commons 11,595; SEC EDGAR 8,096; Census 2,000; World Bank 1,498; UN SDG 713; BigQuery public 356 and marketplace 240; USAspending 223; Treasury 186; nonprofit 990 57; CDC PLACES 40; 45 others.

Extra catalogs. ARD_CATALOGS="name=/path,..." mounts more indexes beside the built-in one (registry/catalogs.py:31-45); each holds index/{vectors.npy,meta.json,manifest.json} and its own sources/. Its identifiers carry a name: prefix. catalogs.combine (120-143) refuses to start when catalogs were embedded with a different model or dimension, or share a source directory, unless the later manifest declares extends. Production mounts open-data=/app/open-data-catalog (93,654 entries, built by tools/build_open_catalog.py), which declares extends: ["census", "datacommons"]: its ACS detailed and subject tables join the census family and its India supplement joins datacommons. Live total: 118,703.

At server start the service verifies the built-in release is current (agent_finder.py:924-931; extra catalogs get only the combine checks), then (lifespan, 873-877) loads the vectors and metadata of every catalog (index._store), projects every row into an ARD entry (_catalog), and builds two exact-lookup tables from the Data Commons leaves (_variable_index, 630-652): variable -> leaf paths and variable -> family where a family is (populationType, measuredProperty, statType, constraints). Those two tables are what /variable and /variables read. If no model credentials are present, startup fails (llm.py:47).

2.2 Endpoints

Route Purpose Model involved
POST /search the main path, below embedding + rerank
GET|POST /ask, POST /mcp the same search from plain text or JSON-RPC; 400-character query cap same
GET /variable?id= every source's document for one published variable, widest coverage first (default 20, max 50); built-in catalog only (see 6) none
GET /variables?populationType&measuredProperty&statType[&constraints=a,b] the dimensions a quantity is split by, or the variables split by exactly those dimensions (default 200, max 500) none
GET /publishers the publishers (domains) a caller may name in filter.publisher, with their sources none
GET /, GET /agents, GET /agents/entry, POST /explore, /.well-known/ard.json, GET /okf/index?dir=, GET /okf/file?path=, /health, /stats, /stats.json search page, browsing, facets, manifest, raw OKF documents, liveness (with per-catalog counts) none

There is no authentication. CORS allows any origin. Body limit 64 KB (413 above). /search has a per-IP daily quota of 10,000 held in process memory (429 with Retry-After); the quota is charged after the body is parsed but before the query is validated.

2.3 A /search request, step by step

NeuralKG2 sends (neuralkg2/catalog.py:160-170):

{"query": {"text": "Find tables for: gdp india. Background only, not the search: the user's full question is \"What is India's GDP?\".",
           "texts": ["gdp india", "What is India's GDP?", "gdp"]},
 "pageSize": 10, "rerank": true, "sources": ["datacommons"]}

Step 1: validate (agent_finder.py:793-824). query.text must be a non-empty string; query.texts a list of 1 to 4 non-empty strings (default [text]); pageSize clamped to 1-100; sources/excludeSources lists of at most 50 names that must exist (400 with the valid names otherwise); query.filter may use publisher, type, tags (other keys are reported back in filter.ignored). Values in one key are ORed, different keys ANDed (filters.py). catalogs optionally names the catalogs to search (unknown names are a 400; agent_finder.py:758-773); the MCP tool does not pass it.

Step 2: phrasings (index.py:697-708). The caller's texts come first. For each, places.strip (places.py:98-101) adds a version with places removed ("gdp india" becomes "gdp") from a gazetteer of US states, countries, large cities, demonyms and "X County"-style patterns. Reason: documents describe measures, not places, and a place in the query drags in any document that merely mentions it. At most 8 phrasings result. No model rewrites the query.

Step 3: embed and score (search_many_async, 807-867). All phrasings are embedded in one call and L2-normalised. Each entry's score is its maximum cosine similarity over the phrasings (sims = vecs @ q.T, 118,703 rows across all catalogs, numpy). Entries are walked in descending score, the request's filters applied, until about 150 (ARD_PREFILTER_MAX) are collected. Each carries embed_score = round(score*100, 1).

Step 4: shape the pool before the model sees it. This is the part that decides recall.

Rule Constant Effect
Adaptive slice PREFILTER_SLICE 30 (60 in production), band 5 points the head is at least the slice and as many as sit within 5 points of the top score
Examined cap PREFILTER_EXAMINED_MAX 120 the head is at most 120; per-source promotion can add up to 3 per source beyond it
Each phrasing's best PER_PHRASING 6 each phrasing's six best tables are promoted into the head if they were below the cut
Each source's best PER_SOURCE_POOL 3 every source family gets at least its three best into the head

The per-phrasing rule exists because a place-bearing phrasing and the stripped phrasing find different tables; keeping each phrasing's best means a table that either finds survives. The per-source rule exists so a large source (Data Commons has 11,595 entries) cannot push every other source out of the head.

Step 5: rerank with a model (_rerank_by_source, 870-900). Candidates are grouped by source family in order of first appearance; the first 5 families are scored, one model call each, in parallel. The first group is scored alone; the other four are prefixed with the first group's top 2 candidates as anchors (their scores are discarded) so a weaker source is not graded on a curve.

Step 6: merge (_source_first, 663-673). Eligible results are ordered with the best result of each source first (up to 5 sources), then the rest by score, cut to pageSize. The order is therefore not purely by score: it guarantees breadth across sources.

Step 7: widen when nothing qualifies (846-866). If no candidate scored 50 or more, the service looks at how spread the embedding scores of the head are. A spread (standard deviation) of 3.0 or more means the head already separates good from bad, and the answer is "no match". A narrow spread means the embedding cannot tell and the answer may be deeper, so it scores further windows of PREFILTER_SLICE (60 in production), one model call each with no per-source grouping or anchors, up to 120 examined. It stops when something qualifies or when a window's own spread is 3.0 or more; a failed widening call is a 503.

Step 8: respond. Each hit becomes a full ARD entry read from the leaf file (_entry_from_meta, 230-280): identifier (a URN urn:air:<publisher>:okf:<source>.<leaf>), displayName, description, representativeQueries, tags, the okf:* terms of the leaf merged over its source's _access.md (variable, facet, unit, populationType, measuredProperty, statType, constraints, coverage, latest observation, access operations), the markdown body, and score. The description is the leaf's own (see 6). The envelope also carries usage: model calls, tokens, dollars and a per-stage and per-model breakdown (llm.Ledger).

2.4 What NeuralKG2 does with the result

RemoteCatalog._search (neuralkg2/catalog.py:160-200) validates the shape strictly (a malformed response is a provider failure, never an empty result), maps each entry to a Resource through the directories table in ard-gcp.json (datacommons to the Data Commons plugin, and so on; an entry with no plugin becomes ard:<family> and is handled by the generic source), and turns no_match into an empty result. If the reranked search comes back empty, NeuralKG2 retries once with rerank: false (catalog.py:131-138) and the plugins judge every document themselves.

2.5 Where ARD is deterministic and where it is not

Deterministic: validation, filters, place stripping, the embedding walk, the slice, per-phrasing and per-source promotion, source-first ordering, the score threshold, the widening rule, and every /variable and /variables answer. Model-driven: the 0-100 rerank score (which decides what appears at all) and the embeddings themselves.

2.6 Failure behaviour

Condition Response
No candidate clears 50 200, results: [], eligibility: {status: "no_match", threshold, topScore}
Rerank of the first group, or of a widening window, fails 503 relevance_scoring_failed (embedding order is deliberately not substituted)
Filters leave no candidate 200 no_match with topScore: null, no model call (index.py:831-832)
Query deadline 504 query_cancelled
Embedding or credential failure unhandled, 500, no usage
Daily quota 429

Silent behaviours worth knowing (all visible in the code, none visible in a response): sources beyond the fifth are never scored; a failure in any non-leading group drops that source without a signal; malformed ranking items are skipped without notice; rerank: false returns embedding order on a different score scale without saying so; a wide first slice short-circuits widening even when the answer is deeper; broken leaf files lose their okf:* terms without an error.

3. Tunables and defaults

Variable Default Where
AGENT_FINDER_PORT 8088 (the Dockerfile sets 7860) agent_finder.py:30
AGENT_FINDER_BIND_HOST 127.0.0.1 (the Dockerfile sets 0.0.0.0) agent_finder.py:34
SEARCH_LIMIT_PER_DAY 10000 per IP (0 disables) agent_finder.py:43
TRUST_PROXY 0 (the Dockerfile sets 1) agent_finder.py:44
AGENT_FINDER_QUERY_TIMEOUT 180 s agent_finder.py:477
AGENT_FINDER_MAX_BODY 65536 bytes agent_finder.py:478
ARD_RERANK 1 index.py:762, 810
ARD_PREFILTER 15 (sync path only; the async first slice overrides it) index.py:602
ARD_PREFILTER_MAX 150 index.py:615
ARD_PREFILTER_BAND 0.05 (times 100) index.py:616
ARD_PREFILTER_SLICE 30; production sets 60 index.py:625
ARD_PREFILTER_EXAMINED_MAX 120 index.py:626
ARD_PREFILTER_NARROW_STD 3.0 index.py:630
ARD_PER_SOURCE_POOL 3 index.py:642
ARD_RESULT_SOURCES 5 index.py:643
ARD_SOURCE_ANCHORS 2 index.py:644
ARD_PER_PHRASING 6 index.py:711
ARD_STRIP_PLACES 1 index.py:694
ARD_MIN_RERANK_SCORE 50 index.py:445
ARD_RERANK_MAX_TOKENS / _REASONING_HEADROOM / _REASONING_EFFORT / _DESC 400 / 1500 / low / 0 index.py:420, 438, 473, 531
ARD_CATALOGS none; production mounts open-data=/app/open-data-catalog (set in its image) registry/catalogs.py:33
LLM_PROVIDER auto-detected from the keys present; production sets openrouter llm.py:36
RERANK_MODEL falls back to the chat model; production sets openai/gpt-oss-20b llm.py:169
OPENROUTER_MODEL openai/gpt-oss-120b llm.py:160
OPENROUTER_EMBEDDING_MODEL openai/text-embedding-3-small llm.py:181
LLM_TIMEOUT / LLM_MAX_RETRIES / LLM_PROVIDER_SORT 120 s / 3 / throughput llm.py:70-71, 355, 443
MAX_QUERY (/ask, /mcp only) 400 characters mcp_server.py:16

4. Measured behaviour (service statistics, as of 2026-10-09)

Read from revision ard-00049-ciq (24,827 entries) on 2026-10-09; not re-measured since the open-data catalog was added.

Searches since start (3 hours) 414, from 5 distinct callers
Outcomes 376 with results, 38 no_match (9%)
Latency per search median 1.84 s, 95th percentile 3.98 s
Model spend $0.00057 per search (rerank dominates; embeddings are about $0.0000003)
Calls per search about 3 model calls (1 embedding + up to 5 rerank groups, averaged)
Status ledger (neuralweb.dev/status) 1 of the 4 sample questions on the ARD page judged to return at least 5 relevant documents of 10 (3 partial)

The live /health shows the same counters for the current instance (searches.total, last_at, latency_seconds, failures_by_status); /stats.json has the per-day and per-model breakdown and the OpenRouter key's own spend.

5. Invariants

  1. A failed rerank is never replaced by embedding order. The leading group's failure is a 503 that says so (agent_finder.py:838-844; a widening window's failure too, index.py:855-856).
  2. Breadth across sources is guaranteed before depth by score: each source's best three enter the head, and each of up to five sources leads the results with its best table (index.py:642-673).
  3. Either phrasing finds a table: a table found by the place-bearing or the place-stripped phrasing stays eligible (_head_with_each_phrasing).
  4. /variable and /variables are exact: no embedding, no model (agent_finder.py:666-708).
  5. Names are checked: an unknown source or publisher is a 400 listing the valid ones, never an empty result that looks like "no match" (filters.py).
  6. The release is verified at start: a stale or incomplete built-in index refuses to serve (agent_finder.py:924-931); extra catalogs must match its embedding model and dimension and may not share a source directory unless declared (registry/catalogs.py:120-143).

6. Open issues

7. File map

Concern Where
HTTP routes, validation, response assembly agent_finder.py
Index build, search, pool shaping, rerank registry/index.py
Place stripping registry/places.py
Extra catalogs registry/catalogs.py
Tags in words (rerank card) registry/scope_text.py
Source and publisher filters filters.py
Models, retries, usage ledger llm.py
MCP and /ask mcp_server.py
Counters and the stats page stats.py
Query deadline and cancellation query_context.py, runtime.py
Search page home.py
Leaf generators tools/gen_*_okf.py, tools/datacommons/; release builder tools/build_registry_release.py; open-data catalog tools/build_open_catalog.py, tools/verify_open_catalog.py
The index registry/current/ (vectors.npy, meta.json, manifest.json)
Tests tests/ (test_each_phrasing.py, test_variable_lookup.py, test_llm_retry.py, test_health.py, test_catalogs.py, test_scope_text.py, ...)