本质上是通过rag解决达模型回答不可靠的问题,前端的核心价值在于将检索结果结构化展示,并与流式生成过程融合。
什么是RAG
rag(检索增强生成)是一种将强大信息检索技术与生成式大语言模型(LLM)相结合 的框架。核心思想是在 让LLM回答问题或生成文本之前,先从一个大规模的知识库如(数据库、文档集合)中检索出相关的上下文信息,然后将这些信息与原始问题一并提供给LLM,来增强生成能力,产出更准确、更具时效性、更符合特定领域知识的问答
为什么需要RAG
解决大模型的知识截止(预训练的大模型的知识库有截止时间)、数据孤岛(企业内部私有文档)、幻觉问题(编造不符合事实的信息)。
RAG工作原理
分为索引和检索两个阶段
**索引阶段-离线预处理:**文档加载、清洗、分段切为小块chunk;通过embedding模型转换为向量;存入向量数据库,构建语义索引
**检索生成阶段-在线实时:**将用户问题转换为向量;从数据库中找到最相近的文档块,拼接为上下文交给LLM生成答案
应用场景-科研助手
论文公式推导:求解一篇论文的公式,需要参考几篇相关的论文同时求解
文献查阅:没有rag的话,ai会凭空捏造文献
论文写作:例如写文献综述,可以生成更为准确的报告
支持私有知识问答:个人文献库中问答
前端目标拆解
能展示答案及其来源
支持引用高亮
与流式输出融合
长对话的稳定
技术调研
为什么选rag而不是fintune
fin tune成本高,更新慢;知识频繁更新、私有敏感文档、需要答案可溯源、不想大量标注数据、控制幻觉 ,RAG更好。
检索方案对比
1.关键词检索BM25:简单成本低,语义理解差
2.向量检索embedding:语义匹配强是主流,embedding负责将文本转换为稠密向量,予以相近的文本在空间中距离更近,需要embedding服务
常用embedding模型
|------------------------------------------|------|------|-------------------|
| 模型 | 维度 | 适用语言 | 特点 |
| text-embedding-3-small (OpenAI) | 1536 | 多语言 | 性价比高,适合大规模索引 |
| text-embedding-3-large (OpenAI) | 3072 | 多语言 | 精度最高,成本较高 |
| BAAI/bge-m3 | 1024 | 中英文 | 开源,中文效果优秀,支持多语言 |
| sentence-transformers/all-MiniLM-L6-v2 | 384 | 英文 | 体积小,速度快,适合本地极轻量部署 |
3.混合检索BM25+向量:效果好,实现复杂
向量检索:embedding+相似度计算
检索的核心是度量距离 。最常用的是余弦相似度(Cosine Similarity),它计算两个向量的夹角余弦值,值域 [-1, 1],越接近 1 越相似。此外还有点积(Dot Product)和欧氏距离(L2 Distance)。
为了在百万级向量中实现毫秒级检索,数据库通常采用近似最近邻(ANN)算法 (如 HNSW、IVF)。HNSW 是目前最主流的算法,它通过构建多层跳跃图网络,牺牲极少的精度换取了数量级的搜索速度提升。
前端展示方案 检索结果如何展示
只给答案:简单,不可信
内联引用(类似论文):可解释性强,实现复杂
采用内联引用+高亮标注效果最佳
流式/非流式输出
一次性输出,简单,但是延迟很高,用户体验很差:流式输出sse,实时体验效果好,但是前端处理比较复杂
方案设计(核心链路)
用户输入->检索服务(向量数据库)-(topk文档)->拼接prompt-> LLM生成-(sse)->前端解析与渲染->答案+引用高亮展示
前端实现
RAG数据结构设计
后端返回实例
type RagChunk = {
content: string
source: string
id: string
}
消息结构
type Message = {
role: 'user' | 'assistant'
content: string
references?: RagChunk[]
}
引用插入 策略(难)
方案一:优
后端:插入引用标记,返回结构化引用源数据,"这是答案内容[1],这里引用了资料[2]";
前端:通过正则表达式匹配解析序号标签,进行样式高亮展示,绑定鼠标悬浮事件,实现鼠标经过[1]->高亮+弹出tooltip(鼠标悬浮文本框,使用ui组件库中的Tooltip组件),展示对应知识库来源
方案二:
前端匹配,文本匹配chunk,性能差,精度差
流式输出融合(重点)
流式输出是逐个chunk返回的,引用怎么对齐呢
后端传输规则:
-
提示词工程 :强制要求 LLM 在回答中为每个事实性陈述添加内联引用标记,例如
[1]、〔2〕。明确禁止跨 token 分割引用(如将[1]拆成[和1])。 -
结构化协议:流式响应中,将纯文本与引用元数据分离传输。典型 SSE 设计:
event: text
data: {"content": "海外营收增长23%[1]。"}event: citation
data: {"id": "1", "doc": "年报2024", "page": 12, "preview": "..."} -
确定性映射:后端维护一个引用清单,每个引用编号对应完整的元数据(文档名、页码、原文片段等)。
前端核心流程
- 维护三个"容器"
- 文本缓冲池 (一个字符串变量):
每收到一个text块,就把它拼接到这个字符串末尾。
作用:保留完整的答案原文,方便后续用正则找出所有[数字]。 - 待渲染队列 (另一个字符串变量):
每收到一个text块,也把它拼接到这个队列里。
作用:累积一小批文本,等合适的时机再一次性画到屏幕上。 - 引用映射表 (一个字典/Map):
每收到一个citation事件,就把编号和对应的文档信息存起来。
- 渲染文本流:批量、与屏幕刷新同步
- 不用"收到一个字立刻改一次 DOM",那样会频繁触发浏览器重排。
- 而是采用
requestAnimationFrame(请求动画帧)机制:
-
- 每次收到新文本,先放进"待渲染队列"。
- 如果当前没有安排渲染任务,就请求浏览器在下一帧绘制前执行一个函数。
- 这个函数会把"待渲染队列"里所有的文本一次性追加 到页面上,然后清空队列。
- 浏览器通常每秒刷新 60 次(约 16.6 毫秒一次),因此用户感知到的延迟极低,依然觉得是"逐字输出",但 CPU 负担大大降低。
- 引用对齐:延迟解析 + 局部更新
- 因为
[1]可能被拆成两个网络包([和1]),所以不能一收到字符就立刻去解析。 - 前端会定期 (比如每 100 毫秒,或者每次收到一批新文本后)对"文本缓冲池"做一次扫描:
-
- 用正则表达式找出所有已经完整的
[数字]。 - 检查每个编号是否已经在"引用映射表"里。
- 如果已存在,就把这个
[1]替换成一个可交互的组件(角标)。 - 如果还不存在,就保留原样,等以后元数据来了再处理。
- 用正则表达式找出所有已经完整的
- 替换时,不会整个页面重新渲染,而是只更新变化的那一小段(Vue/React 的虚拟 DOM diff 会自动完成)。
最终对齐
- 当收到
done事件后,再做一次完整的全量解析,确保所有引用都变成组件。 - 如果有某个引用编号始终没等到元数据,就显示一个灰色的
[?]或保留原文本,不会报错。
部分参考菜鸟教程:RAG 与知识检索 | 菜鸟教程以及ai生成