feat: build-generate AI retrieval layer (knowledge graph + index) - #418
feat: build-generate AI retrieval layer (knowledge graph + index)#418eugenia-scandit wants to merge 9 commits into
Conversation
Add a Docusaurus postBuild plugin that turns the rendered docs into an AI-consumable layer, generated at build time (nothing committed): /assets/knowledge-retrieval-index.json (fast lookup) /assets/knowledge-graph.jsonld (concept graph) Parses the final rendered HTML (so imported partials/MDX are captured in full), splits each current-version page into ~1400-char knowledge modules, and derives per-chunk metadata: summary (from the frontmatter description), rule-based intents/audiences, framework, product, and the real URL. The graph mines real edges — BelongsToProduct, CitesApi, SeeAlso, and per-product AvailableOn / NotAvailableOn. Current docs only (frozen versions, the external API reference, and *.html redirect stubs excluded). Extraction failures are non-fatal so they never block a deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Score the build-generated knowledge retrieval index against a gold set of real user queries (seeded from Algolia top-queries + the product taxonomy). A module is relevant when its URL matches the query's expected path-class, so the gold set stays stable even though module ids are auto-generated. - scripts/retrieval-evals/run.cjs — pure-Node token-mode scorer + thresholds - scripts/retrieval-evals/gold-set.json — 20 seed queries - .github/workflows/docs-retrieval-evals.yml — builds docs, runs the eval on PRs touching docs/extractor/gold-set; uploads the report - npm run docs:retrieval-evals Verified locally against 4523 modules: success@3=0.90, precision@3=0.87, MRR=0.86 (thresholds 0.8 / 0.6). The eval already surfaces real token-mode weaknesses (e.g. "data matrix" matching "matrixscan") — which is the point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lucatorella
left a comment
There was a problem hiding this comment.
🤖 Two build/deploy-integrity findings inline.
|
|
||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import * as cheerio from "cheerio"; |
There was a problem hiding this comment.
🤖 cheerio is imported directly but is not a declared dependency — it resolves today only by npm hoisting, and losing it breaks the whole site build, not just this plugin.
On this branch package.json has no cheerio in dependencies or devDependencies (0 matches), and neither lockfile is touched by the PR. In package-lock.json, node_modules/cheerio (1.0.0-rc.12) has exactly one requirer:
required by: ['node_modules/@docusaurus/plugin-content-blog']
So the import works only because a transitive dep of preset-classic happens to hoist cheerio to the root. @docusaurus/core and @docusaurus/preset-classic are pinned as ^3.4.0, so any Docusaurus 3.x bump that stops pulling cheerio into plugin-content-blog — or a npm dedupe, a lockfile regeneration, or the yarn.lock path with a stricter resolver — removes it.
The consequence is worse than a broken artifact. This is a top-level import in a module that docusaurus.config.ts imports, so resolution failure happens at config load, before any plugin lifecycle runs. The try/catch at the bottom of postBuild — which the PR description relies on for "extraction failures are non-fatal so they never block a deploy" — cannot catch it. The failure mode is npm ci && npm run build dying on every branch, not a skipped artifact.
Adding cheerio to dependencies (and letting the lockfile update) fixes it.
There was a problem hiding this comment.
🤖 Still open at d948727b. package.json on the head has no cheerio in either dependency block (the only change is the new docs:retrieval-evals script), so the import continues to resolve purely through hoisting from @docusaurus/plugin-content-blog.
Worth noting the new Docs Retrieval Evals job raises the stakes slightly: it runs yarn build on every PR touching docs/** or the extractor, so once cheerio stops being hoisted, that job fails alongside Build Docusaurus Site rather than the failure being confined to this plugin.
|
|
||
| const assetsDir = path.join(outDir, "assets"); | ||
| fs.mkdirSync(assetsDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(assetsDir, "knowledge-retrieval-index.json"), JSON.stringify(index, null, 2) + "\n", "utf8"); |
There was a problem hiding this comment.
🤖 Nothing asserts that anything was actually extracted, so this can publish empty artifacts over good ones with a fully green build and no signal.
index is written unconditionally. Combined with the catch-all at the end of postBuild:
} catch (err) {
console.warn(`[knowledge-extractor] skipped (non-fatal): ${(err as Error)?.message || err}`);
}there is no build outcome that distinguishes "extracted 9000 modules" from "extracted 0". Two concrete paths get there:
- Selector drift. Every page is admitted via
$("article .markdown")/$(".theme-doc-markdown")/$("article"), and a non-match iscontinue(if (!root.length) continue;). A Docusaurus or theme upgrade that renames those classes makes every page fall through,indexbecomes[], and the build succeeds — deploying an empty index and a graph with no nodes. - Mid-write failure. The two
writeFileSynccalls are sequential and share onecatch. A throw between them ships an index with no matching graph, again with a passing build.
Because these are build-time generated and served from outDir, a bad run doesn't leave the previous good copy in place — consumers get empty JSON or a 404, and the only trace is a console.warn in build logs nobody greps.
The rest of this config is deliberately fail-loud about build integrity (onBrokenLinks: "throw", onBrokenAnchors: "throw", onBrokenMarkdownLinks: "throw"), so a floor check would match that convention: throw (or at minimum refuse to overwrite) when pagesProcessed or index.length is 0, or below some fraction of the pages walked. "Non-fatal" is a reasonable goal for one bad page; it shouldn't extend to "extracted nothing at all".
There was a problem hiding this comment.
🤖 Partially mitigated at d948727b, but not closed. The new eval script does hard-fail on an absent or empty index:
if (!fs.existsSync(INDEX)) { console.error(`retrieval-evals: index not found at ${INDEX} …`); process.exit(1); }
if (!Array.isArray(index) || !index.length) { console.error("retrieval-evals: empty or invalid index."); process.exit(1); }so a build that silently extracts nothing now gets caught — on PRs. Two gaps remain:
docs-retrieval-evals.ymltriggers only onpull_requestwith apathsfilter (docs/**, the extractor, the eval scripts, its own workflow). There is nopushtrigger, so nothing checks the index onmainor on the branch that actually deploys. A build that starts emitting an empty index for a reason unrelated to those paths — a Docusaurus upgrade renamingarticle .markdown, say — ships with every job green.- The plugin itself still writes whatever it has and swallows its own errors, so the artifact pair can still go out mismatched (index written, graph not) without the eval noticing: it only reads the index.
A floor check inside the plugin still seems worth it, since it's the only place that knows how many pages were walked versus how many produced modules.
Report recall@k = relevant-in-top-k / min(total-relevant-in-corpus, k), with a --min-recall gate (default 0.6), alongside success@k / precision@k / MRR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… writes + all-docs eval Addresses review of PR #418: 1. cheerio was imported but undeclared — resolved only via npm hoisting from a transitive Docusaurus dep. A dedupe / lockfile regen / Docusaurus 3.x bump could drop it, failing config load before any lifecycle (so postBuild's try/catch can't help) and breaking the whole build. Declare cheerio@1.0.0-rc.12 and update both lockfiles. 2. Empty extraction could publish empty artifacts on a green build. postBuild now keeps per-page failures non-fatal but THROWS when 0 modules are extracted (matches onBrokenLinks:"throw"), so selector drift fails the build. 3. Both artifacts are written atomically (temp + rename) so a failure between the two writes can't ship a mismatched index/graph. Also: `--auto` eval mode does corpus-wide, page-level self-retrieval over ALL docs (page-success@3=0.82 over 572 pages), not just the 20-query gold set; recall@k added earlier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lucatorella
left a comment
There was a problem hiding this comment.
🤖 Re-reviewed 65676e09..d948727b. One finding on the new metric; notes on the two earlier threads.
| const success = firstRel !== -1 ? 1 : 0; | ||
| const precision = relCount / Math.max(hits.length, 1); | ||
| const denom = Math.min(totalRelevant(g.expect), K) || 1; | ||
| const recall = relCount / denom; |
There was a problem hiding this comment.
🤖 recall@k is algebraically identical to precision@k for every query in the gold set, so --min-recall adds a blocking threshold that provides no protection beyond the one already there.
const precision = relCount / Math.max(hits.length, 1);
const denom = Math.min(totalRelevant(g.expect), K) || 1;
const recall = relCount / denom;hits comes from search(index, g.query, K), so hits.length === K for any index with at least K modules. And denom === K whenever totalRelevant >= K. Both denominators are then K, both numerators are relCount, and the two metrics are the same number — query by query, not just on average.
totalRelevant >= K holds for all 20 gold entries. Counting pages under each expect path-class in docs/ (and every page yields at least one module):
/sparkscan/ 34 /id-capture/ 44
/matrixscan-count/ 34 /id-bolt/ 13
/matrixscan-find/ 34 /express/ 18
/label-capture/ 43 barcode-symbologies 24
/barcode-capture/get-started 11 /agent-skills 9
The smallest is 9, against K = 3. The || 1 guard and the min(total, K) cap only bite when a path-class has fewer than 3 relevant modules in the whole corpus, which no current entry does — so the cap never engages and the commit's stated "capped recall" degenerates to precision.
Two consequences worth heading off:
- The report and the console will print
precision@3andrecall@3as two equal numbers that move in lockstep. That reads as two independent signals corroborating each other, when it's one signal printed twice. MIN_RECALL = 0.6is really a precision floor. Precision currently has no threshold, so if a precision floor is what's wanted, that's a reasonable thing to add — but it should be named that, andMIN_SUCCESS/MIN_MRRshouldn't be joined by a third gate that can only fail when precision already did.
A recall metric that actually measures recall needs an uncapped denominator (relCount / totalRelevant, reported at a k large enough to be meaningful), or a gold set that names specific pages rather than path-classes so totalRelevant is small and the cap has something to cap.
…ush trigger Follow-up to review of PR #418: - recall@k was algebraically identical to precision@k for this gold set: it uses path-classes (>K relevant modules each), so capped recall (relCount/min(total,k)) reduces to precision (relCount/k), query by query. Reporting both was one signal twice, and --min-recall could only fail when precision already had. Removed the recall metric/gate; report precision@k with an honestly-named MIN_PRECISION floor. A real recall needs single-page gold entries (noted in code). - Run the eval on push to main too, so the deploy branch's index is checked (the plugin's own fail-loud floor already fails every build on empty extraction; this adds the quality check on main). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read each page's source frontmatter (gray-matter) and flow user_intents, not_for, product, topic_type, canonical_id into every chunk — product/topic_type now authoritative over path heuristics, user_intents injected into assistant_context, semantic_status flips rule_based->frontmatter. Falls back to today's heuristics when a page has no mapped source or no fields, so extraction never breaks (verified: 4523 modules/572 pages/identical edge counts). Declares gray-matter (already a Docusaurus transitive dep) explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…excerpt assistant_excerpt is sliced to 300 chars; inlining user_intents/not_for pushed the summary out of the preview. user_intents/not_for/canonical_id/topic_type are already emitted as full, un-truncated fields, so the signal is preserved — the excerpt now stays a clean title+summary preview. llms.txt is unaffected (docusaurus-plugin-llms never reads these fields). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes curated intent retrievable by any consumer that searches keywords, not only one configured to read the dedicated user_intents field. not_for is excluded from keywords to avoid false-matching the products it steers away from; it stays a structured field for reranker demotion. Verified: intent phrase present in keywords, not_for absent from keywords, dedicated fields full. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntmatter_augmented Multi-product: frontmatter product may be a list (e.g. troubleshooting: [sparkscan, barcode-capture, id-capture]); now every product flows into the record (products[]), graph nodes, and BelongsToProduct edges — not just the first — so LLMs see all relevant products. Rename: semantic.status 'frontmatter'->'frontmatter_augmented' since intent/audience remain heuristic and only keywords/product are curated; the old label overstated. Verified: products=[sparkscan,barcode-capture] on a two-product page, status renamed, 0 page errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
raffaelefarinaro
left a comment
There was a problem hiding this comment.
🤖 This review was AI-assisted. I verified each blocking finding by hand against the code and a local build before posting.
Starting with what works, because it is genuinely careful. The exclusion logic is correct, and I checked it against the built output rather than reading it: zero URLs from the frozen versions, the external data-capture-sdk reference, or any of the 235 *.html redirect stubs make it into the index. Build cost is not a regression, Build Documentation on this branch ran 3m29s to 4m08s against 3m36s to 4m00s on recent main. And the eval gates sit far above chance, since a random retriever over this corpus would score success@3 = 0.158 against your floor of 0.8, with 0.900 actual.
Your three open threads are already fixed, so close them
You have probably been looking at three red threads for six days. They are stale, not outstanding:
cheerioandgray-matterare both declared independenciesas of1748eaf, with matching entries in both lockfiles.- The zero-modules floor check at
index.ts:697and the.tmpplus rename writes atindex.ts:712landed in the same commit, which is exactly what the thread asked for. - Recall was removed and replaced with an honestly named
MIN_PRECISIONin972e6cb, plus a comment explaining the algebra.
Luca's re-confirm was against d948727b, which is six commits behind the current head 2a6edd5. I checked the recall claim against the built corpus rather than taking it on trust, and he was right: every one of the 20 gold entries resolves to between 31 and 1075 relevant modules against K = 3, so capped recall did reduce to precision. The fix is the right one.
Rebase before responding to anything else here
The last four commits, which add the entire frontmatter-ingestion feature, have zero CI runs. Here is why. main deleted package-lock.json in f23a28f (#416), and this branch still edits it, so git merge-tree reports a modify/delete conflict on package-lock.json plus a content conflict in package.json. GitHub cannot build the refs/pull/418/merge ref for a conflicted PR, so no pull_request run gets created. Runs exist and are green for the first five commits and then stop dead on 2026-08-05 at 12:53Z. gh pr checks 418 reports "no checks" because it queries head 2a6edd5 specifically.
A rebase onto main plus deleting package-lock.json fixes the conflict, restores CI, and picks up docs-gate.yml, which does not exist on this branch at all. Do that first, because CI will probably surface more than any human reader can, and it would be wasteful to work through review comments against a head that has never been built.
Provenance of the numbers
The corpus statistics below (4523 records, 89% at the 400-character cap, 219 Titanium modules, 26.9 MB, the random-retriever baseline) come from my own local build on Node 26 with npm ci, not from CI on Node 18 with yarn. My counts came out at 4523 modules against CI's 4526 at 972e6cb, a 0.07% difference I attribute to the four intervening commits. Treat the figures as indicative of magnitude, not exact.
What is in the inline comments
Five blocking: the package-lock.json conflict, the Titanium trees, the chunk truncation, the contradictory core availability edges, and the division of labour with docusaurus-plugin-llms. Three smaller ones: a substring match in pickIntents, MIN_AUTO_SUCCESS defaulting to zero, and last_verified being the build timestamp.
Two of these are product decisions more than defects, so I have put them as questions about intent rather than verdicts. I also could not see who consumes these artifacts, so my point about truncation argues from the plugin's own stated purpose in its header comment, not from a requirement I can verify. If a consumer exists that only needs pointers plus a snippet, that finding weakens a lot and I would rather hear that from you than assume it.
I have left a longer writeup with the non-blocking observations out of this review to keep it readable. Happy to walk through it whenever suits.
| "@docusaurus/plugin-client-redirects": "3.4.0", | ||
| "@docusaurus/preset-classic": "^3.4.0", | ||
| "@mdx-js/react": "^3.0.0", | ||
| "cheerio": "1.0.0-rc.12", |
There was a problem hiding this comment.
Blocking: this file is why the PR cannot merge and why CI has gone quiet.
main deleted package-lock.json in f23a28f (#416). This branch still modifies it, which produces a modify/delete conflict, and a conflicted PR gets no pull_request workflow runs at all because GitHub cannot build the merge ref.
The repo is yarn-only: every workflow runs yarn install --frozen-lockfile. Please delete package-lock.json in the rebase rather than updating it. Keeping both lockfiles guarantees drift, and I hit that directly while reviewing: running npm ci on this branch rewrote yarn.lock with 251 insertions and 130 deletions of unrelated Algolia version churn.
The yarn.lock side of your diff is correct and minimal, so nothing to do there beyond stripping the two unrelated registry-URL flips on fsevents and search-insights that came along for the ride.
| ], | ||
| // Build-generate AI layer: emits /assets/knowledge-retrieval-index.json and | ||
| // /assets/knowledge-graph.jsonld from the rendered HTML (see src/plugins/knowledge-extractor). | ||
| knowledgeExtractor, |
There was a problem hiding this comment.
Blocking, as a question rather than a defect: what is the division of labour between this and docusaurus-plugin-llms?
Seven lines above this, the same config registers docusaurus-plugin-llms, which already emits an AI-consumable layer from the same docs on the same build: llms.txt at 0.07 MB and llms-full.txt at 2.03 MB in my build. That plugin's setup here is deliberately curated, with shared-partial dedup, non-Web-SDK root trimming, and measured before/after numbers in the header comment.
This plugin adds a second layer with different curation rules and 26.9 MB of output. I am not assuming one should go, but two parallel AI exports with no stated boundary is the thing most likely to rot, and the next person to touch either one will not know which is authoritative. Could you add a line to the PR description saying what each is for, and whether the intent is for one to eventually replace the other?
| } catch { | ||
| /* no versions.json */ | ||
| } | ||
| const excluded = new Set<string>([...frozenVersions, "data-capture-sdk", "assets", "img", "fonts", "search"]); |
There was a problem hiding this comment.
Blocking: this exclusion set does not honour a curation decision made elsewhere in the same config.
docusaurus.config.ts:49 declares llmsIgnoredSdkTrees = ["docs/sdks/titanium/**"] with the comment "Entire platform omitted from llms export (deprecated / not needed for assistant context)". build/llms.txt has zero /titanium/ references. This index has 219 modules under /sdks/titanium/.
Nothing secret leaks, so this is not a security problem, but the repo has already decided which trees it wants an assistant reading, and this artifact overrides that decision silently and publishes the result to the public site.
Was that deliberate? If the new layer should have wider coverage than the llms export, that is a defensible answer and worth writing down. If not, reusing llmsIgnoredSdkTrees and the shared-partial dedup list would keep the two layers consistent for free.
| channels: m.channels, | ||
| dependencies: m.dependencies, | ||
| tags: m.tags, | ||
| docs_excerpt: m.content.docs_markdown.slice(0, 400), |
There was a problem hiding this comment.
Blocking: the index does not ship the content it chunks.
CHUNK_TARGET_CHARS is 1400 at line 30, but these two lines truncate to 400 and 300. In my build, 4031 of 4523 records (89%) sit exactly at the 400-character cap and 4475 (99%) sit exactly at the 300-character cap. So roughly 70% of every chunk gets parsed, chunked, held in memory, and then discarded, and each published record is a fragment cut mid-sentence.
The header comment describes this as the artifact "an assistant / in-docs search consume". Against that goal, a consumer gets a snippet and still has to fetch the URL, which llms-full.txt already covers.
I want to be careful here, because I could not see who actually consumes these files. If the design intent is a pointer index where a 400-character preview is enough, this is fine as built and I would just note it in the header. If the intent is self-contained retrieval modules, then either raise the caps or drop the chunking to page level, because the 1400-character chunking currently buys nothing that survives into the output. Which is it?
| if (p[0] === "sdks") { | ||
| const i = p[1] === "net" ? 3 : 2; // first segment after the framework | ||
| const rest = p.slice(i); | ||
| return rest.length >= 2 ? slug(rest[0]) : "core"; // product dir vs framework-level page |
There was a problem hiding this comment.
Blocking: this "core" fallback produces five self-contradicting availability edges.
core is a bucket for framework-level pages, not a product, so it collects both real pages (/sdks/linux/add-sdk/) and "not available" stubs (/sdks/linux/ai-powered-barcode-scanning/). Because availability is aggregated per product across modules at lines 560 to 580, the graph then emits both edge types for the same pair.
Confirmed in the built graph: urn:product:core carries both AvailableOn and NotAvailableOn for linux, net-android, net-ios, titanium and web. That is 5 of the 29 NotAvailableOn edges contradicting themselves, and these are the edges the PR description calls out as the ones that "directly answer what's available where".
Simplest fix is to skip availability mining when product === "core". Alternatively let NotAvailableOn win when both are present, though excluding the pseudo-product seems more honest.
Worth noting the stub detection itself is sound, which is why I looked here rather than there: all 70 files matching is not available (on|for) the are 298 to 394 byte stubs, and the six files with other "not available" phrasings are legitimate prose that correctly does not match.
| if (contentType === "tutorial" || contentType === "how-to" || text.includes("configure")) intents.push("configure"); | ||
| if (contentType === "troubleshooting" || text.includes("error") || text.includes("fix")) intents.push("troubleshoot"); | ||
| if (contentType === "reference" || contentType === "concept" || text.includes("integrat")) intents.push("integrate"); | ||
| if (text.includes("secure") || text.includes(" auth")) intents.push("secure"); |
There was a problem hiding this comment.
text.includes(" auth") fires on any word starting with "auth", not just authentication. Every one of the 31 modules tagged with the secure intent in my build matched on authorized, authenticity or authority.
A word-boundary regex fixes it, something like /\bauth(entication|orization|n)?\b/. Small blast radius, easy fix.
| priority: 60, | ||
| status: "active", | ||
| owner: OWNER, | ||
| last_verified: updatedAt.slice(0, 10), |
There was a problem hiding this comment.
updatedAt is new Date().toISOString() from the top of postBuild, so last_verified is the build timestamp. Every module in every deploy claims it was verified today. All 4523 records in my build carry 2026-08-11.
A consumer using this for freshness gets noise shaped like signal, which is worse than an absent field. Either drop it, or wire it to the source file's git mtime, which you already have the path for via readFrontMatter.
Same applies to updated_at in the metadata block just below.
| // create/edit. --auto-limit caps it; --min-auto-success gates it. | ||
| const AUTO = process.argv.includes("--auto"); | ||
| const AUTO_LIMIT = parseInt(arg("auto-limit", "0"), 10); | ||
| const MIN_AUTO_SUCCESS = parseFloat(arg("min-auto-success", "0")); |
There was a problem hiding this comment.
MIN_AUTO_SUCCESS defaults to 0, and the gate at line 161 is page_success_at_k < MIN_AUTO_SUCCESS, so --auto can never fail. Nothing in the workflow passes --auto today, so this is latent rather than live, but if the intent is a corpus-wide floor it needs a real default.
Two related notes while you are in this file. --auto is O(pages x modules), roughly 2.6 million score computations at the current corpus size, so it is worth timing before wiring it into CI. And the header comment at line 5 points at docs/assets/knowledge-retrieval-index.json, while the actual default at line 32 is build/assets/....
Add a Docusaurus postBuild plugin that turns the rendered docs into an AI-consumable layer, generated at build time:
/assets/knowledge-retrieval-index.json (fast lookup)
/assets/knowledge-graph.jsonld (concept graph)
Parses the final rendered HTML (so imported partials/MDX are captured in full), splits each current-version page into ~1400-char knowledge modules, and derives per-chunk metadata: summary (from the frontmatter description), rule-based intents/audiences, framework, product, and the real URL. The graph mines real edges — BelongsToProduct, CitesApi, SeeAlso, and per-product AvailableOn / NotAvailableOn. Current docs only (frozen versions, the external API reference, and *.html redirect stubs excluded). Extraction failures are non-fatal so they never block a deploy.