simple-dynamsoft-mcp 7.6.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -305,6 +305,8 @@ The server exposes this minimal tool surface:
305
305
 
306
306
  Typical doc Q&A flow: `search` -> `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
307
307
 
308
+ DBR searches also cover the shared Capture Vision API pages that the DBR docs link to (for example `CaptureVisionRouter.capture()` and `getSimplifiedSettings()`/`updateSettings()`); these hits keep their official `capture-vision/docs` URL and are labelled `shared Capture Vision API used by DBR` in search results. Doc URIs are path-based (`doc://dbr/web/web/<version>/user-guide/use-in-framework`) and change only when the docs move or the SDK version changes.
309
+
308
310
  ## Advanced Configuration And Operator Docs
309
311
 
310
312
  Advanced settings, CI/runbook details, and maintenance workflows live in:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "7.6.1",
3
+ "version": "7.7.0",
4
4
  "description": "MCP server for Dynamsoft SDKs - Capture Vision, Barcode Reader (Mobile/Python/Web), Dynamic Web TWAIN, and Document Viewer. Provides documentation, code snippets, and API guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,13 +24,15 @@
24
24
  "scripts": {
25
25
  "start": "node src/index.js",
26
26
  "test": "npm run test:lite",
27
- "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js",
27
+ "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js",
28
28
  "test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
29
29
  "test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
30
30
  "test:gemini": "node scripts/run-gemini-tests.mjs",
31
31
  "test:stdio": "node --test test/integration/stdio.test.js",
32
32
  "test:http": "node --test test/integration/http.test.js",
33
33
  "test:package": "node --test test/integration/package-runtime.test.js",
34
+ "test:retrieval": "node --test test/integration/retrieval-quality.test.js",
35
+ "eval:retrieval": "node scripts/eval-retrieval.mjs",
34
36
  "test:contract": "node --test test/contract/contract.test.js",
35
37
  "check:version-drift": "node scripts/check-version-drift.mjs",
36
38
  "test:lazy": "node --test test/integration/lazy-hydration.test.js",
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Measures search relevance against the golden query set.
3
+ //
4
+ // node scripts/eval-retrieval.mjs [--provider=lexical|gemini] [--case=<id>] [--show] [--json=<path>]
5
+ //
6
+ // The provider is passed through RAG_PROVIDER before the RAG layer loads, with
7
+ // fallback disabled so a failing provider is reported instead of masked.
8
+
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const args = Object.fromEntries(
14
+ process.argv.slice(2).map((arg) => {
15
+ const [key, ...rest] = arg.replace(/^--/, "").split("=");
16
+ return [key, rest.length ? rest.join("=") : "true"];
17
+ })
18
+ );
19
+ const provider = args.provider || "lexical";
20
+ process.env.RAG_PROVIDER = provider;
21
+ process.env.RAG_FALLBACK = "none";
22
+ process.env.RAG_PREWARM = "false";
23
+
24
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const goldenPath = args.golden || join(repoRoot, "test", "fixtures", "retrieval-golden.json");
26
+ const golden = JSON.parse(readFileSync(goldenPath, "utf8"));
27
+ const cases = args.case ? golden.cases.filter((item) => item.id === args.case) : golden.cases;
28
+ if (cases.length === 0) {
29
+ console.error(`No golden cases matched ${args.case}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const { ensureResourceIndexReady } = await import("../src/server/resource-index.js");
34
+ const { searchResources } = await import("../src/rag/index.js");
35
+ const { runRetrievalEval } = await import("./lib/retrieval-eval.mjs");
36
+ const { isResultInScope } = await import("./lib/scope-matcher.mjs");
37
+
38
+ ensureResourceIndexReady();
39
+ const report = await runRetrievalEval({ searchResources, cases, k: golden.k || 5, isInScope: isResultInScope });
40
+ report.provider = provider;
41
+
42
+ const fmt = (value) => (value === null || value === undefined ? "n/a" : value.toFixed(3));
43
+ for (const item of report.cases) {
44
+ const flag = item.gap ? "GAP " : item.requiredHit === false ? "MISS" : "ok ";
45
+ console.log(`${flag} ${item.id.padEnd(34)} recall=${fmt(item.recall)} mrr=${fmt(item.mrr)} ndcg=${fmt(item.ndcg)} dup=${item.duplicates} off=${item.offScope} rn=${item.releaseNotes} ms=${item.latencyMs.toFixed(0)}`);
46
+ if (args.show || item.requiredHit === false) {
47
+ for (const hit of item.top) {
48
+ console.log(` ${hit.rank}. [g${hit.grade}] [${hit.product}] ${hit.title} :: ${hit.url || hit.uri}`);
49
+ }
50
+ for (const url of item.requiredMissing) console.log(` missing: ${url}`);
51
+ }
52
+ }
53
+
54
+ const s = report.summary;
55
+ console.log(`\nprovider=${provider} cases=${s.cases} (gap=${s.gapCases}) k=${report.k}`);
56
+ console.log(`recall@${report.k}=${fmt(s.recallAtK)} mrr=${fmt(s.mrr)} ndcg@${report.k}=${fmt(s.ndcgAtK)} required_hit=${fmt(s.requiredHitRate)}`);
57
+ console.log(`duplicate_rate=${fmt(s.duplicateRate)} off_scope_rate=${fmt(s.offScopeRate)} release_notes_rate=${fmt(s.releaseNotesRate)}`);
58
+ const lat = s.latencyMs;
59
+ console.log(`latency_ms first_query=${lat.firstQuery.toFixed(0)} first_pass p50=${lat.p50FirstPass.toFixed(0)} p95=${lat.p95FirstPass.toFixed(0)} | warm p50=${lat.p50Warm.toFixed(0)} p95=${lat.p95Warm.toFixed(0)}`);
60
+
61
+ if (args.json) {
62
+ writeFileSync(args.json, JSON.stringify(report, null, 2));
63
+ console.log(`wrote ${args.json}`);
64
+ }
@@ -0,0 +1,134 @@
1
+ // Retrieval-quality metrics for the golden query set (test/fixtures/retrieval-golden.json).
2
+ // Cases grade relevant documents by public URL (not doc:// URI) so judgments
3
+ // survive URI-scheme and index-order changes.
4
+
5
+ function normalizeUrl(url) {
6
+ return String(url || "").split("#")[0].split("?")[0].replace(/\/index\.html$/i, "/").toLowerCase();
7
+ }
8
+
9
+ function gradeOf(result, relevant) {
10
+ const key = normalizeUrl(result.url);
11
+ if (!key) return 0;
12
+ return relevant.get(key) || 0;
13
+ }
14
+
15
+ function dcg(gains) {
16
+ return gains.reduce((sum, gain, index) => sum + ((2 ** gain) - 1) / Math.log2(index + 2), 0);
17
+ }
18
+
19
+ function percentile(values, p) {
20
+ if (values.length === 0) return 0;
21
+ const sorted = [...values].sort((a, b) => a - b);
22
+ const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
23
+ return sorted[Math.max(0, index)];
24
+ }
25
+
26
+ // `isInScope(result, filters)` (when given) applies the server's own
27
+ // product/edition/platform matching so cross-platform leakage is counted too.
28
+ function isOffScope(result, testCase, isInScope) {
29
+ const product = testCase.filters?.product;
30
+ if (product && result.product !== product) return true;
31
+ if (isInScope && !isInScope(result, testCase.filters || {})) return true;
32
+ const prefixes = testCase.allowedUrlPrefixes;
33
+ if (Array.isArray(prefixes) && prefixes.length > 0 && result.url) {
34
+ return !prefixes.some((prefix) => String(result.url).startsWith(prefix));
35
+ }
36
+ return false;
37
+ }
38
+
39
+ function isReleaseNotes(result) {
40
+ return /\/release-notes?\//i.test(String(result.url || ""));
41
+ }
42
+
43
+ function scoreCase(testCase, results, k, isInScope) {
44
+ const relevant = new Map(
45
+ Object.entries(testCase.relevant || {}).map(([url, grade]) => [normalizeUrl(url), Number(grade) || 0])
46
+ );
47
+ const top = results.slice(0, k);
48
+ const gains = top.map((result) => gradeOf(result, relevant));
49
+ const positiveGains = [...relevant.values()].filter((g) => g > 0).sort((a, b) => b - a);
50
+ const idealGains = positiveGains.slice(0, k);
51
+ const relevantCount = positiveGains.length;
52
+
53
+ const firstHit = gains.findIndex((gain) => gain > 0);
54
+ const found = new Set(top.filter((result) => gradeOf(result, relevant) > 0).map((r) => normalizeUrl(r.url)));
55
+ const required = [...relevant.entries()].filter(([, grade]) => grade >= 3).map(([url]) => url);
56
+ const requiredFound = required.filter((url) => found.has(url));
57
+
58
+ const urls = top.map((result) => normalizeUrl(result.url || result.uri));
59
+ const duplicates = urls.length - new Set(urls).size;
60
+
61
+ return {
62
+ id: testCase.id,
63
+ gap: relevantCount === 0,
64
+ recall: relevantCount ? found.size / relevantCount : null,
65
+ mrr: relevantCount ? (firstHit >= 0 ? 1 / (firstHit + 1) : 0) : null,
66
+ ndcg: relevantCount ? dcg(gains) / dcg(idealGains) : null,
67
+ requiredHit: required.length ? requiredFound.length === required.length : null,
68
+ requiredMissing: required.filter((url) => !found.has(url)),
69
+ duplicates,
70
+ offScope: top.filter((result) => isOffScope(result, testCase, isInScope)).length,
71
+ releaseNotes: top.filter(isReleaseNotes).length,
72
+ returned: top.length,
73
+ top: top.map((result, index) => ({
74
+ rank: index + 1,
75
+ grade: gains[index],
76
+ product: result.product,
77
+ title: result.title,
78
+ url: result.url || "",
79
+ uri: result.uri
80
+ }))
81
+ };
82
+ }
83
+
84
+ function mean(values) {
85
+ const present = values.filter((value) => value !== null && value !== undefined);
86
+ if (present.length === 0) return null;
87
+ return present.reduce((sum, value) => sum + value, 0) / present.length;
88
+ }
89
+
90
+ function summarize(caseScores, firstPass, warmPass) {
91
+ const totalReturned = caseScores.reduce((sum, item) => sum + item.returned, 0) || 1;
92
+ const required = caseScores.filter((item) => item.requiredHit !== null);
93
+ return {
94
+ cases: caseScores.length,
95
+ gapCases: caseScores.filter((item) => item.gap).length,
96
+ recallAtK: mean(caseScores.map((item) => item.recall)),
97
+ mrr: mean(caseScores.map((item) => item.mrr)),
98
+ ndcgAtK: mean(caseScores.map((item) => item.ndcg)),
99
+ requiredHitRate: required.length ? required.filter((item) => item.requiredHit).length / required.length : null,
100
+ duplicateRate: caseScores.reduce((sum, item) => sum + item.duplicates, 0) / totalReturned,
101
+ offScopeRate: caseScores.reduce((sum, item) => sum + item.offScope, 0) / totalReturned,
102
+ releaseNotesRate: caseScores.reduce((sum, item) => sum + item.releaseNotes, 0) / totalReturned,
103
+ // First pass pays index loading and per-scope index builds; the warm pass
104
+ // repeats the same queries, so it reflects steady-state search cost.
105
+ latencyMs: {
106
+ firstQuery: firstPass[0] ?? 0,
107
+ p50FirstPass: percentile(firstPass, 50),
108
+ p95FirstPass: percentile(firstPass, 95),
109
+ p50Warm: percentile(warmPass, 50),
110
+ p95Warm: percentile(warmPass, 95)
111
+ }
112
+ };
113
+ }
114
+
115
+ async function runRetrievalEval({ searchResources, cases, k = 5, isInScope }) {
116
+ const caseScores = [];
117
+ const firstPass = [];
118
+ const warmPass = [];
119
+ for (const testCase of cases) {
120
+ const startedAt = performance.now();
121
+ const results = await searchResources({ query: testCase.query, ...(testCase.filters || {}), limit: k, throwOnError: true });
122
+ const latencyMs = performance.now() - startedAt;
123
+ firstPass.push(latencyMs);
124
+ caseScores.push({ ...scoreCase(testCase, results, k, isInScope), latencyMs });
125
+ }
126
+ for (const testCase of cases) {
127
+ const startedAt = performance.now();
128
+ await searchResources({ query: testCase.query, ...(testCase.filters || {}), limit: k, throwOnError: true });
129
+ warmPass.push(performance.now() - startedAt);
130
+ }
131
+ return { k, summary: summarize(caseScores, firstPass, warmPass), cases: caseScores };
132
+ }
133
+
134
+ export { normalizeUrl, scoreCase, summarize, runRetrievalEval };
@@ -0,0 +1,17 @@
1
+ // Scope check for retrieval evaluation, using the same normalizers and
2
+ // matchers the search path applies to filters.
3
+ import {
4
+ normalizeProduct,
5
+ normalizePlatform,
6
+ normalizeEdition,
7
+ editionMatches,
8
+ platformMatches
9
+ } from "../../src/server/resource-index.js";
10
+ import { normalizeSearchFilters, entryMatchesScope } from "../../src/rag/search-utils.js";
11
+
12
+ function isResultInScope(result, rawFilters) {
13
+ const filters = normalizeSearchFilters(rawFilters, { normalizeProduct, normalizePlatform, normalizeEdition });
14
+ return entryMatchesScope(result, filters, { editionMatches, platformMatches });
15
+ }
16
+
17
+ export { isResultInScope };
@@ -32,6 +32,8 @@ if (!existsSync(cacheDir)) {
32
32
  throw new Error(`RAG cache directory not found after prebuild: ${cacheDir}`);
33
33
  }
34
34
 
35
+ // Each cache is a JSON header plus a sibling `.f32` vector file (see
36
+ // src/rag/vector-cache.js); both ship in the release archive.
35
37
  const cacheFiles = readdirSync(cacheDir, { withFileTypes: true })
36
38
  .filter((entry) => entry.isFile())
37
39
  .map((entry) => join(cacheDir, entry.name))
@@ -48,6 +50,12 @@ if (cacheFiles.length === 0) {
48
50
  throw new Error(`No cache JSON files found in ${cacheDir}.`);
49
51
  }
50
52
 
53
+ const vectorFiles = cacheFiles.map((path) => path.replace(/\.json$/i, ".f32"));
54
+ const missingVectors = vectorFiles.filter((path) => !existsSync(path));
55
+ if (missingVectors.length > 0) {
56
+ throw new Error(`Vector files missing for cache headers: ${missingVectors.map((path) => basename(path)).join(", ")}`);
57
+ }
58
+
51
59
  let indexSignature = "";
52
60
  let indexCacheKey = "";
53
61
  const geminiCacheFile = cacheFiles.find((path) => /rag-gemini-.*\.json$/i.test(path));
@@ -69,7 +77,7 @@ const manifest = {
69
77
  indexSignature,
70
78
  indexCacheKey,
71
79
  cacheDir: toPosixPath(cacheDir),
72
- files: cacheFiles.map((path) => {
80
+ files: [...cacheFiles, ...vectorFiles].sort().map((path) => {
73
81
  const stats = statSync(path);
74
82
  return {
75
83
  name: toPosixPath(path.slice(cacheDir.length + 1)),
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { resourceIndex, readResourceContent } from "../src/server/resource-index.js";
3
+ import { ensureResourceIndexReady, resourceIndex, readResourceContent } from "../src/server/resource-index.js";
4
4
 
5
5
  function parsePositiveInt(value, fallback) {
6
6
  const parsed = Number.parseInt(String(value ?? ""), 10);
@@ -9,6 +9,7 @@ function parsePositiveInt(value, fallback) {
9
9
  }
10
10
 
11
11
  async function main() {
12
+ ensureResourceIndexReady();
12
13
  const concurrency = parsePositiveInt(process.env.DOC_VERIFY_CONCURRENCY, 8);
13
14
  const docs = resourceIndex.filter((entry) => entry.type === "doc");
14
15
  const total = docs.length;
@@ -3,14 +3,33 @@ import { getHydrationProduct } from "../server/public-offerings.js";
3
3
 
4
4
  const REPO_MAP = {
5
5
  dbr: {
6
+ // DBR docs index the Capture Vision pages they link to (shared APIs such as
7
+ // CaptureVisionRouter), so each DBR edition also needs the matching DCV
8
+ // edition repo plus DCV core.
6
9
  docs: {
7
- web: ["documentation/barcode-reader-docs-js"],
8
- mobile: ["documentation/barcode-reader-docs-mobile"],
9
- server: ["documentation/barcode-reader-docs-server"],
10
+ web: [
11
+ "documentation/barcode-reader-docs-js",
12
+ "documentation/capture-vision-docs-js",
13
+ "documentation/capture-vision-docs"
14
+ ],
15
+ mobile: [
16
+ "documentation/barcode-reader-docs-mobile",
17
+ "documentation/capture-vision-docs-mobile",
18
+ "documentation/capture-vision-docs"
19
+ ],
20
+ server: [
21
+ "documentation/barcode-reader-docs-server",
22
+ "documentation/capture-vision-docs-server",
23
+ "documentation/capture-vision-docs"
24
+ ],
10
25
  any: [
11
26
  "documentation/barcode-reader-docs-js",
12
27
  "documentation/barcode-reader-docs-mobile",
13
- "documentation/barcode-reader-docs-server"
28
+ "documentation/barcode-reader-docs-server",
29
+ "documentation/capture-vision-docs",
30
+ "documentation/capture-vision-docs-js",
31
+ "documentation/capture-vision-docs-mobile",
32
+ "documentation/capture-vision-docs-server"
14
33
  ]
15
34
  },
16
35
  samples: {
package/src/rag/config.js CHANGED
@@ -63,12 +63,10 @@ const ragConfig = {
63
63
  modelCacheDir: readEnvValue("RAG_MODEL_CACHE_DIR", join(dataRoot, ".rag-cache", "models")),
64
64
  chunkSize: readIntEnv("RAG_CHUNK_SIZE", 1200),
65
65
  chunkOverlap: readIntEnv("RAG_CHUNK_OVERLAP", 200),
66
- // Keep the total embedded-chunk count within V8's ~512MB max-string limit,
67
- // which the prebuilt Gemini cache hits via a single JSON.stringify of ~3072-dim
68
- // vectors. At 24 the release prebuild overflowed ("Invalid string length"); 6 is
69
- // the count the pre-v7.4 release built successfully on this data. Raising this
70
- // needs the ceiling-proof cache format tracked in the v7.5 follow-ups.
71
- maxChunksPerDoc: readIntEnv("RAG_MAX_CHUNKS_PER_DOC", 6),
66
+ // 0 = embed every chunk of every doc (~19.7k chunks vs ~10.3k at the old cap
67
+ // of 6). Each chunk is one Gemini embedding per release prebuild; the vector
68
+ // cache is header + float32 (src/rag/vector-cache.js), so there is no size cap.
69
+ maxChunksPerDoc: readIntEnv("RAG_MAX_CHUNKS_PER_DOC", 0),
72
70
  maxTextChars: readIntEnv("RAG_MAX_TEXT_CHARS", 4000),
73
71
  minScore: readFloatEnv("RAG_MIN_SCORE", 0.2),
74
72
  includeScore: readBoolEnv("RAG_INCLUDE_SCORE", false),
package/src/rag/index.js CHANGED
@@ -102,7 +102,22 @@ function refreshRagIndexes() {
102
102
  logRag(`indexes refreshed resources=${resourceIndex.length}`);
103
103
  }
104
104
 
105
- async function searchResources({ query, product, edition, platform, type, limit }) {
105
+ function dedupeByPublicUrl(results, limit) {
106
+ const seen = new Set();
107
+ const unique = [];
108
+ for (const entry of results) {
109
+ const key = entry.url || entry.uri;
110
+ if (seen.has(key)) continue;
111
+ seen.add(key);
112
+ unique.push(entry);
113
+ if (limit && unique.length >= limit) break;
114
+ }
115
+ return unique;
116
+ }
117
+
118
+ // `throwOnError` makes an all-providers failure throw instead of returning [];
119
+ // evaluation uses it so a broken provider cannot pass as zero relevance.
120
+ async function searchResources({ query, product, edition, platform, type, limit, throwOnError = false }) {
106
121
  const startedAt = Date.now();
107
122
  const filters = normalizeSearchFilters({ product, edition, platform, type }, {
108
123
  normalizeProduct,
@@ -133,7 +148,11 @@ async function searchResources({ query, product, edition, platform, type, limit
133
148
  for (const name of providers) {
134
149
  try {
135
150
  const provider = await providerOrchestrator.loadSearchProvider(name);
136
- const results = await provider.search(searchQuery, filters, maxResults);
151
+ // Shared pages (e.g. Capture Vision core reference) are indexed once per
152
+ // DBR edition, so an edition-less search can hit the same public page
153
+ // several times. Over-fetch, then keep the best hit per public URL.
154
+ const candidates = await provider.search(searchQuery, filters, maxResults ? maxResults * 3 : undefined);
155
+ const results = dedupeByPublicUrl(candidates, maxResults);
137
156
  if (!ragLogState.providerFirstUse.has(name)) {
138
157
  ragLogState.providerFirstUse.add(name);
139
158
  logRag("provider_selected", {
@@ -191,6 +210,7 @@ async function searchResources({ query, product, edition, platform, type, limit
191
210
  latency_bucket: latencyBucket(elapsedMs),
192
211
  error: lastError.message
193
212
  }, { level: "error" });
213
+ if (throwOnError) throw lastError;
194
214
  }
195
215
  return [];
196
216
  }
@@ -1,5 +1,5 @@
1
1
  import Fuse from "fuse.js";
2
- import { expandQueryTokens, extractSnippet } from "./search-utils.js";
2
+ import { chunkMarkdown, expandQueryTokens, extractSnippet } from "./search-utils.js";
3
3
 
4
4
  const DEFAULT_FUSE_OPTIONS = {
5
5
  keys: [
@@ -12,27 +12,40 @@ const DEFAULT_FUSE_OPTIONS = {
12
12
  includeScore: true
13
13
  };
14
14
 
15
- const BODY_INDEX_CHARS = 4000;
15
+ // Section size and overlap for body scoring; match the embedding chunking so
16
+ // lexical and Gemini see the same units, and so a term cut at a chunk boundary
17
+ // still appears whole in the neighbouring chunk.
18
+ const SECTION_CHARS = 1200;
19
+ const SECTION_OVERLAP_CHARS = 200;
16
20
 
17
- // Field-weighted BM25 haystack: title/tags outrank summary; doc/sample body text
18
- // is indexed at low weight so body-only content is findable; the URI's last slug
21
+ // Field-weighted header: title/tags outrank summary; the URI's last slug
19
22
  // segment is kept (it is often the most descriptive token) while scheme/version
20
23
  // noise is dropped (issue #147).
21
- function buildLexicalHaystack(entry) {
24
+ function buildHeaderText(entry) {
22
25
  const title = entry.title || "";
23
26
  const tags = Array.isArray(entry.tags) ? entry.tags.join(" ") : "";
24
27
  const summary = entry.summary || "";
25
- const body = entry.embedText ? String(entry.embedText).slice(0, BODY_INDEX_CHARS) : "";
26
28
  const uriSlug = String(entry.uri || "").split("/").filter(Boolean).pop() || "";
27
- return [
28
- title, title, title,
29
- tags, tags,
30
- summary,
31
- uriSlug,
32
- body
33
- ].join(" \n ");
29
+ return [title, title, title, tags, tags, summary, uriSlug].join(" \n ");
34
30
  }
35
31
 
32
+ // The whole body, split on headings (each section keeps its heading path), so
33
+ // content deep in long guides is searchable and a page is ranked by its best
34
+ // matching section instead of by one length-diluted blob.
35
+ function buildBodySections(entry) {
36
+ if (!entry.embedText) return [];
37
+ return chunkMarkdown(entry.embedText, SECTION_CHARS, SECTION_OVERLAP_CHARS, 0);
38
+ }
39
+
40
+ const MAX_SCOPED_FUSE_INDEXES = 64;
41
+
42
+ // Fuse matches long natural-language questions only through fragments of the
43
+ // pattern, giving a weak, noisy signal at a cost of seconds on large scopes.
44
+ // On the golden set (scripts/eval-retrieval.mjs), skipping it above this length
45
+ // kept recall, raised MRR, and cut warm p95 from ~2.2s to ~0.2s. BM25 carries
46
+ // long queries.
47
+ const MAX_FUSE_QUERY_CHARS = 64;
48
+
36
49
  const BM25_K1 = 1.2;
37
50
  const BM25_B = 0.75;
38
51
 
@@ -61,63 +74,86 @@ function compareLexicalResults(a, b) {
61
74
  return String(a.entry.uri || "").localeCompare(String(b.entry.uri || ""));
62
75
  }
63
76
 
64
- function buildBm25Index(entries) {
65
- const documents = [];
77
+ function buildBm25Stats(units) {
66
78
  const documentFrequency = new Map();
67
79
  let totalLength = 0;
68
-
69
- entries.forEach((entry, index) => {
70
- const haystack = buildLexicalHaystack(entry);
71
-
72
- const tokens = tokenize(haystack);
73
- const termFreq = new Map();
74
- tokens.forEach((token) => {
75
- termFreq.set(token, (termFreq.get(token) || 0) + 1);
76
- });
77
-
78
- const seen = new Set();
79
- for (const token of tokens) {
80
- if (seen.has(token)) continue;
81
- seen.add(token);
80
+ for (const unit of units) {
81
+ unit.termFreq = new Map();
82
+ for (const token of unit.tokens) {
83
+ unit.termFreq.set(token, (unit.termFreq.get(token) || 0) + 1);
84
+ }
85
+ for (const token of unit.termFreq.keys()) {
82
86
  documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
83
87
  }
88
+ unit.length = unit.tokens.length;
89
+ totalLength += unit.length;
90
+ delete unit.tokens;
91
+ }
92
+ return {
93
+ documentFrequency,
94
+ totalDocuments: units.length,
95
+ avgLength: units.length > 0 ? totalLength / units.length : 0
96
+ };
97
+ }
84
98
 
85
- totalLength += tokens.length;
86
- documents.push({
87
- index,
88
- entry,
89
- length: tokens.length,
90
- termFreq,
91
- snippetText: entry.embedText || entry.summary || entry.title || ""
92
- });
93
- });
94
-
95
- const avgLength = documents.length > 0 ? totalLength / documents.length : 0;
99
+ // Two BM25 indexes: one header unit per entry, one unit per body section.
100
+ // A page scores header + best section.
101
+ function buildBm25Index(entries) {
102
+ const documents = entries.map((entry, index) => ({
103
+ index,
104
+ entry,
105
+ tokens: tokenize(buildHeaderText(entry)),
106
+ sections: [],
107
+ fallbackSnippet: entry.summary || entry.title || ""
108
+ }));
109
+ const sections = [];
110
+ for (const doc of documents) {
111
+ for (const text of buildBodySections(doc.entry)) {
112
+ const section = { text, tokens: tokenize(text) };
113
+ doc.sections.push(section);
114
+ sections.push(section);
115
+ }
116
+ }
96
117
  return {
97
118
  documents,
98
- documentFrequency,
99
- totalDocuments: documents.length,
100
- avgLength
119
+ headerStats: buildBm25Stats(documents),
120
+ sectionStats: buildBm25Stats(sections)
101
121
  };
102
122
  }
103
123
 
104
- function computeBm25Score(indexState, docState, terms) {
124
+ function computeBm25Score(stats, unit, terms) {
105
125
  if (terms.length === 0) return 0;
106
- if (!docState.length || !indexState.avgLength) return 0;
126
+ if (!unit.length || !stats.avgLength) return 0;
107
127
 
108
128
  let score = 0;
109
129
  for (const term of terms) {
110
- const tf = docState.termFreq.get(term) || 0;
130
+ const tf = unit.termFreq.get(term) || 0;
111
131
  if (!tf) continue;
112
- const df = indexState.documentFrequency.get(term) || 0;
113
- const idf = Math.log(1 + (indexState.totalDocuments - df + 0.5) / (df + 0.5));
114
- const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (docState.length / indexState.avgLength));
132
+ const df = stats.documentFrequency.get(term) || 0;
133
+ const idf = Math.log(1 + (stats.totalDocuments - df + 0.5) / (df + 0.5));
134
+ const denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (unit.length / stats.avgLength));
115
135
  score += idf * ((tf * (BM25_K1 + 1)) / denom);
116
136
  }
117
137
 
118
138
  return score;
119
139
  }
120
140
 
141
+ function scoreDocument(index, doc, terms) {
142
+ let best = null;
143
+ let bestScore = 0;
144
+ for (const section of doc.sections) {
145
+ const score = computeBm25Score(index.sectionStats, section, terms);
146
+ if (score > bestScore) {
147
+ bestScore = score;
148
+ best = section;
149
+ }
150
+ }
151
+ return {
152
+ score: computeBm25Score(index.headerStats, doc, terms) + bestScore,
153
+ snippetText: best ? best.text : doc.fallbackSnippet
154
+ };
155
+ }
156
+
121
157
  function createLexicalProvider({
122
158
  entries,
123
159
  entryMatchesScope,
@@ -126,10 +162,24 @@ function createLexicalProvider({
126
162
  bm25Weight = 0.7,
127
163
  fuseWeight = 0.3
128
164
  }) {
129
- const fuse = new Fuse(entries, fuseOptions);
130
165
  const bm25Index = buildBm25Index(entries);
131
166
  const entryByUri = new Map(entries.map((entry) => [entry.uri, entry]));
132
- const snippetTextByUri = new Map(bm25Index.documents.map((doc) => [doc.entry.uri, doc.snippetText]));
167
+ // Fuse cost grows with the number of entries scanned, and its hits were
168
+ // filtered by scope only afterwards. One Fuse index per scope keeps scoped
169
+ // searches proportional to that scope; the scope set is small and bounded.
170
+ const fuseByScope = new Map();
171
+ const getScopedFuse = (filters) => {
172
+ const key = [filters?.product, filters?.edition, filters?.platform, filters?.type].map((v) => v || "").join("|");
173
+ let scoped = fuseByScope.get(key);
174
+ if (!scoped) {
175
+ scoped = new Fuse(entries.filter((entry) => entryMatchesScope(entry, filters || {})), fuseOptions);
176
+ if (fuseByScope.size >= MAX_SCOPED_FUSE_INDEXES) {
177
+ fuseByScope.delete(fuseByScope.keys().next().value);
178
+ }
179
+ fuseByScope.set(key, scoped);
180
+ }
181
+ return scoped;
182
+ };
133
183
 
134
184
  return {
135
185
  name: "lexical",
@@ -137,7 +187,7 @@ function createLexicalProvider({
137
187
  // Expand with domain synonyms/stems so vocabulary gaps (webcam/camera,
138
188
  // datamatrix/data matrix, scanning/scan) do not zero the BM25 component.
139
189
  const terms = expandQueryTokens([...new Set(tokenize(query))]);
140
- const fuseHits = fuse.search(query);
190
+ const fuseHits = query.length <= MAX_FUSE_QUERY_CHARS ? getScopedFuse(filters).search(query) : [];
141
191
  const fuseScoreByUri = new Map();
142
192
 
143
193
  for (const hit of fuseHits) {
@@ -154,9 +204,11 @@ function createLexicalProvider({
154
204
  // out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
155
205
  let maxFuse = 0;
156
206
 
207
+ const snippetTextByUri = new Map();
157
208
  for (const doc of bm25Index.documents) {
158
209
  if (!entryMatchesScope(doc.entry, filters)) continue;
159
- const score = computeBm25Score(bm25Index, doc, terms);
210
+ const { score, snippetText } = scoreDocument(bm25Index, doc, terms);
211
+ snippetTextByUri.set(doc.entry.uri, snippetText);
160
212
  if (score > 0) {
161
213
  bm25ScoreByUri.set(doc.entry.uri, score);
162
214
  if (score > maxBm25) maxBm25 = score;