156 lines
6.0 KiB
TypeScript
156 lines
6.0 KiB
TypeScript
// @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)
|
|
})
|