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,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
}))
}
}