feat: enhance local knowledge retrieval

This commit is contained in:
lofyer
2026-08-12 21:45:47 +08:00
parent e0e5a8c1b3
commit 111f487e20
65 changed files with 13517 additions and 1155 deletions
@@ -0,0 +1,443 @@
{
"version": 1,
"id": "synthetic-bilingual-v1",
"provenance": {
"kind": "synthetic",
"license": "CC0-1.0"
},
"documents": [
{
"id": "doc-secrets",
"title": "Credential storage",
"chunks": [
{
"id": "chunk-secrets-main",
"content": "API tokens and service keys stay in the main process encrypted settings store. The renderer never receives the credential value."
}
]
},
{
"id": "doc-secrets-secondary",
"title": "凭据轮换",
"chunks": [
{
"id": "chunk-secrets-secondary",
"content": "轮换访问凭据后,应立即撤销旧令牌,并确认新密钥只保存在加密存储中。"
}
]
},
{
"id": "doc-offline",
"title": "Offline operation",
"chunks": [
{
"id": "chunk-offline-main",
"content": "Offline search uses the local SQLite index and does not require a network connection. Local files remain on the device."
}
]
},
{
"id": "doc-cancel",
"title": "任务取消",
"chunks": [
{
"id": "chunk-cancel-main",
"content": "用户取消索引任务时,系统会中止当前批次,保留旧索引,并将任务标记为已取消。"
}
]
},
{
"id": "doc-backup",
"title": "Backup and restore",
"chunks": [
{
"id": "chunk-backup-main",
"content": "Create a backup before migration. To restore, close the application and replace the database with the verified backup copy."
}
]
},
{
"id": "doc-accessibility",
"title": "键盘导航",
"chunks": [
{
"id": "chunk-accessibility-main",
"content": "分段控件支持键盘导航:左右方向键移动焦点,Tab 键离开控件,当前选项使用清晰的焦点样式。"
}
]
},
{
"id": "doc-image",
"title": "Image handling",
"chunks": [
{
"id": "chunk-image-main",
"content": "Generated images are accepted only as bounded inline image data. Provider-returned image URLs are not fetched."
}
]
},
{
"id": "doc-sqlite",
"title": "数据库更新",
"chunks": [
{
"id": "chunk-sqlite-main",
"content": "SQLite 数据迁移在事务中执行;任一步骤失败都会回滚,以保护现有用户数据。"
}
]
},
{
"id": "doc-runtime",
"title": "Runtime modes",
"chunks": [
{
"id": "chunk-runtime-main",
"content": "Ask mode is read-only at the runtime boundary. Execute mode can use tools only after the configured approval check."
}
]
},
{
"id": "doc-fonts",
"title": "界面字体",
"chunks": [
{
"id": "chunk-fonts-main",
"content": "界面使用随应用打包的 Inter Variable 和 Noto Sans SC Variable 字体,不发起远程字体请求。"
}
]
},
{
"id": "doc-release",
"title": "Release verification",
"chunks": [
{
"id": "chunk-release-main",
"content": "Each release package includes a release manifest containing SHA-256 hashes for the packaged artifacts."
}
]
},
{
"id": "doc-chunking",
"title": "上下文分块",
"chunks": [
{
"id": "chunk-chunking-main",
"content": "检索结果可加入相邻分块来补充上下文,但合并后的内容必须遵守字符预算。"
}
]
},
{
"id": "doc-language",
"title": "Interface language",
"chunks": [
{
"id": "chunk-language-main",
"content": "The interface supports Simplified Chinese and English. Release notes are shown in the current interface language."
}
]
},
{
"id": "doc-approval-secondary",
"title": "审批记录",
"chunks": [
{
"id": "chunk-approval-secondary",
"content": "执行敏感工具前应显示审批提示,并记录本次允许的操作范围。"
}
]
},
{
"id": "doc-manifest-secondary",
"title": "包清单检查",
"chunks": [
{
"id": "chunk-manifest-secondary",
"content": "发布检查会核对包清单,但测试构建可以跳过签名步骤。"
}
]
}
],
"queries": [
{
"id": "query-secrets-en",
"language": "en",
"query": "Where should API tokens be stored?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-secrets-main",
"relevance": 3,
"spans": [
{
"text": "API tokens and service keys stay in the main process encrypted settings store."
}
]
},
{
"chunkId": "chunk-secrets-secondary",
"relevance": 1,
"spans": [
{
"text": "新密钥只保存在加密存储中"
}
]
}
]
},
{
"id": "query-secrets-zh",
"language": "zh-CN",
"query": "怎样安全保存访问凭据",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-secrets-main",
"relevance": 2,
"spans": [
{
"text": "main process encrypted settings store"
}
]
},
{
"chunkId": "chunk-secrets-secondary",
"relevance": 3,
"spans": [
{
"text": "新密钥只保存在加密存储中"
}
]
}
]
},
{
"id": "query-offline-zh",
"language": "zh-CN",
"query": "没有网络时能否检索本地资料",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-offline-main",
"relevance": 3,
"spans": [
{
"text": "Offline search uses the local SQLite index and does not require a network connection."
}
]
}
]
},
{
"id": "query-cancel-en",
"language": "en",
"query": "What happens when indexing is aborted?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-cancel-main",
"relevance": 3,
"spans": [
{
"text": "系统会中止当前批次,保留旧索引"
}
]
}
]
},
{
"id": "query-backup-en",
"language": "en",
"query": "How do I restore the database backup?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-backup-main",
"relevance": 3,
"spans": [
{
"text": "To restore, close the application and replace the database with the verified backup copy."
}
]
}
]
},
{
"id": "query-keyboard-en",
"language": "en",
"query": "How does focus move inside a segmented control?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-accessibility-main",
"relevance": 3,
"spans": [
{
"text": "左右方向键移动焦点"
}
]
}
]
},
{
"id": "query-image-zh",
"language": "zh-CN",
"query": "模型生成的图片数据如何接收",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-image-main",
"relevance": 3,
"spans": [
{
"text": "accepted only as bounded inline image data"
}
]
}
]
},
{
"id": "query-sqlite-en",
"language": "en",
"query": "How are failed database migrations protected?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-sqlite-main",
"relevance": 3,
"spans": [
{
"text": "任一步骤失败都会回滚"
}
]
}
]
},
{
"id": "query-runtime-zh",
"language": "zh-CN",
"query": "询问模式是否允许写入,工具何时需要审批",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-runtime-main",
"relevance": 3,
"spans": [
{
"text": "Ask mode is read-only at the runtime boundary."
},
{
"text": "Execute mode can use tools only after the configured approval check."
}
]
},
{
"chunkId": "chunk-approval-secondary",
"relevance": 1,
"spans": [
{
"text": "执行敏感工具前应显示审批提示"
}
]
}
]
},
{
"id": "query-font-en",
"language": "en",
"query": "Are interface typefaces downloaded remotely?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-fonts-main",
"relevance": 3,
"spans": [
{
"text": "不发起远程字体请求"
}
]
}
]
},
{
"id": "query-release-zh",
"language": "zh-CN",
"query": "发布产物怎样校验完整性",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-release-main",
"relevance": 3,
"spans": [
{
"text": "release manifest containing SHA-256 hashes"
}
]
},
{
"chunkId": "chunk-manifest-secondary",
"relevance": 1,
"spans": [
{
"text": "发布检查会核对包清单"
}
]
}
]
},
{
"id": "query-chunk-en",
"language": "en",
"query": "Can neighboring sections be added to retrieved context?",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-chunking-main",
"relevance": 3,
"spans": [
{
"text": "检索结果可加入相邻分块来补充上下文"
}
]
}
]
},
{
"id": "query-language-zh",
"language": "zh-CN",
"query": "界面提供哪些语言",
"noAnswer": false,
"judgments": [
{
"chunkId": "chunk-language-main",
"relevance": 3,
"spans": [
{
"text": "supports Simplified Chinese and English"
}
]
}
]
},
{
"id": "query-noanswer-weather",
"language": "zh-CN",
"query": "明天火星天气预报",
"noAnswer": true,
"judgments": []
},
{
"id": "query-noanswer-payroll",
"language": "en",
"query": "employee payroll tax withholding schedule",
"noAnswer": true,
"judgments": []
},
{
"id": "query-noanswer-recipe",
"language": "en",
"query": "sourdough cinnamon recipe temperature",
"noAnswer": true,
"judgments": []
}
]
}
@@ -0,0 +1,155 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
createDeterministicTokenHashEmbeddingProvider,
deterministicReportProjection,
loadRetrievalFixture,
runRetrievalEvaluation
} from './support/knowledge-retrieval-evaluation'
describe('knowledge retrieval evaluation', () => {
it('evaluates production retrieval ablations offline, privately, and deterministically', async () => {
const fixture = await loadRetrievalFixture()
expect(fixture.provenance.kind).toBe('synthetic')
expect(fixture.queries.filter((query) => !query.noAnswer).length).toBeGreaterThanOrEqual(12)
expect(new Set(fixture.queries.map((query) => query.language))).toEqual(
new Set(['en', 'zh-CN'])
)
const first = await runRetrievalEvaluation()
const second = await runRetrievalEvaluation()
expect(deterministicReportProjection(second)).toEqual(
deterministicReportProjection(first)
)
const byId = new Map(first.ablations.map((ablation) => [ablation.id, ablation]))
const lexical = byId.get('lexical')!
const tokenHash = byId.get('token-hash-vector')!
const regressionVector = byId.get('regression-alias-vector')!
const hybrid = byId.get('hybrid')!
const rerank = byId.get('hybrid-rerank')!
expect(first.corpusHash).toMatch(/^[a-f0-9]{64}$/u)
expect(first.evaluationDefinitionHash).toMatch(/^[a-f0-9]{64}$/u)
expect(first.providerFingerprintHash).toMatch(/^[a-f0-9]{64}$/u)
expect(first.evaluationDefinitionHash).not.toBe(first.corpusHash)
expect(lexical.metrics.recallAt10).toBeGreaterThanOrEqual(0.2)
expect(lexical.metrics.contextRecall).toBeGreaterThanOrEqual(0.2)
expect(tokenHash.metrics.recallAt10).toBeGreaterThanOrEqual(0.15)
expect(regressionVector.metrics.recallAt10).toBeGreaterThanOrEqual(0.9)
expect(hybrid.metrics.recallAt5).toBeGreaterThanOrEqual(0.9)
expect(rerank.metrics.mrrAt10).toBeGreaterThanOrEqual(0.78)
expect(rerank.metrics.ndcgAt10).toBeGreaterThanOrEqual(0.75)
expect(rerank.metrics.contextPrecision).toBeGreaterThanOrEqual(0.05)
expect(rerank.metrics.contextRecall).toBeGreaterThanOrEqual(0.9)
expect(rerank.metrics.noAnswerFalsePositiveRate).toBeLessThanOrEqual(0.34)
for (const language of ['en', 'zh-CN'] as const) {
expect(rerank.metricsByLanguage[language].recallAt10).toBeGreaterThanOrEqual(0.9)
expect(rerank.metricsByLanguage[language].contextRecall).toBeGreaterThanOrEqual(0.85)
}
expect(
rerank.metrics.ndcgAt10,
'local rerank should not materially regress hybrid ranking quality'
).toBeGreaterThanOrEqual(hybrid.metrics.ndcgAt10 - 0.05)
expect(rerank.failures, JSON.stringify(rerank.failures)).toEqual([])
const serialized = JSON.stringify(first)
expect(Object.keys(first).sort()).toEqual([
'ablations',
'corpusHash',
'evaluationDefinitionHash',
'fixtureId',
'providerFingerprintHash',
'queryIds',
'schemaVersion'
])
for (const ablation of first.ablations) {
expect(Object.keys(ablation).sort()).toEqual([
'failures',
'id',
'latencyMs',
'metrics',
'metricsByLanguage'
])
expect(Object.keys(ablation.metrics).sort()).toEqual([
'contextPrecision',
'contextRecall',
'mrrAt10',
'ndcgAt10',
'noAnswerFalsePositiveRate',
'recallAt10',
'recallAt5'
])
}
const forbiddenReportValues = [
...fixture.documents.map((document) => document.title),
...fixture.documents.flatMap((document) =>
document.chunks.map((chunk) => chunk.content)
),
...fixture.queries.map((query) => query.query),
'fixture://',
'goodbuddy:retrieval-eval',
'handcrafted-alias-hash-v1',
'topic-agnostic-token-hash-v1'
]
for (const value of forbiddenReportValues) {
expect(serialized).not.toContain(value)
}
for (const document of fixture.documents) {
expect(serialized).not.toContain(document.id.replace(/^doc-/u, 'fixture://'))
}
expect(serialized).not.toMatch(/[a-z]:[\\/]/iu)
}, 30_000)
it('provides a deterministic topic-agnostic token-hash vectorizer', async () => {
const provider = createDeterministicTokenHashEmbeddingProvider()
const [first, second, unrelated] = await provider.embed([
'alpha beta alpha',
'beta alpha',
'凭据'
])
expect(first).toEqual(second)
expect(first).not.toEqual(unrelated)
expect(provider.model).toBe('topic-agnostic-token-hash-v1')
expect(provider.fingerprint).not.toContain('synthetic-bilingual')
})
it('writes only to non-symlink workspace-relative output paths', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-retrieval-output-'))
const workspace = join(root, 'workspace')
const outside = join(root, 'outside')
await mkdir(workspace)
await mkdir(outside)
try {
const outputPath = join('reports', 'retrieval.json')
const report = await runRetrievalEvaluation({
workingDirectory: workspace,
outputPath
})
const persisted = JSON.parse(
await readFile(join(workspace, outputPath), 'utf8')
) as { evaluationDefinitionHash: string }
expect(persisted.evaluationDefinitionHash).toBe(
report.evaluationDefinitionHash
)
await expect(runRetrievalEvaluation({
workingDirectory: workspace,
outputPath: join('..', 'outside.json')
})).rejects.toThrow(/workspace-relative/u)
const link = join(workspace, 'linked')
await symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir')
await expect(runRetrievalEvaluation({
workingDirectory: workspace,
outputPath: join('linked', 'escaped.json')
})).rejects.toThrow(/symlink/u)
} finally {
await rm(root, { recursive: true, force: true })
}
}, 30_000)
})
+211
View File
@@ -0,0 +1,211 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
computeEvaluationDefinitionHash,
computeRetrievalMetrics,
retrievalFixtureSchema,
summarizeLatencies,
type RetrievalFixture
} from './support/knowledge-retrieval-evaluation'
const metricFixture: RetrievalFixture = retrievalFixtureSchema.parse({
version: 1,
id: 'metric-fixture',
provenance: { kind: 'synthetic', license: 'CC0-1.0' },
documents: Array.from({ length: 10 }, (_, index) => ({
id: `document-${index}`,
title: `Document ${index}`,
chunks: [{
id: `chunk-${index}`,
content: `Synthetic content number ${index} with exact span ${index}.`
}]
})),
queries: [
{
id: 'query-answer-one',
language: 'en',
query: 'first synthetic question',
noAnswer: false,
judgments: [
{ chunkId: 'chunk-0', relevance: 3, spans: [{ text: 'exact span 0' }] },
{ chunkId: 'chunk-1', relevance: 1, spans: [{ text: 'exact span 1' }] }
]
},
{
id: 'query-answer-two',
language: 'zh-CN',
query: '第二个合成测试问题',
noAnswer: false,
judgments: [
{ chunkId: 'chunk-2', relevance: 2, spans: [{ text: 'exact span 2' }] }
]
},
...Array.from({ length: 9 }, (_, index) => ({
id: `query-padding-${index}`,
language: (index === 0 ? 'zh-CN' : 'en') as 'en' | 'zh-CN',
query: `padding synthetic query ${index}`,
noAnswer: true,
judgments: []
})),
{
id: 'query-no-answer',
language: 'en',
query: 'unanswerable synthetic question',
noAnswer: true,
judgments: []
}
]
})
describe('retrieval evaluation metrics', () => {
it('computes cutoffs, reciprocal rank, graded nDCG, exact-span context, and no-answer rate', () => {
const rankings = new Map([
[
'query-answer-one',
[
{ chunkId: 'chunk-9', context: 'irrelevant' },
{ chunkId: 'chunk-1', context: 'exact span 1' },
{ chunkId: 'chunk-0', context: 'prefix exact span 0 suffix' }
]
],
[
'query-answer-two',
[
{ chunkId: 'chunk-9', context: 'noise' },
{ chunkId: 'chunk-8', context: 'noise' },
{ chunkId: 'chunk-7', context: 'noise' },
{ chunkId: 'chunk-6', context: 'noise' },
{ chunkId: 'chunk-5', context: 'noise' },
{ chunkId: 'chunk-2', context: 'exact span 2' }
]
],
['query-no-answer', [{ chunkId: 'chunk-4', context: 'false positive' }]]
])
const metrics = computeRetrievalMetrics(metricFixture, rankings)
expect(metrics.recallAt5).toBeCloseTo(0.5)
expect(metrics.recallAt10).toBe(1)
expect(metrics.mrrAt10).toBeCloseTo((1 / 2 + 1 / 6) / 2)
const firstQueryDcg =
(2 ** 1 - 1) / Math.log2(3) +
(2 ** 3 - 1) / Math.log2(4)
const firstQueryIdeal =
(2 ** 3 - 1) / Math.log2(2) +
(2 ** 1 - 1) / Math.log2(3)
const secondQueryNdcg =
((2 ** 2 - 1) / Math.log2(7)) /
((2 ** 2 - 1) / Math.log2(2))
expect(metrics.ndcgAt10).toBeCloseTo(
(firstQueryDcg / firstQueryIdeal + secondQueryNdcg) / 2,
12
)
expect(metrics.contextRecall).toBe(1)
expect(metrics.contextPrecision).toBeCloseTo(36 / 85)
expect(metrics.noAnswerFalsePositiveRate).toBeCloseTo(0.1)
})
it('deduplicates rankings and unions overlapping evidence spans', () => {
const fixture = structuredClone(metricFixture)
fixture.queries[0]!.judgments[0]!.spans = [
{ text: 'exact span' },
{ text: 'span 0' },
{ text: 'exact span 0' }
]
const rankings = new Map([
[
'query-answer-one',
[
{ chunkId: 'chunk-0', context: 'exact span 0' },
{ chunkId: 'chunk-0', context: 'exact span 0' }
]
],
['query-answer-two', [{ chunkId: 'chunk-2', context: 'exact span 2' }]]
])
const metrics = computeRetrievalMetrics(fixture, rankings)
expect(metrics.recallAt5).toBeCloseTo(0.75)
expect(metrics.mrrAt10).toBe(1)
expect(metrics.ndcgAt10).toBeLessThanOrEqual(1)
expect(metrics.contextRecall).toBeCloseTo(2 / 3)
expect(metrics.contextPrecision).toBe(1)
})
it('uses deterministic nearest-rank latency summaries', () => {
expect(summarizeLatencies([9, 1, 5, 3, 7])).toEqual({
count: 5,
min: 1,
median: 5,
p95: 9,
max: 9,
mean: 5
})
expect(summarizeLatencies([])).toEqual({
count: 0,
min: 0,
median: 0,
p95: 0,
max: 0,
mean: 0
})
})
it('hashes fixture queries, judgments, settings, providers, and metric version', () => {
const baseline = computeEvaluationDefinitionHash(metricFixture)
const changedQuery = structuredClone(metricFixture)
changedQuery.queries[0]!.query = 'changed synthetic question'
const changedJudgment = structuredClone(metricFixture)
changedJudgment.queries[0]!.judgments[0]!.relevance = 2
expect(baseline).toMatch(/^[a-f0-9]{64}$/u)
expect(computeEvaluationDefinitionHash(changedQuery)).not.toBe(baseline)
expect(computeEvaluationDefinitionHash(changedJudgment)).not.toBe(baseline)
})
it('rejects unknown fields, unsafe provenance, and inexact spans', () => {
const unsafe = structuredClone(metricFixture) as unknown as {
documents: Array<{ chunks: Array<{ content: string }> }>
queries: Array<{ judgments: Array<{ spans: Array<{ text: string }> }> }>
endpoint?: string
}
unsafe.endpoint = 'https://example.invalid'
unsafe.queries[0]!.judgments[0]!.spans[0]!.text = 'not in corpus'
expect(() => retrievalFixtureSchema.parse(unsafe)).toThrow()
})
it('rejects all-answer, all-no-answer, and missing bilingual class coverage', () => {
const allAnswer = structuredClone(metricFixture)
allAnswer.queries = allAnswer.queries.map((query) => ({
...query,
noAnswer: false,
judgments: [{
chunkId: 'chunk-0',
relevance: 1,
spans: [{ text: 'exact span 0' }]
}]
}))
expect(() => retrievalFixtureSchema.parse(allAnswer)).toThrow(
/must include a no-answer query/u
)
const allNoAnswer = structuredClone(metricFixture)
allNoAnswer.queries = allNoAnswer.queries.map((query) => ({
...query,
noAnswer: true,
judgments: []
}))
expect(() => retrievalFixtureSchema.parse(allNoAnswer)).toThrow(
/must include an answerable query/u
)
const noChineseNoAnswer = structuredClone(metricFixture)
noChineseNoAnswer.queries = noChineseNoAnswer.queries.map((query) =>
query.noAnswer ? { ...query, language: 'en' as const } : query
)
expect(() => retrievalFixtureSchema.parse(noChineseNoAnswer)).toThrow(
/zh-CN: fixture must include a no-answer query/u
)
})
})
@@ -0,0 +1,780 @@
import { createHash } from 'node:crypto'
import {
lstat,
mkdtemp,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
import { z } from 'zod'
import { embeddingStorageProvider } from '../../src/main/knowledge/embedding-provider-key'
import { KnowledgeService } from '../../src/main/knowledge/knowledge-service'
import { knowledgeRetrievalTerms } from '../../src/main/knowledge/retrieval-text'
import { isPathInside } from '../../src/main/workspace-file-access'
import type { EmbeddingProvider } from '../../src/main/knowledge/types'
const stableIdSchema = z
.string()
.regex(/^[a-z][a-z0-9-]{2,63}$/u, 'must be a stable lowercase ID')
const spanSchema = z
.object({ text: z.string().min(2).max(500) })
.strict()
const judgmentSchema = z
.object({
chunkId: stableIdSchema,
relevance: z.number().int().min(1).max(3),
spans: z.array(spanSchema).min(1).max(8)
})
.strict()
const querySchema = z
.object({
id: stableIdSchema,
language: z.enum(['en', 'zh-CN']),
query: z.string().min(4).max(300),
noAnswer: z.boolean(),
judgments: z.array(judgmentSchema).max(10)
})
.strict()
const chunkSchema = z
.object({
id: stableIdSchema,
content: z.string().min(20).max(2_000)
})
.strict()
const documentSchema = z
.object({
id: stableIdSchema,
title: z.string().min(2).max(100),
chunks: z.array(chunkSchema).min(1).max(12)
})
.strict()
export const retrievalFixtureSchema = z
.object({
version: z.literal(1),
id: stableIdSchema,
provenance: z
.object({
kind: z.enum(['synthetic', 'public']),
license: z.string().min(2).max(64)
})
.strict(),
documents: z.array(documentSchema).min(10).max(100),
queries: z.array(querySchema).min(12).max(100)
})
.strict()
.superRefine((fixture, context) => {
const ids = new Set<string>()
const chunks = new Map<string, string>()
for (const document of fixture.documents) {
if (ids.has(document.id)) {
context.addIssue({ code: 'custom', message: `duplicate ID: ${document.id}` })
}
ids.add(document.id)
for (const chunk of document.chunks) {
if (ids.has(chunk.id)) {
context.addIssue({ code: 'custom', message: `duplicate ID: ${chunk.id}` })
}
ids.add(chunk.id)
chunks.set(chunk.id, chunk.content)
}
}
for (const query of fixture.queries) {
if (ids.has(query.id)) {
context.addIssue({ code: 'custom', message: `duplicate ID: ${query.id}` })
}
ids.add(query.id)
if (query.noAnswer !== (query.judgments.length === 0)) {
context.addIssue({
code: 'custom',
message: `${query.id}: noAnswer must exactly match empty judgments`
})
}
const judged = new Set<string>()
for (const judgment of query.judgments) {
const content = chunks.get(judgment.chunkId)
if (!content) {
context.addIssue({
code: 'custom',
message: `${query.id}: unknown chunk ${judgment.chunkId}`
})
continue
}
if (judged.has(judgment.chunkId)) {
context.addIssue({
code: 'custom',
message: `${query.id}: duplicate judgment ${judgment.chunkId}`
})
}
judged.add(judgment.chunkId)
for (const span of judgment.spans) {
if (!content.includes(span.text)) {
context.addIssue({
code: 'custom',
message: `${query.id}: annotated span is not exact`
})
}
}
}
}
for (const language of ['en', 'zh-CN'] as const) {
const languageQueries = fixture.queries.filter(
(query) => query.language === language
)
if (!languageQueries.some((query) => !query.noAnswer)) {
context.addIssue({
code: 'custom',
message: `${language}: fixture must include an answerable query`
})
}
if (!languageQueries.some((query) => query.noAnswer)) {
context.addIssue({
code: 'custom',
message: `${language}: fixture must include a no-answer query`
})
}
}
const forbidden = /(?:[a-z]:[\\/]|\/(?:users|home|var|etc)\/|https?:\/\/|api[_-]?key|bearer\s+[a-z0-9]|sk-[a-z0-9]{8})/iu
if (forbidden.test(JSON.stringify(fixture))) {
context.addIssue({
code: 'custom',
message: 'fixture contains a path, endpoint, or secret-like value'
})
}
})
export type RetrievalFixture = z.infer<typeof retrievalFixtureSchema>
export type RetrievalAblation =
| 'lexical'
| 'token-hash-vector'
| 'regression-alias-vector'
| 'hybrid'
| 'hybrid-rerank'
export interface RankedEvaluationItem {
chunkId: string
context?: string
}
export interface RetrievalMetrics {
recallAt5: number
recallAt10: number
mrrAt10: number
ndcgAt10: number
contextPrecision: number
contextRecall: number
noAnswerFalsePositiveRate: number
}
export interface RetrievalEvaluationReport {
schemaVersion: 1
fixtureId: string
corpusHash: string
evaluationDefinitionHash: string
providerFingerprintHash: string
queryIds: string[]
ablations: Array<{
id: RetrievalAblation
metrics: RetrievalMetrics
metricsByLanguage: Record<'en' | 'zh-CN', RetrievalMetrics>
latencyMs: {
count: number
min: number
median: number
p95: number
max: number
mean: number
}
failures: Array<{
queryId: string
reason: 'no-relevant-result-at-10' | 'no-answer-false-positive'
}>
}>
}
const fixturePath = fileURLToPath(
new URL('../fixtures/knowledge-retrieval/synthetic-bilingual-v1.json', import.meta.url)
)
const dimensions = 256
const metricVersion = 2
const retrievalSettings = {
version: 1 as const,
topK: 10,
minimumVectorSimilarity: 0.18,
graphWeight: 0,
candidateMultiplier: 4,
contextMaxCharacters: 16_000,
adjacentChunkCount: 0
}
const providerDefinitions = {
'regression-alias': {
fingerprint: 'goodbuddy:retrieval-eval:regression-alias:v1',
model: 'handcrafted-alias-hash-v1'
},
'token-hash': {
fingerprint: 'goodbuddy:retrieval-eval:topic-agnostic-token-hash:v1',
model: 'topic-agnostic-token-hash-v1'
}
} as const
type EvaluationProviderId = keyof typeof providerDefinitions
// The repository-wide setup targets jsdom. This owned config keeps the
// evaluation in Node and prevents that renderer setup from crossing boundaries.
export default defineConfig({
test: {
environment: 'node',
setupFiles: []
}
})
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
function canonicalFixtureCorpus(fixture: RetrievalFixture): string {
return fixture.documents
.flatMap((document) =>
document.chunks.map((chunk) => `${document.id}\0${chunk.id}\0${chunk.content}`)
)
.join('\n')
}
function normalizedTerms(text: string): string[] {
return knowledgeRetrievalTerms(text)
}
function tokenHashVector(text: string): number[] {
const vector: number[] = Array.from({ length: dimensions }, () => 0)
for (const term of normalizedTerms(text)) {
const digest = createHash('sha256').update(`token:${term}`).digest()
const bucket = digest.readUInt16LE(0) % dimensions
vector[bucket]! += digest[2]! % 2 === 0 ? 1 : -1
}
if (vector.every((value) => value === 0)) {
return [1, ...vector.slice(1)]
}
return vector
}
/*
* Regression plumbing only: these fixture-specific bilingual aliases make the
* production vector/hybrid path deterministic. They do not model embedding
* quality and must not be presented as a provider-quality benchmark.
*/
function regressionAliasHashVector(text: string): number[] {
const vector = tokenHashVector(text)
const aliases: Record<string, string[]> = {
credential: ['credential', 'credentials', 'token', 'tokens', 'key', 'keys', '凭据', '密钥', '令牌'],
offline: ['offline', 'network', 'connection', '本地', '网络', '离线'],
cancel: ['cancel', 'cancelled', 'aborted', 'indexing', '取消', '中止', '索引'],
backup: ['backup', 'restore', 'database', '备份', '恢复', '数据库'],
keyboard: ['keyboard', 'focus', 'segmented', 'arrow', '键盘', '焦点', '方向键', '分段控件'],
image: ['image', 'images', 'generated', 'inline', '图片', '生成', '内联'],
migration: ['migration', 'migrations', 'failed', 'rollback', '迁移', '失败', '回滚'],
approval: ['ask', 'execute', 'approval', 'read-only', '询问', '执行', '审批', '写入'],
font: ['font', 'fonts', 'typefaces', 'remote', '字体', '远程'],
integrity: ['release', 'artifact', 'manifest', 'hash', 'integrity', '发布', '产物', '完整性', '校验'],
context: ['context', 'neighboring', 'sections', 'chunks', '上下文', '相邻', '分块'],
language: ['language', 'languages', 'interface', 'english', 'chinese', '语言', '界面', '英文', '中文']
}
for (const [concept, variants] of Object.entries(aliases)) {
if (variants.some((variant) => text.toLowerCase().includes(variant))) {
const digest = createHash('sha256').update(`concept:${concept}`).digest()
const bucket = digest.readUInt16LE(0) % dimensions
vector[bucket]! += digest[2]! % 2 === 0 ? 12 : -12
}
}
return vector
}
function createEvaluationEmbeddingProvider(
providerId: EvaluationProviderId
): EmbeddingProvider {
const definition = providerDefinitions[providerId]
const vectorize =
providerId === 'token-hash' ? tokenHashVector : regressionAliasHashVector
return {
provider: 'retrieval-eval-memory',
model: definition.model,
fingerprint: definition.fingerprint,
embed: async (input, signal) => {
signal?.throwIfAborted()
return input.map(vectorize)
}
}
}
/** Topic-agnostic deterministic plumbing for lexical-overlap vector ablations. */
export function createDeterministicTokenHashEmbeddingProvider(): EmbeddingProvider {
return createEvaluationEmbeddingProvider('token-hash')
}
/** Fixture-aware regression plumbing; not an embedding-quality model. */
export function createRegressionAliasEmbeddingProvider(): EmbeddingProvider {
return createEvaluationEmbeddingProvider('regression-alias')
}
export async function loadRetrievalFixture(
path = fixturePath
): Promise<RetrievalFixture> {
const parsed: unknown = JSON.parse(await readFile(path, 'utf8'))
return retrievalFixtureSchema.parse(parsed)
}
function relevantAt(
query: RetrievalFixture['queries'][number],
ranked: readonly RankedEvaluationItem[],
limit: number
): number {
const relevant = new Set(query.judgments.map((judgment) => judgment.chunkId))
return ranked.slice(0, limit).filter((item) => relevant.has(item.chunkId)).length /
Math.max(1, relevant.size)
}
function dedupeRanking(
ranked: readonly RankedEvaluationItem[]
): RankedEvaluationItem[] {
const seen = new Set<string>()
return ranked.filter((item) => {
if (seen.has(item.chunkId)) {
return false
}
seen.add(item.chunkId)
return true
})
}
function unionRangeLength(ranges: Array<readonly [number, number]>): number {
const sorted = ranges
.filter(([start, end]) => end > start)
.sort(([leftStart, leftEnd], [rightStart, rightEnd]) =>
leftStart - rightStart || leftEnd - rightEnd
)
let total = 0
let currentStart = -1
let currentEnd = -1
for (const [start, end] of sorted) {
if (currentStart < 0) {
currentStart = start
currentEnd = end
} else if (start <= currentEnd) {
currentEnd = Math.max(currentEnd, end)
} else {
total += currentEnd - currentStart
currentStart = start
currentEnd = end
}
}
return currentStart < 0 ? 0 : total + currentEnd - currentStart
}
function spanRanges(
content: string,
spans: readonly { text: string }[]
): Array<readonly [number, number]> {
return spans.flatMap((span) => {
const start = content.indexOf(span.text)
return start < 0 ? [] : [[start, start + span.text.length] as const]
})
}
export function computeRetrievalMetrics(
fixture: RetrievalFixture,
rankings: ReadonlyMap<string, readonly RankedEvaluationItem[]>
): RetrievalMetrics {
const answerable = fixture.queries.filter((query) => !query.noAnswer)
const noAnswer = fixture.queries.filter((query) => query.noAnswer)
let recall5 = 0
let recall10 = 0
let reciprocalRank = 0
let ndcg = 0
let matchedSpanCharacters = 0
let returnedContextCharacters = 0
let annotatedSpanCharacters = 0
const chunksById = new Map(
fixture.documents.flatMap((document) =>
document.chunks.map((chunk) => [chunk.id, chunk.content] as const)
)
)
for (const query of answerable) {
const ranked = dedupeRanking(rankings.get(query.id) ?? [])
const grades = new Map(
query.judgments.map((judgment) => [judgment.chunkId, judgment.relevance])
)
recall5 += relevantAt(query, ranked, 5)
recall10 += relevantAt(query, ranked, 10)
const firstRelevant = ranked
.slice(0, 10)
.findIndex((item) => (grades.get(item.chunkId) ?? 0) > 0)
reciprocalRank += firstRelevant < 0 ? 0 : 1 / (firstRelevant + 1)
const dcg = ranked.slice(0, 10).reduce((total, item, index) => {
const grade = grades.get(item.chunkId) ?? 0
return total + (2 ** grade - 1) / Math.log2(index + 2)
}, 0)
const ideal = [...grades.values()]
.sort((left, right) => right - left)
.slice(0, 10)
.reduce((total, grade, index) => total + (2 ** grade - 1) / Math.log2(index + 2), 0)
ndcg += ideal === 0 ? 0 : dcg / ideal
for (const judgment of query.judgments) {
annotatedSpanCharacters += unionRangeLength(
spanRanges(chunksById.get(judgment.chunkId) ?? '', judgment.spans)
)
}
for (const item of ranked.slice(0, 10)) {
const context = item.context ?? ''
returnedContextCharacters += context.length
const judgment = query.judgments.find((candidate) => candidate.chunkId === item.chunkId)
matchedSpanCharacters += unionRangeLength(
spanRanges(context, judgment?.spans ?? [])
)
}
}
const falsePositives = noAnswer.filter(
(query) => dedupeRanking(rankings.get(query.id) ?? []).length > 0
).length
return {
recallAt5: recall5 / answerable.length,
recallAt10: recall10 / answerable.length,
mrrAt10: reciprocalRank / answerable.length,
ndcgAt10: ndcg / answerable.length,
contextPrecision:
returnedContextCharacters === 0 ? 0 : matchedSpanCharacters / returnedContextCharacters,
contextRecall:
annotatedSpanCharacters === 0 ? 0 : matchedSpanCharacters / annotatedSpanCharacters,
noAnswerFalsePositiveRate:
noAnswer.length === 0 ? 0 : falsePositives / noAnswer.length
}
}
export function summarizeLatencies(values: readonly number[]) {
if (values.length === 0) {
return { count: 0, min: 0, median: 0, p95: 0, max: 0, mean: 0 }
}
const sorted = [...values].sort((left, right) => left - right)
const percentile = (fraction: number): number =>
sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]!
return {
count: sorted.length,
min: sorted[0]!,
median: percentile(0.5),
p95: percentile(0.95),
max: sorted.at(-1)!,
mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length
}
}
async function createSeededService(
fixture: RetrievalFixture,
providerId: EvaluationProviderId
) {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-retrieval-eval-'))
const provider = createEvaluationEmbeddingProvider(providerId)
const vectorize =
providerId === 'token-hash' ? tokenHashVector : regressionAliasHashVector
const service = new KnowledgeService({
databasePath: join(directory, 'knowledge.sqlite'),
managedRoot: join(directory, 'managed'),
embeddingProvider: provider
})
await service.initialize()
const library = service.createLibrary({
id: 'library-retrieval-eval',
name: 'Synthetic retrieval evaluation',
storageMode: 'reference',
graphEnabled: false
})
const source = service.database.upsertSource({
id: 'source-retrieval-eval',
knowledgeBaseId: library.id,
type: 'file',
location: 'fixture://synthetic-bilingual-v1',
displayName: 'Synthetic fixture',
status: 'ready'
})
for (const document of fixture.documents) {
service.database.upsertDocument(
{
id: document.id,
knowledgeBaseId: library.id,
sourceId: source.id,
externalId: document.id,
title: document.title,
mimeType: 'text/plain',
metadata: { status: 'ready', fixtureId: fixture.id }
},
document.chunks.map((chunk, ordinal) => ({
id: chunk.id,
ordinal,
content: chunk.content,
role: 'standalone' as const
}))
)
service.database.replaceDocumentEmbeddings(
document.id,
embeddingStorageProvider(provider),
provider.model,
document.chunks.map((chunk) => ({
chunkId: chunk.id,
contentChecksum: sha256(chunk.content),
vector: vectorize(chunk.content)
}))
)
}
return { directory, service, libraryId: library.id }
}
const ablationSettings: Record<RetrievalAblation, {
providerId: EvaluationProviderId
ftsWeight: number
vectorWeight: number
localRerankEnabled: boolean
}> = {
lexical: {
providerId: 'token-hash',
ftsWeight: 1,
vectorWeight: 0,
localRerankEnabled: false
},
'token-hash-vector': {
providerId: 'token-hash',
ftsWeight: 0,
vectorWeight: 1,
localRerankEnabled: false
},
'regression-alias-vector': {
providerId: 'regression-alias',
ftsWeight: 0,
vectorWeight: 1,
localRerankEnabled: false
},
hybrid: {
providerId: 'regression-alias',
ftsWeight: 1,
vectorWeight: 1,
localRerankEnabled: false
},
'hybrid-rerank': {
providerId: 'regression-alias',
ftsWeight: 1,
vectorWeight: 1,
localRerankEnabled: true
}
}
async function safeOutputPath(rawPath: string, workingDirectory: string): Promise<string> {
if (rawPath.includes('\0')) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT contains a null byte')
}
if (isAbsolute(rawPath)) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT must be a workspace-relative path')
}
const workspace = await realpath(workingDirectory)
const output = resolve(workspace, rawPath)
if (!isPathInside(workspace, output) || output === workspace) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT must be a workspace-relative file')
}
const relativeParent = relative(workspace, dirname(output))
let current = workspace
for (const component of relativeParent.split(sep).filter(Boolean)) {
current = join(current, component)
try {
const status = await lstat(current)
if (status.isSymbolicLink()) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT may not traverse a symlink')
}
if (!status.isDirectory()) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT parent must be a directory')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
await mkdir(current)
}
const canonicalCurrent = await realpath(current)
if (!isPathInside(workspace, canonicalCurrent)) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT escapes the workspace')
}
}
try {
if ((await lstat(output)).isSymbolicLink()) {
throw new Error('GOODBUDDY_RETRIEVAL_EVAL_OUTPUT may not be a symlink')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
return output
}
async function writeWorkspaceReport(
destination: string,
report: RetrievalEvaluationReport
): Promise<void> {
const temporaryDirectory = await mkdtemp(join(dirname(destination), '.retrieval-eval-'))
const temporaryPath = join(temporaryDirectory, 'report.json')
try {
await writeFile(temporaryPath, `${JSON.stringify(report, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx'
})
await rename(temporaryPath, destination)
} finally {
await rm(temporaryDirectory, { recursive: true, force: true })
}
}
function metricsForLanguage(
fixture: RetrievalFixture,
rankings: ReadonlyMap<string, readonly RankedEvaluationItem[]>,
language: 'en' | 'zh-CN'
): RetrievalMetrics {
return computeRetrievalMetrics(
{
...fixture,
queries: fixture.queries.filter((query) => query.language === language)
},
rankings
)
}
export function computeEvaluationDefinitionHash(
fixture: RetrievalFixture
): string {
return sha256(JSON.stringify({
fixtureVersion: fixture.version,
fixtureId: fixture.id,
queries: fixture.queries.map((query) => ({
id: query.id,
language: query.language,
query: query.query,
noAnswer: query.noAnswer,
judgments: query.judgments
})),
retrievalSettings,
ablations: ablationSettings,
providers: providerDefinitions,
metricVersion
}))
}
export async function runRetrievalEvaluation(options: {
outputPath?: string
workingDirectory?: string
} = {}): Promise<RetrievalEvaluationReport> {
const fixture = await loadRetrievalFixture()
const ablations: RetrievalEvaluationReport['ablations'] = []
for (const id of Object.keys(ablationSettings) as RetrievalAblation[]) {
const settings = ablationSettings[id]
const seeded = await createSeededService(fixture, settings.providerId)
try {
const rankings = new Map<string, RankedEvaluationItem[]>()
const latencies: number[] = []
for (const query of fixture.queries) {
const response = await seeded.service.retrieve({
knowledgeBaseId: seeded.libraryId,
query: query.query,
settings: {
...retrievalSettings,
ftsWeight: settings.ftsWeight,
vectorWeight: settings.vectorWeight,
localRerankEnabled: settings.localRerankEnabled
}
})
latencies.push(response.durationMs)
const contexts = new Map(
response.context.groups.map((group) => [group.resultChunkId, group.content])
)
rankings.set(
query.id,
response.results.map((result) => ({
chunkId: result.chunkId,
context: contexts.get(result.chunkId)
}))
)
}
const failures: RetrievalEvaluationReport['ablations'][number]['failures'] = []
for (const query of fixture.queries) {
const ranking = rankings.get(query.id) ?? []
if (query.noAnswer) {
if (ranking.length > 0) {
failures.push({
queryId: query.id,
reason: 'no-answer-false-positive'
})
}
continue
}
const relevant = new Set(query.judgments.map((judgment) => judgment.chunkId))
if (!ranking.slice(0, 10).some((item) => relevant.has(item.chunkId))) {
failures.push({
queryId: query.id,
reason: 'no-relevant-result-at-10'
})
}
}
ablations.push({
id,
metrics: computeRetrievalMetrics(fixture, rankings),
metricsByLanguage: {
en: metricsForLanguage(fixture, rankings, 'en'),
'zh-CN': metricsForLanguage(fixture, rankings, 'zh-CN')
},
latencyMs: summarizeLatencies(latencies),
failures
})
} finally {
await seeded.service.dispose()
await rm(seeded.directory, { recursive: true, force: true })
}
}
const report: RetrievalEvaluationReport = {
schemaVersion: 1,
fixtureId: fixture.id,
corpusHash: sha256(canonicalFixtureCorpus(fixture)),
evaluationDefinitionHash: computeEvaluationDefinitionHash(fixture),
providerFingerprintHash: sha256(JSON.stringify(providerDefinitions)),
queryIds: fixture.queries.map((query) => query.id),
ablations
}
const outputPath = options.outputPath ?? process.env.GOODBUDDY_RETRIEVAL_EVAL_OUTPUT
if (outputPath) {
const destination = await safeOutputPath(
outputPath,
options.workingDirectory ?? process.cwd()
)
await writeWorkspaceReport(destination, report)
}
return report
}
export function deterministicReportProjection(report: RetrievalEvaluationReport) {
return {
schemaVersion: report.schemaVersion,
fixtureId: report.fixtureId,
corpusHash: report.corpusHash,
evaluationDefinitionHash: report.evaluationDefinitionHash,
providerFingerprintHash: report.providerFingerprintHash,
queryIds: report.queryIds,
ablations: report.ablations.map((ablation) => ({
id: ablation.id,
metrics: ablation.metrics,
metricsByLanguage: ablation.metricsByLanguage,
failures: ablation.failures
}))
}
}