Files
bigwo/test2/server/tests/test_kb_retriever.js
User 56940676f6 feat(kb): VikingDB纯检索+重排+Redis上下文+全库搜索+别名扩展+KB保护窗口+RAG语气引导
- 新增 kbRetriever.js: VikingDB search_knowledge 纯检索替代 Ark chat/completions, doubao-seed-rerank 重排, RAG payload 语气引导缓解音色差异

- 新增 redisClient.js: Redis 连接管理 + 5轮对话历史 + KB缓存双写

- toolExecutor.js: 产品别名扩展25条, 全库检索topK=25, 检索阈值0.01, 精简 buildDeterministicKnowledgeQuery

- nativeVoiceGateway.js: isPureChitchat扩展, KB保护窗口60s, prequery参数调优

- realtimeDialogRouting.js: resolveReply感知KB保护窗口, fast-path适配raw模式

- app.js: 健康检查新增 redis/reranker/kbRetrievalMode

- 新增测试: alias A/B测试, KB retriever测试, Redis客户端测试, raw模式集成测试
2026-03-26 14:30:32 +08:00

323 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* kbRetriever.js 单元测试
* 覆盖配置读取、rerankChunks降级、buildRagPayload组装、hit/no-hit判断
* 纯本地测试不依赖外部API
*
* 运行方式: node --test tests/test_kb_retriever.js
*/
const { describe, it, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
// 设置测试环境变量在require之前
const ENV_BACKUP = {};
function setEnv(overrides) {
for (const [k, v] of Object.entries(overrides)) {
ENV_BACKUP[k] = process.env[k];
process.env[k] = v;
}
}
function restoreEnv() {
for (const [k, v] of Object.entries(ENV_BACKUP)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
// 设置基本环境变量避免模块加载出错
setEnv({
VOLC_ARK_API_KEY: 'test_key',
VOLC_ARK_ENDPOINT_ID: 'test_endpoint',
VOLC_ARK_KNOWLEDGE_BASE_IDS: 'ds_test1,ds_test2',
VOLC_ARK_RERANKER_ENDPOINT_ID: 'reranker_test',
VOLC_ARK_RERANKER_TOP_N: '3',
VOLC_ARK_KB_RETRIEVAL_TOP_K: '10',
VOLC_ARK_KNOWLEDGE_THRESHOLD: '0.1',
ENABLE_RERANKER: 'true',
ENABLE_REDIS_CONTEXT: 'false', // 测试中不连Redis
});
const kbRetriever = require('../services/kbRetriever');
// ================================================================
// 1. getConfig — 配置读取
// ================================================================
describe('kbRetriever.getConfig — 配置读取', () => {
afterEach(() => restoreEnv());
it('应正确读取所有配置项', () => {
const config = kbRetriever.getConfig();
assert.equal(config.authKey, 'test_key');
assert.equal(config.rerankerTopN, 3);
assert.equal(config.retrievalTopK, 10);
assert.equal(config.enableReranker, true);
assert.equal(config.enableRedisContext, false);
assert.ok(config.kbIds.includes('ds_test1'));
assert.ok(config.kbIds.includes('ds_test2'));
});
it('ENABLE_RERANKER=false 应正确关闭', () => {
setEnv({ ENABLE_RERANKER: 'false' });
const config = kbRetriever.getConfig();
assert.equal(config.enableReranker, false);
});
it('无 RERANKER_MODEL 时应默认为 doubao-seed-rerank', () => {
setEnv({ VOLC_ARK_RERANKER_MODEL: '', VOLC_ARK_RERANKER_ENDPOINT_ID: '' });
const config = kbRetriever.getConfig();
assert.equal(config.rerankerModel, 'doubao-seed-rerank');
});
it('retrievalMode 默认应为 raw', () => {
setEnv({ VOLC_ARK_KB_RETRIEVAL_MODE: 'raw' });
const config = kbRetriever.getConfig();
assert.equal(config.retrievalMode, 'raw');
});
it('retrievalMode 为空时默认 raw', () => {
setEnv({ VOLC_ARK_KB_RETRIEVAL_MODE: '' });
const config = kbRetriever.getConfig();
// 空字符串 || 'raw' → 'raw'... 不对实际是空字符串是falsy
// 代码: process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'raw'
assert.equal(config.retrievalMode, 'raw');
});
it('dataset_ids 分割应正确处理空格和逗号', () => {
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: ' ds_a , ds_b , ds_c ' });
const config = kbRetriever.getConfig();
assert.deepEqual(config.kbIds, ['ds_a', 'ds_b', 'ds_c']);
});
});
// ================================================================
// 2. rerankChunks — 重排降级逻辑
// ================================================================
describe('kbRetriever.rerankChunks — 降级与边界', () => {
afterEach(() => restoreEnv());
it('空 chunks 应返回空数组', async () => {
const result = await kbRetriever.rerankChunks('测试', [], 3);
assert.deepEqual(result, []);
});
it('chunks 数量 <= topN 时应直接返回全部', async () => {
const chunks = [
{ id: '1', content: '片段1', score: 0.9 },
{ id: '2', content: '片段2', score: 0.8 },
];
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
assert.equal(result.length, 2, 'Should return all chunks when count <= topN');
assert.equal(result[0].content, '片段1');
});
it('ENABLE_RERANKER=false 时应返回前 topN 条(按原序)', async () => {
setEnv({ ENABLE_RERANKER: 'false' });
const chunks = [
{ id: '1', content: 'A', score: 0.9 },
{ id: '2', content: 'B', score: 0.8 },
{ id: '3', content: 'C', score: 0.7 },
{ id: '4', content: 'D', score: 0.6 },
{ id: '5', content: 'E', score: 0.5 },
];
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
assert.equal(result.length, 3);
assert.equal(result[0].content, 'A');
assert.equal(result[1].content, 'B');
assert.equal(result[2].content, 'C');
});
it('无 RERANKER_ENDPOINT_ID 时应降级为按检索排序取 topN', async () => {
setEnv({ VOLC_ARK_RERANKER_ENDPOINT_ID: '' });
const chunks = Array.from({ length: 8 }, (_, i) => ({
id: `c${i}`, content: `内容${i}`, score: 1 - i * 0.1,
}));
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
assert.equal(result.length, 3);
assert.equal(result[0].content, '内容0', 'First chunk should be highest score');
});
it('reranker API 超时/失败时应降级返回前 topN', async () => {
// 设置一个不存在的 endpoint会导致 API 调用失败
setEnv({ ENABLE_RERANKER: 'true', VOLC_ARK_RERANKER_ENDPOINT_ID: 'invalid_endpoint' });
const chunks = [
{ id: '1', content: '片段1', score: 0.9 },
{ id: '2', content: '片段2', score: 0.8 },
{ id: '3', content: '片段3', score: 0.7 },
{ id: '4', content: '片段4', score: 0.6 },
];
const result = await kbRetriever.rerankChunks('测试', chunks, 3);
assert.equal(result.length, 3, 'Should fallback to top 3');
assert.equal(result[0].content, '片段1');
});
});
// ================================================================
// 3. buildRagPayload — RAG payload 组装
// ================================================================
describe('kbRetriever.buildRagPayload — payload 组装', () => {
it('无上下文时应只包含 KB 片段', () => {
const chunks = [
{ content: '片段A', doc_name: '产品手册' },
{ content: '片段B', doc_name: 'FAQ' },
];
const payload = kbRetriever.buildRagPayload(chunks, []);
assert.equal(payload.length, 2, 'Should have 2 items (chunks only)');
assert.equal(payload[0].title, '产品手册');
assert.equal(payload[0].content, '片段A');
assert.equal(payload[1].title, 'FAQ');
});
it('有上下文时应在片段前注入上下文条目', () => {
const chunks = [{ content: '片段A', doc_name: '' }];
const history = [
{ role: 'user', content: '小红怎么吃' },
{ role: 'assistant', content: '小红每天一包...' },
];
const payload = kbRetriever.buildRagPayload(chunks, history);
assert.equal(payload.length, 2, 'Should have context + 1 chunk');
assert.equal(payload[0].title, '对话上下文');
assert.ok(payload[0].content.includes('用户: 小红怎么吃'), 'Context should include user message');
assert.ok(payload[0].content.includes('助手: 小红每天一包'), 'Context should include assistant message');
assert.equal(payload[1].content, '片段A');
});
it('无 doc_name 的片段应使用默认标题"知识库片段N"', () => {
const chunks = [
{ content: '内容1', doc_name: '' },
{ content: '内容2', doc_name: '' },
{ content: '内容3', doc_name: '' },
];
const payload = kbRetriever.buildRagPayload(chunks, []);
assert.equal(payload[0].title, '知识库片段1');
assert.equal(payload[1].title, '知识库片段2');
assert.equal(payload[2].title, '知识库片段3');
});
it('空 chunks 应返回空数组(无上下文时)', () => {
const payload = kbRetriever.buildRagPayload([], []);
assert.equal(payload.length, 0);
});
it('空 chunks + 有上下文 应只返回上下文条目', () => {
const history = [{ role: 'user', content: '你好' }];
const payload = kbRetriever.buildRagPayload([], history);
assert.equal(payload.length, 1);
assert.equal(payload[0].title, '对话上下文');
});
it('5轮对话上下文应完整保留', () => {
const history = [];
for (let i = 1; i <= 5; i++) {
history.push({ role: 'user', content: `问题${i}` });
history.push({ role: 'assistant', content: `回答${i}` });
}
const payload = kbRetriever.buildRagPayload([{ content: '片段', doc_name: '' }], history);
const contextContent = payload[0].content;
for (let i = 1; i <= 5; i++) {
assert.ok(contextContent.includes(`问题${i}`), `Should include question ${i}`);
assert.ok(contextContent.includes(`回答${i}`), `Should include answer ${i}`);
}
});
});
// ================================================================
// 4. searchAndRerank — 主流程无API调用的边界测试
// ================================================================
describe('kbRetriever.searchAndRerank — 主流程边界', () => {
afterEach(() => restoreEnv());
it('endpoint 未配置时应返回 hit=false + error', async () => {
setEnv({ VOLC_ARK_ENDPOINT_ID: 'your_ark_endpoint_id', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: '' });
const result = await kbRetriever.searchAndRerank('测试');
assert.equal(result.hit, false);
assert.ok(result.reason, 'Should have reason');
assert.equal(result.source, 'ark_knowledge');
});
it('无 dataset_ids 时应返回 hit=false', async () => {
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: '' });
const result = await kbRetriever.searchAndRerank('测试');
assert.equal(result.hit, false);
});
it('返回结构应包含所有必需字段(或抛出可捕获的异常)', async () => {
// 使用假 endpointAPI 调用会失败
setEnv({
VOLC_ARK_ENDPOINT_ID: 'ep_test',
VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: 'ep_test',
VOLC_ARK_KNOWLEDGE_BASE_IDS: 'ds_test',
});
try {
const result = await kbRetriever.searchAndRerank('测试查询');
// 如果返回了结果(非抛出),验证结构
assert.ok('hit' in result, 'Should have hit field');
assert.ok('reason' in result, 'Should have reason field');
assert.ok('source' in result, 'Should have source field');
assert.ok('latencyMs' in result, 'Should have latencyMs field');
assert.equal(result.source, 'ark_knowledge');
} catch (err) {
// API 调用失败抛出异常也是合理行为(由上层 toolExecutor catch 处理)
assert.ok(err instanceof Error, 'Should throw an Error instance');
console.log(` searchAndRerank threw as expected: ${err.message.slice(0, 80)}`);
}
});
});
// ================================================================
// 5. hit/no-hit 判定逻辑
// ================================================================
describe('hit/no-hit 判定 — 基于 reranker score', () => {
afterEach(() => restoreEnv());
it('buildRagPayload 有片段 + score > 0.3 应判为 hit通过 searchAndRerank 返回值验证)', () => {
// 直接验证判定逻辑
const highScoreChunks = [{ content: '有效内容', score: 0.8, doc_name: '' }];
const payload = kbRetriever.buildRagPayload(highScoreChunks, []);
assert.ok(payload.length > 0, 'High score chunks should produce payload');
assert.ok(highScoreChunks[0].score >= 0.3, 'Score 0.8 >= 0.3 should be hit');
});
it('score < 0.3 的片段应判为 no-hit', () => {
const lowScoreChunks = [{ content: '弱相关内容', score: 0.1, doc_name: '' }];
assert.ok(lowScoreChunks[0].score < 0.3, 'Score 0.1 < 0.3 should be no-hit');
});
it('无重排器时 hitThreshold 应为 0.5', () => {
setEnv({ ENABLE_RERANKER: 'false' });
// 验证逻辑无重排器时0.4的分数应该不算hit阈值0.5
const config = kbRetriever.getConfig();
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.3 : 0.5;
assert.equal(hitThreshold, 0.5, 'Without reranker, threshold should be 0.5');
});
it('有重排器时 hitThreshold 应为 0.3', () => {
setEnv({ ENABLE_RERANKER: 'true', VOLC_ARK_RERANKER_MODEL: 'doubao-seed-rerank' });
const config = kbRetriever.getConfig();
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.3 : 0.5;
assert.equal(hitThreshold, 0.3, 'With reranker, threshold should be 0.3');
});
});
// ================================================================
// 6. retrieveChunks — 解析逻辑模拟response
// ================================================================
describe('retrieveChunks — 边界', () => {
afterEach(() => restoreEnv());
it('endpoint 未配置时应返回 error', async () => {
setEnv({ VOLC_ARK_ENDPOINT_ID: 'your_ark_endpoint_id', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: '' });
const result = await kbRetriever.retrieveChunks('测试', ['ds1'], 5, 0.1);
assert.equal(result.error, 'endpoint_not_configured');
assert.equal(result.chunks.length, 0);
});
it('无 datasetIds 且环境变量也为空时应返回 error', async () => {
setEnv({ VOLC_ARK_KNOWLEDGE_BASE_IDS: '', VOLC_ARK_ENDPOINT_ID: 'ep_valid', VOLC_ARK_KNOWLEDGE_ENDPOINT_ID: 'ep_valid' });
const result = await kbRetriever.retrieveChunks('测试', [], 5, 0.1);
assert.equal(result.error, 'no_dataset_ids');
});
});
console.log('\n=== kbRetriever 测试加载完成 ===\n');