450 lines
18 KiB
TypeScript
450 lines
18 KiB
TypeScript
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
|
||
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
|
||
|
||
/**
|
||
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
|
||
*
|
||
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
|
||
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
|
||
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
|
||
*/
|
||
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
|
||
let thinkingMarkdown = '';
|
||
let answerMarkdown = '';
|
||
let inThink = false;
|
||
let thinkingComplete = false;
|
||
|
||
const appendToken = (token: string) => {
|
||
let remaining = token;
|
||
while (remaining) {
|
||
if (inThink) {
|
||
const endIdx = remaining.indexOf('</think>');
|
||
if (endIdx === -1) {
|
||
thinkingMarkdown += remaining;
|
||
break;
|
||
}
|
||
thinkingMarkdown += remaining.slice(0, endIdx);
|
||
inThink = false;
|
||
thinkingComplete = true;
|
||
remaining = remaining.slice(endIdx + '</think>'.length);
|
||
} else {
|
||
const startIdx = remaining.indexOf('<think>');
|
||
if (startIdx === -1) {
|
||
answerMarkdown += remaining;
|
||
break;
|
||
}
|
||
answerMarkdown += remaining.slice(0, startIdx);
|
||
inThink = true;
|
||
remaining = remaining.slice(startIdx + '<think>'.length);
|
||
}
|
||
}
|
||
};
|
||
|
||
chunks.forEach((chunk) => appendToken(chunk.answer));
|
||
|
||
return {
|
||
title: '单轮 RAG 思考过程',
|
||
thinkingMarkdown,
|
||
answerMarkdown,
|
||
thinkingComplete,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 构造单轮 RAG 的演示 trace。
|
||
*
|
||
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
|
||
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
|
||
*/
|
||
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
|
||
{ answer: '<think>' },
|
||
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
|
||
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
|
||
{ answer: '- 检索阶段:查找相关知识片段\n' },
|
||
{ answer: '- 增强阶段:将证据注入提示词\n' },
|
||
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
|
||
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
|
||
{ answer: '</think>' },
|
||
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
|
||
{ answer: '### 工作流程\n\n' },
|
||
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
|
||
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
|
||
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
|
||
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
|
||
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
|
||
]);
|
||
|
||
/**
|
||
* 构造多轮/深度检索的演示思考链。
|
||
*
|
||
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
|
||
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
|
||
*/
|
||
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
|
||
// 初始化轮次数组,第一轮总是基础检索
|
||
const rounds = [
|
||
{
|
||
id: 'round-1', // 第一轮唯一标识
|
||
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
|
||
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
|
||
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
|
||
queryShow: true,
|
||
retrievals: [ // 第一轮检索返回的资料列表
|
||
{
|
||
id: 'r1-1',
|
||
title: 'RAG技术白皮书',
|
||
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
|
||
source: '知识库 / RAG技术白皮书.pdf',
|
||
},
|
||
{
|
||
id: 'r1-2',
|
||
title: 'LLM应用实践:检索增强生成',
|
||
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
|
||
source: '知识库 / LLM应用实践.docx',
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
|
||
if (mode !== 'single-round') {
|
||
// 添加第二轮检索:补充多轮检索与重排机制的差异
|
||
rounds.push({
|
||
id: 'round-2', // 第二轮的唯一标识
|
||
title: '补充多轮检索与重排机制', // 本轮检索的主题
|
||
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
|
||
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
|
||
// 本轮检索返回的资料列表
|
||
queryShow: true,
|
||
retrievals: [
|
||
{
|
||
id: 'r2-1',
|
||
title: '高级RAG技术指南',
|
||
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
|
||
source: '资料库 / 高级RAG技术指南.md',
|
||
},
|
||
{
|
||
id: 'r2-2',
|
||
title: 'RAG召回与重排最佳实践',
|
||
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
|
||
source: '行业资料 / RAG最佳实践.html',
|
||
},
|
||
{
|
||
id: 'r2-3',
|
||
title: '检索增强系统评测报告',
|
||
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
|
||
source: '评测库 / 检索增强系统评测报告.xlsx',
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
|
||
if (mode === 'deep-search') {
|
||
rounds.push({
|
||
id: 'round-3', // 第三轮的唯一标识
|
||
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
|
||
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
|
||
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
|
||
queryShow: true,
|
||
retrievals: [
|
||
{
|
||
id: 'r3-1',
|
||
title: '企业知识问答系统建设方案',
|
||
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
|
||
source: '方案库 / 企业知识问答系统建设方案.pptx',
|
||
},
|
||
{
|
||
id: 'r3-2',
|
||
title: '生成式AI落地风险控制',
|
||
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
|
||
source: '风控库 / 生成式AI落地风险控制.pdf',
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
return {
|
||
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
|
||
rounds,
|
||
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 构造流式多轮检索的初始空 trace。
|
||
*
|
||
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
|
||
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
|
||
*/
|
||
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
|
||
title: '多轮检索思考过程',
|
||
rounds: [{
|
||
id: `round-${Date.now()}`,
|
||
title: '正在准备检索',
|
||
thoughtMarkdown: '',
|
||
query: '',
|
||
queryShow: true,
|
||
queryLoading: true,
|
||
retrievals: [],
|
||
}],
|
||
thinkingComplete: false,
|
||
finalAnswerMarkdown: '',
|
||
});
|
||
|
||
/**
|
||
* 为对比模式创建左右面板的初始状态。
|
||
*
|
||
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
|
||
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
|
||
*/
|
||
export const createComparePane = (mode: QAMode): ComparePane => ({
|
||
mode,
|
||
loading: true,
|
||
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
|
||
? {
|
||
title: '单轮 RAG 思考过程',
|
||
thinkingMarkdown: '',
|
||
answerMarkdown: '',
|
||
thinkingComplete: false,
|
||
}
|
||
: undefined,
|
||
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
|
||
sources: [],
|
||
});
|
||
|
||
/**
|
||
* 首屏演示消息。
|
||
*
|
||
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
|
||
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
|
||
*/
|
||
export const createMockSingleRagMessages = (): Message[] => ([
|
||
{
|
||
id: 'mock-user-1',
|
||
role: 'user',
|
||
content: 'RAG 在企业知识问答里具体怎么落地?',
|
||
timestamp: new Date(),
|
||
},
|
||
{
|
||
id: 'mock-assistant-1',
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
singleRagTrace: {
|
||
title: '单轮 RAG 思考过程',
|
||
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
|
||
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
|
||
thinkingComplete: true,
|
||
},
|
||
sources: [
|
||
{
|
||
title: '企业知识问答平台建设指南',
|
||
type: '知识库',
|
||
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
|
||
source: '内部架构组',
|
||
time: '2026-03-18',
|
||
url: 'https://example.com/rag-enterprise-guide',
|
||
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
|
||
},
|
||
{
|
||
title: 'RAG 重排实践报告',
|
||
type: '技术报告',
|
||
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
|
||
source: 'NLP 团队',
|
||
time: '2026-01-09',
|
||
url: 'https://example.com/rag-rerank-report',
|
||
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
|
||
},
|
||
{
|
||
title: '可追溯生成规范',
|
||
type: '规范',
|
||
snippet: '回答输出应绑定证据编号,便于用户核验。',
|
||
source: 'AI 治理委员会',
|
||
time: '2025-12-22',
|
||
url: 'https://example.com/traceable-generation-spec',
|
||
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'mock-user-2',
|
||
role: 'user',
|
||
content: '那单轮和多轮检索的差异是什么?',
|
||
timestamp: new Date(),
|
||
},
|
||
{
|
||
id: 'mock-assistant-2',
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
singleRagTrace: {
|
||
title: '单轮 RAG 思考过程',
|
||
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
|
||
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
|
||
thinkingComplete: true,
|
||
},
|
||
sources: [
|
||
{
|
||
title: 'RAG 模式选型对比',
|
||
type: '白皮书',
|
||
snippet: '单轮偏性能,多轮偏完整性。',
|
||
source: '智能搜索实验室',
|
||
time: '2026-02-11',
|
||
url: 'https://example.com/rag-mode-comparison',
|
||
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
|
||
},
|
||
{
|
||
title: '检索系统时延评测',
|
||
type: '评测',
|
||
snippet: '多轮策略会增加查询与重排调用次数。',
|
||
source: '平台性能组',
|
||
time: '2026-02-27',
|
||
url: 'https://example.com/rag-latency-benchmark',
|
||
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
|
||
/**
|
||
* 将多轮检索接口返回的 references 项映射为通用引用来源。
|
||
*/
|
||
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
|
||
title: item.title || '未命名来源',
|
||
type: '参考资料',
|
||
snippet: item.snippet || item.content || '',
|
||
content: item.content || item.snippet || '',
|
||
});
|
||
|
||
/**
|
||
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
|
||
*/
|
||
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
|
||
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
|
||
|
||
return normalizedDocs.map((item, idx) => {
|
||
console.log(item,'检索资料')
|
||
if (typeof item === 'string') {
|
||
return {
|
||
title: '',
|
||
type: '知识库',
|
||
snippet: item,
|
||
source: '',
|
||
time: '',
|
||
content: item,
|
||
};
|
||
}
|
||
|
||
return {
|
||
title: item.title || item.source || `参考资料 ${idx + 1}`,
|
||
type: '知识库',
|
||
snippet: item.snippet || item.content || item.url || '',
|
||
source: item.source || item.title || '',
|
||
time: '',
|
||
url: item.url,
|
||
content: item.content || item.snippet || '',
|
||
};
|
||
});
|
||
};
|
||
|
||
export const chatDataJSON = (chatJSON: string) => {
|
||
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
|
||
chatJSONText = '[' + chatJSONText + ']';
|
||
// console.log('解析chat',chatJSONText);
|
||
let chatData = [];
|
||
let chatTitle: any = '';
|
||
const docsList: any[] = [];
|
||
try {
|
||
chatData = JSON.parse(chatJSONText);
|
||
chatData.forEach((item: any) => {
|
||
if (item['answer']) {
|
||
chatTitle += item['answer'];
|
||
} else if (item['docs']) {
|
||
docsList.push(item);
|
||
}
|
||
});
|
||
return {
|
||
chatTitle,
|
||
docsList,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
chatTitle,
|
||
docsList,
|
||
};
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 从流式文本中提取完整 JSON 对象。
|
||
*
|
||
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
|
||
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
|
||
*/
|
||
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
|
||
const objects: string[] = [];
|
||
let depth = 0;
|
||
let stringQuote: '"' | "'" | null = null;
|
||
let escaped = false;
|
||
let start = -1;
|
||
|
||
for (let i = 0; i < raw.length; i += 1) {
|
||
const ch = raw[i];
|
||
|
||
if (stringQuote) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (ch === '\\') {
|
||
escaped = true;
|
||
} else if (ch === stringQuote) {
|
||
stringQuote = null;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"' || ch === "'") {
|
||
stringQuote = ch;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '{') {
|
||
if (depth === 0) start = i;
|
||
depth += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === '}') {
|
||
depth -= 1;
|
||
if (depth === 0 && start !== -1) {
|
||
objects.push(raw.slice(start, i + 1));
|
||
start = -1;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (depth > 0 && start !== -1) {
|
||
return { objects, rest: raw.slice(start) };
|
||
}
|
||
|
||
return { objects, rest: '' };
|
||
};
|
||
|
||
// 处理平台数据
|
||
export const platformTypeChange = (platform: string) => {
|
||
let platformType: string = '';
|
||
if (platform.includes('fb')) {
|
||
platformType = 'facebook';
|
||
} else if (platform.includes('x')) {
|
||
platformType = 'x';
|
||
} else if (platform.includes('in')) {
|
||
platformType = 'instagram';
|
||
} else if (platform.includes('re')) {
|
||
platformType = 'reddit';
|
||
} else if (platform.includes('th')) {
|
||
platformType = 'threads';
|
||
} else {
|
||
platformType = 'facebook';
|
||
}
|
||
return platformType;
|
||
} |