深入浅出 RAG:用一个可运行的 Demo 讲透完整链路

RAG 是什么

RAG(Retrieval-Augmented Generation,检索增强生成)是一种先检索资料、再生成答案的问答方法。用户提出问题后,系统先从外部知识库中找出相关内容,再把问题和检索结果一起交给 LLM 回答。

检索(Retrieval)负责从知识库中找到相关资料,生成(Generation)负责根据这些资料生成答案。如果系统只列出相似文档,完成的只是检索;把检索结果继续交给 LLM 生成回答,才构成 RAG。

检索器会按相关性给候选片段排序,通常只把排名最靠前的 K 条交给 LLM。这组结果称为 Top-K,其中 K 表示保留多少个文本片段。

本文配合一个可以直接运行的 Demo 来讲解这套流程。服务端先在本地把资料转换成可供比较的向量,再结合语义和关键词两种方式查找相关内容,最后调用 .env 文件中配置的 LLM 生成带来源的回答。文中的公司、制度、金额和错误码均为 mock 数据,用户也可以上传自己的文档,验证私有资料的检索效果。

为什么需要 RAG

LLM 可以组织出流畅的文字,但如果应用没有为它接入外部资料或搜索工具,它只能依据训练数据和当前上下文回答问题。公司的内部制度、团队文档和个人笔记这些资料通常不会出现在公开训练数据中。

例如用户问:

打车费超过八百块应该怎么办?

如果只把这句话发给 LLM,模型最多根据常识猜测"可能需要领导审批"。由于当前上下文里没有公司的报销额度、审批人和填单要求,这个回答即使碰巧正确,也很难证明依据来自哪份制度。

接入 RAG 后,系统不会直接生成答案,而是先从知识库中找到相关资料。例如,这个问题应该命中下面的制度条款:

text 复制代码
员工因公产生的市内交通费用,每月报销限额为 800 元。
超出限额的部分需要部门负责人审批,并在报销单中说明业务原因。

检索完成后,LLM 再根据这段制度条款回答。私有资料不需要写入模型参数,文档发生变化时,重新处理发生变化的内容并更新索引即可。检索结果还保留了来源,因此答案可以附上引用,用户能够回到原文核对。

不过,RAG 不能"彻底消灭幻觉"。如果正确的资料没有进入 Top-K,或者文档已经过期、分块切断了关键条件,系统仍然会给出错误答案。因此,检索结果和最终回答都需要单独评估,不能因为答案带有引用就认为它一定正确。

RAG 的工作原理

文章开头的"检索"和"生成"描述的是一次问答:系统先找资料,再生成答案。但在用户提问之前,系统还要预先处理知识库。因此,从系统运行顺序来看,RAG 包含建立索引(Indexing)和处理查询(Querying)两个阶段;检索与生成都发生在查询阶段。

建立索引时,系统读取文档,按标题、段落和长度切成多个 chunk(文本片段),再用 Embedding 模型为每个片段生成向量。系统会同时保存原文、向量、来源、标题和原文位置,之后不必把整份文档逐字交给搜索模块。

处理查询时,Demo 分别执行向量检索和 BM25 关键词检索:前者按语义相似度排序,后者按词项匹配程度排序。两条分支独立搜索同一批 chunk,得到的顺序可能不同。程序把两路分数换算到可合并的范围,计算新的总分,再把最终 Top-K 连同来源编号交给 LLM。

后面的章节会依次拆开这条链路:先看文档怎样分块、Embedding 怎样生成,再看向量与 BM25 如何排序和融合。

这个 Demo 的 Embedding 模型输出 384 维向量。向量中的每个位置称为一维:第一个位置是第 1 维,第二个位置是第 2 维,依此类推。计算文本相似度时,程序会使用全部 384 维;单独一维没有"交通费"或"报销"这样的固定含义。

索引文件中的一条记录如下。完整的 vector 包含 384 维。0.024451 是第 1 维的值,-0.015882 是第 2 维的值。文章省略了其余 378 维,实际索引会保存完整向量。

jsonc 复制代码
{
  "id": "星河科技差旅与交通报销制度-27-coxv8b",
  "content": "员工因公产生的市内交通费用,每月报销限额为 800 元。超出限额的部分需要部门负责人审批,并在报销单中说明业务原因。",
  "source": "星河科技差旅与交通报销制度",
  "heading": "市内交通费用",
  "startOffset": 27,
  "vector": [0.024451, -0.015882, -0.075545, -0.058644, 0.048382, 0.016459 /* 其余 378 维省略 */]
}

这条记录中的 content 保存原始文本,之后会作为上下文交给 LLM。来源和标题用于生成引用,起始位置用于定位原文。vector 则只参与相似度计算,不会直接展示给最终用户。

索引、检索和生成解决的问题不同:索引把资料加工成可搜索的数据,检索寻找与当前问题相关的片段,生成再把这些片段组织成自然语言答案。这三个环节的故障也不同------分块可能切断关键条件,排序可能漏掉正确片段,LLM 则可能误解资料或加入资料中没有的内容。Demo 因此保留了"只看混合搜索"视图,让读者先检查检索结果,再检查生成内容。

分块策略:标题、长度和重叠内容

如果把一整本手册压成一个向量,里面不同章节的语义会互相稀释。检索即使命中了整本手册,也无法精确定位答案所在的段落。把全文放进上下文还会占用更多 token。

常见做法

因此,建立索引前要先做文本分块(Chunking)。在实际项目中,没有适合所有文档的固定切法,通常会按下面的顺序处理:

  1. 先利用文档结构。Markdown 和 HTML 按标题、段落、列表等边界切分;PDF、DOCX 和表格则尽量保留章节、页码、表头与单元格关系。
  2. 再限制 chunk 长度。某个章节仍然过长时,按段落、句子、词或 token 逐级切分,尽量不从一句话中间截断。
  3. 视情况保留重叠内容(overlap),也就是让后一块开头重复前一块结尾的一小段文字。这样不容易把跨越切分位置的一句话或一个条件拆散,但重复过多会增大索引,也可能让搜索结果出现内容相似的片段。
  4. 保存文档 ID、标题层级、页码、权限标签等元数据,以供过滤、引用和回溯使用。

前两步是最常见的基础切法:先按照文档原有的标题和段落划分内容,再把仍然过长的部分继续切小。遇到结构更复杂的资料,还可以使用下面这些方法:

  • 版面感知切分会读取文字在页面上的位置,避免把左右分栏的内容或表格中不同单元格的文字错误地拼在一起。
  • 语义分块会判断相邻句子是否仍在讨论同一主题,话题明显变化时才切开,而不是只按固定长度截断。
  • 父子块检索会用较短的片段进行精确搜索,命中后再取回它所属的较长章节,作为更完整的生成上下文。

这些方法需要额外的文档解析或检索步骤,是否使用要看资料的结构和实际测试结果。

Demo 的实现

这个 Demo 使用了简化的切分方法:Markdown 先按一到三级标题分段,过长的段落再按长度切开,并尽量在空行或换行处结束。代码中的 chunkSize = 400 表示每个片段的目标上限约为 400 个 token;chunkOverlap = 80 表示切出下一片时,会重复上一片结尾约 80 个 token。比如第一片接近 400 个 token,第二片会从大约第 321 个 token 附近开始,因此两片之间保留约 80 个 token 的相同内容。

这里的 token 数是估算值。代码用"文本字符数除以 3.5"做近似计算,没有调用模型实际使用的分词计数器(tokenizer)。这种写法让示例代码更容易运行,但 400 和 80 只是这个 Demo 的默认值,不能由此推断它们适合所有中文资料。

除了文本本身,每个片段还保存来源、标题和它在原文中的起始位置,方便展示引用并定位原文。当前 Demo 的资料较短,加入复杂的 PDF 版面解析、语义分块或父子块检索未必能改善结果,还会增加读者理解核心检索流程的难度。如果资料是每页分成左右几栏排版的 PDF、包含复杂表格,或者是一份很长的手册,再改用能够识别相应结构的切分方法更合适。

在实际项目中,需要准备一组测试问题,并提前标出每个问题应该命中哪段原文。然后分别调整片段长度、重叠长度和标题切分方式,比较哪种设置更容易找到正确内容。如果还没有这组测试问题,可以先用 400 和 80 把系统运行起来;它们是否合适,要等实际检索结果验证后才能确定。

Embedding 到底是什么

文本向量(Embedding)是用向量表示一段文字,使程序可以计算两段文字在语义上的相似程度:

text 复制代码
"差旅交通费如何报销"
    ↓
[0.021, -0.114, 0.307, ...]

经过训练的 Embedding 模型会让意思相近的文字得到相近的向量。因此,用户查"打车费超额",即使原文写的是"市内交通费用超过限额",向量检索仍有机会把它们匹配起来。

这个 Demo 使用的模型会为每段文字输出 384 维向量。检索时,程序比较问题向量和片段向量的整体方向;两者越接近,对应文本的语义相关性通常越高。向量的各个维度由模型训练得到,没有"第 7 维固定表示报销"这样的人工定义。

Embedding 只负责表示和比较文本,不会直接生成答案。两个向量接近,只能说明两段文字在当前模型生成的向量空间中相关,不能证明它们表达的是同一个事实。

理解了 Embedding 的作用以后,还要区分它的部署方式。这里可以从两个维度来看:模型运行在哪里,以及应用通过什么方式调用它。下表中的 ONNX 是一种通用的模型文件格式,用来保存已经训练好的模型;应用通过相应的运行库读取文件并完成计算。

维度 选择 含义
部署位置 云端 文本发送给托管服务,由服务返回向量
部署位置 本地 模型在用户设备或自有服务器运行
常见实现方式 云端 Embedding API 接入简单,需要考虑费用、网络和数据边界
常见实现方式 本地模型工具 Ollama Ollama 管理模型,并通过本机 HTTP 接口提供 Embedding
常见实现方式 本地 ONNX 模型 模型保存在 ONNX 文件中,由应用内的运行库读取并计算向量

表中的部署位置和实现方式不是同一层概念:前者描述模型位于云端还是本地,后者描述应用如何调用模型。Ollama 像一个单独运行的模型服务,应用通过本机接口请求它。使用 ONNX 文件时,运行模型的代码可以放在当前应用内部,不必另外启动 Ollama 服务。

Demo 如何在 Node.js 中生成向量

这个 Demo 使用 multilingual-e5-small 把问题和知识库片段转换成向量。它是一个模型名称,其中 multilingual 表示支持多种语言,E5 是模型系列,small 表示这是该系列中体积较小的版本。代码使用完整标识 Xenova/multilingual-e5-small,前面的 Xenova 是 Hugging Face 模型仓库的发布者名称。

项目没有另外启动一个模型服务。Node.js 服务端第一次初始化 Embedding 时,@huggingface/transformers 会从 Hugging Face Hub 下载模型权重、Tokenizer 和配置文件,总计约 135 MB;其中 q8 ONNX 权重约 118 MB。这些文件缓存到项目的 .cache/transformers 目录,模型再被加载到当前服务进程中。后续启动会优先读取缓存。每次传入问题或文本片段,服务端都会直接调用已经加载的模型并得到 384 维向量。

项目加载的是 q8 量化版本。量化是把模型参数从较高精度压缩到较低精度;q8 表示这里使用 8 位整数保存参数。这样可以减小模型文件和内存占用,代价是计算精度可能有少量损失。

从一段文本到最终向量,会经过下面几步:

flowchart LR T[问题或文本片段] --> P[添加 query 或 passage 前缀] P --> Z[Tokenizer 切成模型可处理的 token] Z --> E[E5 ONNX 模型计算每个 token 的表示] E --> M[按 attention mask 对有效 token 取平均] M --> N[将向量长度归一化] N --> V[得到 384 维向量]

核心代码位于 packages/server/src/embedding.ts

typescript 复制代码
const instance = await pipeline(
  'feature-extraction',
  'Xenova/multilingual-e5-small',
  {
    dtype: 'q8',
    revision: EMBEDDING_REVISION,
  }
);

const output = await instance(text, {
  pooling: 'mean',
  normalize: true,
});

模型接收的不是原始字符串,而是 Tokenizer 生成的整数 ID。以"差旅交通费如何报销?"为例,在本文记录的模型 revision 和 Transformers.js 3.8.1 下,实际切分结果如下:

text 复制代码
query: 差旅交通费如何报销?
    ↓
token:<s> | que | ry | : | 空格标记 | 差 | 旅 | 交通 |
       费 | 如何 | 报 | 销 | ? | </s>

ID:[0, 41, 1294, 12, 6, 9470, 27783, 10766,
    14479, 5733, 9001, 29906, 32, 2]

这次输入一共得到 14 个 token。<s></s> 是 Tokenizer 自动添加的起止标记;query: 本身被拆成 query:,并不是两个特殊 token。中文也不一定按单个汉字切分:"差""旅""费"分别占一个 token,而"交通""如何"各自合成一个 token。换一段文字、模型 revision 或 Tokenizer 版本,切分结果都可能变化,因此这些 ID 只用于解释当前 Demo,不能当作 E5 的固定规则。

pipeline(...) 负责加载 ONNX 模型,feature-extraction 表示取得文本表示而不是生成回答。模型先为 14 个 token 分别生成一组包含 384 个数字的向量,所以此时共有 14 组向量。这里的"池化"(pooling)就是把多组 token 向量合并成一个能代表整句话的向量。pooling: 'mean' 使用的是平均池化:attention mask 会标记哪些位置是真实输入、哪些只是为了统一长度而补齐的空位,程序先忽略这些空位,再把真实 token 的向量逐项求平均。例如,14 组向量的第 1 个数字取平均后成为句子向量的第 1 个数字,其余 383 个位置也做同样处理,最终得到一组 384 维向量。normalize: true 再把这组向量的长度调整为 1,便于后续比较方向。

每输入一段文字,这段代码都会返回一个 384 维向量。索引还会记录模型名称和版本,检索时只比较由同一模型、同一版本生成的向量。

E5 在训练时用不同前缀区分"用户要查什么"和"可供搜索的资料",因此代码需要分别添加 query:passage:

typescript 复制代码
embedQuery(question)  // 实际输入:query: ${question}
embedPassage(chunk)   // 实际输入:passage: ${chunk}

query: 标记后面的文字是用户问题,passage: 标记后面的文字是知识库片段。这两个前缀只参与向量计算,页面仍然展示原文。E5 的模型说明要求检索任务按这种格式输入,省略前缀会使输入格式与训练方式不一致,并可能降低检索效果。

向量检索是怎样排序的

Demo 使用"余弦相似度"比较问题向量和文档向量。它关注两个向量的方向是否接近。A 和 B 分别代表两个向量,程序会对两者对应的维度进行计算:

text 复制代码
cos(A, B) = (A · B) / (||A|| × ||B||)

这项计算会得到 -11 之间的分数。分数越接近 1,两段文字在当前模型下的相关性越高;分数较低,相关性也越弱。

余弦相似度不是唯一的向量比较方式。常见方法还有点积和欧氏距离:点积同时受方向和向量长度影响,欧氏距离衡量两个向量在空间中相隔多远。具体应该使用哪一种,要看 Embedding 模型的训练方式和索引配置。这个 Demo 已通过 normalize: true 把向量长度统一为 1;在这种情况下,按余弦相似度或点积从高到低排列,与按欧氏距离从低到高排列,会得到相同的先后顺序,因此代码选择了容易解释的余弦相似度。

不过,0.86 不表示"答案有 86% 概率正确"。相似度只是当前模型下的排序信号,不同模型、语料和查询的分数分布都可能不同。

此外,向量检索虽然能匹配同义表达,但不擅长保证精确标识符完全一致。ERR_FIN_403ERR_FIN_404 只有最后一位数字不同;如果两者所在片段的其他文字也很相似,纯向量检索可能把它们排得很近。为了让错误码的完整字符串参与排序,Demo 还加入了 BM25。

FTS、倒排索引与 BM25

前面提到 BM25 根据查询词匹配情况打分,但具体怎么找到哪些片段包含查询词?数据量较大的生产系统通常不会像 Demo 那样逐个扫描,而是靠全文检索(Full-Text Search,FTS)加速。FTS 负责切分查询词、通过倒排索引找到包含这些词的片段,再对结果排序。BM25 只是其中的一种排序算法。

这里的"切分查询词"不是前面 Embedding 模型使用的 Tokenizer,而是 Demo 为 BM25 单独实现的简单分词规则。它会把连续中文拆成相邻的两个字,例如"打车费"会得到"打车"和"车费";英文、数字和下划线组成的 ERR_FIN_403 则会保留为一个词项。查询和文档都使用这套规则,程序才能统计同一个词项出现了几次。

生产环境的 FTS 引擎通常有自己的 tokenizer,但"自带 tokenizer"不等于自动适合中文。例如 SQLite FTS5 提供 unicode61trigram 等内置方案,也允许接入自定义 tokenizer;项目仍要根据中文词语、错误码和短语查询的实际效果选择配置。整体思路不变:先把文档和查询按同一规则转换成词项,再通过倒排索引定位候选片段。

FTS 可以改用其他排序算法(如 PostgreSQL FTS 的 ts_rank),BM25 也可以脱离 FTS:本 Demo 就没有 FTS 和倒排索引,而是在用户每次查询时遍历所有片段并逐个计算 BM25 分数。

FTS 先找出候选片段,BM25 再为这些候选打分排序。在 Elasticsearch 等系统中,这两步由一次搜索请求完成。

倒排索引是 FTS 加速查找的核心,它把"片段包含哪些词"的正向关系反过来,存成"词出现在哪些片段"。

以查询 ERR_FIN_403 为例,FTS 的处理流程如下:

以 Demo 的资料为例,倒排索引展开后如下(实际保存的是片段 ID,这里展开为标题和原文方便核对):

text 复制代码
err_fin_403 -> 报销入口("系统错误码 ERR_FIN_403")
报销        -> 市内交通费用("每月报销限额为 800 元")
              报销入口("在首页选择费用报销")
限额        -> 市内交通费用("每月报销限额为 800 元")

索引建立时统一转小写,所以原文的 ERR_FIN_403 可以按 err_fin_403 查到。用户查询 ERR_FIN_403 时,FTS 直接从索引中取出对应的片段,不必逐个检查其他片段。如果查询包含多个词、命中了多个片段,BM25 再计算每个候选的相关性分数并完成排序。SQLite FTS5 和 Elasticsearch 的全文检索可以使用 BM25,PostgreSQL FTS 则提供 ts_rankts_rank_cd 等其他排序函数。

相比之下,Demo 没有实现 FTS,也没有建立倒排索引,而是采用下面这条流程:

flowchart LR Q[查询] --> S[遍历全部 chunk] S --> B[逐个计算 BM25 分数] B --> R[按分数排序]

Demo 采用全量扫描,首先是因为当前数据量很小,逐个检查片段不会造成明显延迟。其次,文章需要直接展示分词、词项统计和 BM25 分数;接入 FTS 引擎后,这些步骤会被封装在数据库或搜索服务内部。项目也不必为教学代码增加一套倒排索引的增删更新逻辑。

这样写让读者可以直接看到 BM25 的计算过程,但也带来了限制:文本片段增多后,逐个扫描会越来越慢,Demo 也没有短语查询、前缀查询和结果高亮等完整的全文检索能力。进入生产环境或数据量明显增加后,可以改用 SQLite FTS5、PostgreSQL FTS 或 Elasticsearch。

这些 FTS 系统会返回一份按关键词相关性排列的结果,向量检索则返回另一份按语义相似度排列的结果。所谓"把关键词结果与向量结果融合",就是把同一个片段在两路检索中的分数合并,再生成一份新的总排名;并不是先用 FTS 查完,再只对 FTS 的结果做向量检索。

了解 FTS 和 BM25 的分工以后,再看 BM25 如何计算关键词相关度。它会综合下面三个因素:

  • 词频(Term Frequency,TF):统计查询词在当前片段中出现几次。一个片段多次出现查询词,通常比只出现一次更相关;但从第十次增加到第十一次,不会获得与第一次出现同样多的加分。
  • 逆文档频率(Inverse Document Frequency,IDF):统计一个查询词出现在多少个片段中。包含它的片段越少,这个词越能帮助定位,IDF 权重就越高。例如 ERR_FIN_403 只出现在少数片段中,权重较高;"报销"出现在很多片段中,无法单独定位某一段,所以"报销"的权重较低。这两个例子分别说明高权重和低权重。
  • 文档长度归一化:同一个词在一段很短的说明中出现一次,通常比它在一本很长的手册中偶然出现一次更能说明主题相关。BM25 会根据当前片段长度和平均片段长度修正分数,避免长片段仅仅因为包含的词多就占优势。

把这三个因素写成公式如下:

text 复制代码
score(D, Q) = Σ IDF(q) × f(q,D) × (k1 + 1)
                         -------------------------------
                         f(q,D) + k1 × (1 - b + b × |D|/avgdl)

公式中的符号含义如下:

  • D 是当前片段,Q 是用户查询。
  • q 是查询中的一个词,f(q,D) 是这个词在当前片段中的词频。
  • |D|/avgdl 是当前片段长度与平均片段长度的比值。
  • k1 控制词频增加到多少次后逐渐停止明显加分。
  • b 控制片段长度对分数的影响程度。

Demo 使用 k1 = 1.2b = 0.75。这是 BM25 的常见默认配置,实际项目仍要根据检索评估结果调整。

BM25 只能统计已经切分好的词,因此计算前要先分词。英文句子通常有空格,例如"travel expense"可以直接切成"travel"和"expense"。中文句子没有空格,"打车费"究竟应该切成"打车费""打车 + 费"还是"打 + 车费",需要分词器决定。

为了避免额外引入中文分词库,本项目采用了一个简单规则:连续中文每相邻两个字组成一个二元词组,并向后移动一个字继续切分。例如:

text 复制代码
打车费超过八百块应该怎么办?
    ↓
打车 | 车费 | 费超 | 超过 | 过八 | 八百 |
百块 | 块应 | 应该 | 该怎 | 怎么 | 么办

这里的"二元"就是"两个字符",所以"打车费"会得到"打车"和"车费"。英文、数字和下划线连续组成的内容则作为一个完整词项,并统一转成小写。ERR_FIN_403 会变成"err_fin_403",不会被拆成"err""fin"和"403"。

这种切法容易实现,也能完整匹配错误码,但它不识别词义和词语边界。例如,"费超"只是相邻字符,不是一个有意义的中文词;"超过"出现在不同主题的片段中时,也可能产生不合理的高分。后文的小节"一次实际的检索调试"中的实测结果就出现了这个问题:由于中文二元词组把"超过"拆成了一个有效词项,"住宿标准"片段获得了最高的 BM25 分数,甚至压过了向量检索排在第一的正确片段。

向量与 BM25 的混合检索

向量检索与 BM25 解决的是两类不同问题:

查询 更有价值的信号
"打车费超额怎么办"匹配"市内交通费用超过限额" 向量语义
ERR_FIN_403、合同号、函数名 BM25 精确词项
用户和原文使用不同语言或同义词 多语言向量模型
少见编号在长文档中只出现一次 BM25 的 IDF

向量检索和 BM25 会给同一个片段算出两种分数,但这两种分数不能直接相加。余弦相似度通常小于 1,BM25 分数却可能是 2、6 或更大。如果直接计算 0.86 + 6.2,BM25 仅仅因为数值范围更大就会控制最终排名。

Demo 先让两条分支各取 2 × Top-K 个候选。默认 Top-K = 5,所以向量检索和 BM25 最多各交出 10 个片段。程序按照片段 ID 合并两份列表:同一个片段如果两边都出现,只保留一条记录并同时保存两路分数;只在一路出现的片段,另一路分数记为 0。这份去重后的列表就是两路候选的并集。

接下来,程序把每一路的最高分换算成 1,其余分数按相同比例缩小。这个过程称为归一化:

text 复制代码
vectorNorm = max(0, vectorScore) / maxVectorScore
bm25Norm   = bm25Score / maxBm25Score
finalScore = 0.7 × vectorNorm + 0.3 × bm25Norm

然后再按照默认权重计算总分:向量分占 70%,BM25 分占 30%。这里的权重表示两路分数对排序的影响大小,不是答案正确率。

程序会对候选列表中的每个片段执行这套计算,按最终分重新排序,再返回前 K 条。下一节会用一组实际结果说明:向量分较高的正确片段,为什么可能被同时获得两路加分的错误片段超过。

核心合并逻辑可以概括为:

typescript 复制代码
const vectorContribution = normalizedVectorScore * vectorWeight;
const keywordContribution = normalizedKeywordScore * keywordWeight;
const finalScore = vectorContribution + keywordContribution;

页面会同时展示原始分、归一化分、加权贡献和最终分,方便检查某个片段为什么排在当前位置。权重设为 0 时,对应的检索分支不会执行。生产系统还可以使用校准后的分数,或者改用只参考两路名次的倒数排名融合(Reciprocal Rank Fusion,RRF)。

RAG 一定需要向量数据库吗

不一定。RAG 需要的是检索能力,既不强制使用专用向量数据库,也不强制只用向量检索。BM25、SQL、知识图谱和搜索 API 都可以承担 Retrieval。

这个 Demo 把原文、384 维向量和来源信息保存在 data/vectors.json。服务启动后,Node.js 将全部记录读入内存;查询时,向量分支和 BM25 分支都扫描这批记录,再合并两路结果。vectors.json 只负责持久化,本身没有搜索能力。

这种方式适合小型 Demo,因为数据结构和计算过程都能直接看到。代价是 chunk 越多,启动时读取的数据越多,每次查询要比较的记录也越多。是否升级到带索引的存储方案,应由数据量、延迟、并发和权限过滤要求决定。

方案 适合场景 查询方式
内存数组或 JSON + 内存 单元测试、小型 Demo 应用进程全量扫描
浏览器 IndexedDB 纯本地个人资料工具 浏览器扫描,或另加索引库
SQLite + sqlite-vec 桌面应用、单机服务 SQLite 计算并返回 Top-K
PostgreSQL + pgvector 已有业务数据库,需要事务和字段过滤 数据库执行向量搜索
Qdrant、Milvus、Weaviate、Pinecone 等 大规模、高并发、独立检索服务 服务管理索引、过滤与分片

例如,使用 pgvector 后,可以让数据库先按租户过滤,再完成相似度排序:

sql 复制代码
SELECT id, content, source
FROM chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 5;

应用提交查询向量后只接收前 5 条,不必把整张向量表传给 Node.js。当前 Demo 只有少量资料,使用 JSON 和内存扫描已经足够,也更适合展示混合检索的内部过程。

一次实际的检索调试

为了检查上述融合方式的实际效果,下面直接看只包含 14 个内置 chunk 时的页面运行结果。查询使用页面默认参数:Top-K = 5,向量/BM25 权重为 0.7/0.3

这里沿用前面的查询:

打车费超过八百块应该怎么办?

完整的 Top-5 如下:

名次 片段 余弦原始分 向量归一化 BM25 原始分 BM25 归一化 最终融合分
1 住宿标准 0.843634 0.960091 2.613690 1.000000 0.972064
2 票据期限 0.878703 1.000000 2.048268 0.783669 0.935101
3 市内交通费用 0.872662 0.993126 0 0 0.695188
4 报销入口 0.854296 0.972224 0 0 0.680557
5 加权融合 0.838667 0.954437 0 0 0.668106

结果并不漂亮:正确的"市内交通费用"排在第 3。"住宿标准"同时含有"超出标准",中文二元词组使它借助相近词项获得最高 BM25 分;归一化之后,这个片段又得到 0.3 的关键词贡献,最终超过了相关性更高的交通片段。

BM25 的原始分数会随着索引内容变化,因为 IDF 需要统计一个词出现在全部 chunk 中的比例。这里的 2.6136902.048268 只对应当前 14 个内置 chunk;如果加入用户上传的文档,即使查询和参数不变,BM25 原始分和最终排名也可能变化。

这个结果说明,不能根据一条回答是否通顺来验收 RAG。当前 Top-5 仍包含正确资料,所以 LLM 还有机会回答正确;但噪声已经进入上下文,换一个 Top-K 或换一批文档就可能漏掉答案。针对这个问题,可以从以下几个方向改进:

  • 用更合适的中文分词器,减少跨词边界的伪命中。
  • 降低当前语料上的 BM25 权重,或在验证集上重新调参。
  • 先找出更多候选,再增加重排模型(reranker),让它对候选做一次更精细的排序。
  • 调整种子资料和分块,避免标题或短片段产生异常高分。
  • 使用倒数排名融合(Reciprocal Rank Fusion,RRF)等更看重排名、较少受原始分数影响的合并方法,并用评估集验证。

这一节先把一个排序异常解释清楚。其他三个页面示例题会放到后面的评估章节中统一比较,避免在不同索引条件下重复测试。

把检索结果交给 LLM

检索完成后,服务端会给本次回答中的每个去重片段分配编号,再把下面这样的文本交给 LLM。按照前面的实际排名,"市内交通费用"是 [来源 3]

text 复制代码
[来源 1]
文件:星河科技差旅与交通报销制度
标题:住宿标准
内容:上海地区住宿标准......

[来源 3]
文件:星河科技差旅与交通报销制度
标题:市内交通费用
内容:员工因公产生的市内交通费用......

这些片段写入提示词后,系统会要求 LLM 只根据资料陈述事实,并为结论标注 [来源 N]。如果资料不足,LLM 应说明缺少什么,不能使用模型记忆补全。此外,知识库文本按不可信数据处理,其中即使出现"忽略系统规则"之类的提示词,也不能覆盖系统指令。

在这些约束下,模型可能返回下面的答案:

超出每月 800 元限额的部分,需要部门负责人审批,并在报销单中说明业务原因。来源 3

与此同时,前端还会收到本轮累计检索到的去重来源、厂商与模型名,以及 Agent 步骤数和检索次数。这里的 sources 是"曾交给模型的候选资料",不等于模型在答案中实际引用了每一条;当前代码也没有自动验证 [来源 N] 是否存在或是否支撑相邻结论。用户可以手动核对,生产系统则应把引用有效性加入后文的自动评估与监控。

用自己的资料验证

内置数据只能证明程序可以运行,无法代表用户自己的资料也能得到同样效果。因此,还要上传自己的文档来检查分块和检索结果。页面支持 Markdown、TXT、文本型 PDF 和 DOCX 四种格式。

服务端解析文件、提取文本并分块,再调用本地 ONNX 模型生成向量并写入索引。PDF 必须带有可以直接复制和搜索的文本层;只有图片的扫描件需要先做光学字符识别(Optical Character Recognition,OCR),把图片中的文字识别出来。

sequenceDiagram actor U as 用户 participant UI as 网页 participant S as 应用服务端 participant M as 本地 ONNX participant I as 本地索引 U->>UI: 选择文件 UI->>S: 上传 S->>S: 提取文本并分块 S->>M: 批量生成向量 M-->>S: 384 维向量 S->>I: 文本、向量与来源 I-->>UI: 文档与 chunk 统计

从固定 RAG 到模型自主检索

前面的流程由应用预先决定检索策略:收到问题后固定执行混合检索,取出 Top-K,再调用 LLM。检索器、参数和调用次数都写在应用代码中,这里称为固定 RAG。

模型自主检索(Agentic Search)把部分控制权交给模型。在完整的 Agent 系统中,模型可以根据当前结果决定接下来使用哪种搜索工具、搜索什么内容以及是否继续。这里描述的是一种通用架构,不代表当前 Demo 已经实现了所有这些能力。

两种方式都会用外部资料增强生成,区别在于谁决定检索步骤。相关术语并未完全统一,同一种结构也可能被称为 Agentic RAG 或 Agentic Retrieval;本文只用"固定 RAG"和"Agentic Search"区分控制流。

当前 Demo 的能力要简单得多。/api/ask 先用固定参数执行混合检索,再把结果交给 LLM;同时,它把同一套混合检索包装成一个名为 search_knowledge_base 的工具。LLM 如果认为初始资料不够,可以换一种问法调用这个工具,服务端再补充一批片段。

因此,当前 Demo 只能让模型决定"是否用新的查询再次搜索知识库"。它不能在 SQL、grep、文件读取和向量搜索之间自主选择,也没有实现代码调用链分析。页面上的"Agent 步骤数"和"检索次数"只是用来显示这一轮实际调用了几次 LLM 和知识库搜索。

什么时候选择哪种检索方案

可以直接根据资料类型和问题特点选择:

要解决的问题 优先考虑的方式
按订单状态、日期、用户 ID 等字段筛选数据 SQL
查找错误码、合同号、函数名等准确字符串 BM25、grep 或其他关键词检索
用户用自然语言提问,原文可能使用不同说法 向量检索
既要理解自然语言,又要匹配准确编号 向量检索与 BM25 组合
必须根据第一次结果才能决定下一步查什么 在更完整的系统中考虑 Agent 工具;当前 Demo 只支持再次调用同一个混合检索工具

对当前 Demo 这类制度问答,通常先取一次 Top-K 就能生成答案,固定混合检索应当是主要流程。额外搜索只用于初始片段确实缺少答案的情况,例如一个问题同时询问报销限额和错误码含义,而初始 Top-K 只覆盖了其中一部分。此时模型可以把缺少的部分改写成新查询,再调用同一个混合检索工具。

如果实际业务还需要查询数据库、搜索代码或读取指定文件,可以在更完整的系统中把 SQL、grep 和文件读取分别做成工具,让模型根据上一步结果决定下一步操作。这已经超出当前 Demo 的能力范围。多步工具调用会增加响应时间和费用,因此只有一次固定检索无法完成任务时才需要采用。

代码 Agent 为什么采用不同的搜索方案

下面三个例子的作用不是比较产品好坏,而是说明代码搜索没有唯一方案。代码库是否经常变化、规模有多大、能否建立索引,以及用户是在查准确名称还是自然语言概念,都会影响技术选择。

产品 公开资料描述的做法 对检索选型的启示
Claude Code 主要让模型调用 Glob、Grep、Read 等工具直接查看当前代码;早期尝试过语义索引,后来没有继续采用 代码变化频繁时,直接搜索当前文件可以省去索引同步,但模型可能需要多轮查找
Cursor 同时使用 grep 和语义搜索,并增量更新代码索引 精确名称交给 grep,自然语言问题交给语义搜索,两种方式可以互补
GitHub Copilot 在支持的仓库上下文中建立语义代码搜索索引,也会按需执行语义搜索 是否预先建索引取决于仓库环境、平台能力和组织策略

这些公开资料来自不同时间、不同产品和不同测试条件,不能用来得出谁的搜索效果更好。它们真正说明的是:项目应该根据自己的代码规模、更新频率、数据边界和测试结果选择直接搜索、语义索引或两者组合。

怎样判断 RAG 是否有效

页面能够返回答案,只能说明程序已经运行起来,不能说明答案可靠。即使接口状态是 200、相似度是 0.86、答案后面写着 [来源 1],仍然可能发生以下问题:

  • 找到的是不相关的资料。
  • 找到了正确资料,但把关键条件漏掉了。
  • 资料里没有答案,模型却生成了资料之外的内容。
  • 答案内容可能正确,但标注的来源不能支持它。

评估时需要分开检查两个环节:

  1. 检索:正确片段是否进入 Top-K,排名是否足够靠前。
  2. 生成:LLM 是否根据这些片段回答,是否遗漏条件、添加了资料中没有的内容,引用能否支撑结论。

检索失败和生成失败的修复位置不同。正确片段没有进入 Top-K 时,应检查分块、Embedding、关键词检索和排序;正确片段已经交给 LLM 而答案仍然出错时,再检查提示词、模型和引用处理。

准备带标准答案的测试题

评估集(eval set,也叫 golden set)由测试问题、正确来源、答案要点和可回答性组成。每道题都要提前写明判定标准,否则不同版本之间无法按同一规则比较。

本文后面的实测只使用项目内置资料,不包含用户上传的文档。这些资料不是项目目录里的独立文件,而是开发者直接写在 packages/server/src/seedDocuments.ts 中的 4 段示例文本。运行 npm run ingest 时,程序会自动把它们加入索引。星河科技也是虚构名称,它的差旅制度只用于演示问答。

页面上的三道可回答示例题都在询问星河科技的报销制度,所以使用页面时最容易注意到这段资料。另外三段也由程序自动加入,只是分别用于回答 RAG 原理、检索方式和模型配置方面的问题。四段文本的用途如下:

代码中预置的示例文本 为什么放进 Demo 包含的小标题
RAG 基础知识(内置) 测试能否回答"RAG 是什么"等问题 定义、三个阶段、数据更新、向量数据库
混合检索说明(内置) 测试能否回答向量检索和 BM25 方面的问题 向量检索、BM25、加权融合
星河科技差旅与交通报销制度 为页面上的报销示例问题提供答案 市内交通费用、票据期限、住宿标准、报销入口
Demo 生成模型配置(内置) 说明 Demo 怎样调用生成模型以及怎样保存 API Key 工作方式、支持的厂商、API Key

分块时,每个小标题和它下面的正文会成为一个可以单独搜索的片段,也就是一个 chunk。四段文本分别产生 4、3、4、3 个 chunk,加起来是 4 + 3 + 4 + 3 = 14。因此,"14 个内置 chunk"指 4 段示例文本被拆成的 14 个可搜索片段。

npm run ingest 只会重新生成这些内置片段,不会删除用户已经上传的文档,所以 14 是内置片段的数量,不一定是当前索引的总数。页面预设了 4 个示例问题,前三道能从差旅制度中找到答案,第四道用于检查资料不足时能否拒答:

测试问题 知识库能否回答 应该找到哪份资料 答案必须包含什么
打车费超过八百块应该怎么办? 差旅制度 > 市内交通费用 800 元、部门负责人审批、说明业务原因
ERR_FIN_403 是什么意思? 差旅制度 > 报销入口 没有报销权限、联系部门管理员
上海住宿一晚最多报多少? 差旅制度 > 住宿标准 600 元/晚、超标前置申请
公司的年假有多少天? 无对应来源 说明资料缺少年假天数

前三道题用于计算后文的检索命中指标;"公司的年假有多少天?"用于单独检查拒答。

正式评估还应覆盖错别字、简称、不同表述、过期资料、相互冲突的资料和无权查看的资料。例如,"上海酒店能报几钱""魔都住宿标准"和"上海出差住店上限"可能都在问同一件事。

当前 Demo 只有 4 道示例题,数量太少,不适合拆成两组。正式项目积累了足够多的测试题后,可以这样做:假设共有 100 道题,先用其中 80 道反复调整分块方式和检索权重;剩下 20 道在调参期间不用,等方案确定后再做最终验收。

如果工程师看着全部 100 道题的成绩反复改参数,最后选出的参数可能只适合这些已经见过的题。换成新的用户问法,检索效果仍然可能很差。留出 20 道验收题,就是为了检查参数能否处理没有参与调优的问题。

测试题还应单独保存在文件中,例如 eval-v1.json。文件里记录每道题的正确来源、答案要点和可回答性;增加或修改题目时再生成 eval-v2.json。这样可以确认两次测试是否使用了同一批题。正确来源最好记录为"文档名称 + 小标题或一小段原文",不要只记录 chunk ID,因为重新分块后 ID 可能改变。

先检查搜索有没有找到正确资料

RAG 回答错误,可能是搜索没有找到正确资料,也可能是 LLM 没有正确使用资料。为了先排除第一种问题,这一步暂时不看 LLM 的回答,只检查搜索结果。

测试前要为每道题指定一个"必须找到的片段":

问题 必须找到的片段
打车费超过八百块应该怎么办? 市内交通费用
ERR_FIN_403 是什么意思? 报销入口
上海住宿一晚最多报多少? 住宿标准

测试时只保留 14 个内置 chunk,不加入用户上传的文档,并固定 Top-K = 5、向量/BM25 权重为 0.7/0.3。程序分别搜索这三道题,结果如下:

问题 排在前面的搜索结果 正确 chunk 的名次
打车费超过八百块应该怎么办? ① 住宿标准;② 票据期限;③ 市内交通费用(正确) 3
ERR_FIN_403 是什么意思? ① 报销入口(正确) 1
上海住宿一晚最多报多少? ① 住宿标准(正确) 1

"打车费"这道题的正确片段排在第 3;ERR_FIN_403 和"上海住宿"两道题的正确片段都排在第 1。下面用三个数字概括这个结果:

text 复制代码
Hit@1 = 2 / 3 = 0.667
Hit@3 = 3 / 3 = 1.000
MRR   = (1/3 + 1 + 1) / 3 = 0.778

Hit@1 = 2/3 表示只查看每道题的第 1 条结果时,三道题中有两道能够直接找到正确片段。Hit@3 = 3/3 表示查看每道题的前 3 条时,三道题都能找到正确片段。

MRR 用来区分正确片段排在第 1 还是第 3。排第 1 记 1 分,排第 3 记 1/3 分,再对三道题取平均,所以这里的 MRR 是 0.778。这个数字越接近 1,表示正确片段通常出现得越靠前。

当前 Demo 会把前 5 条结果交给 LLM,因此正确片段如果没有进入前 5 条,LLM 就无法依据它回答,这是最基本的底线。财务制度涉及金额和审批条件,还可以提出更严格的要求,例如关键问题的正确片段必须进入前 3 条,并且新版本不能比旧版本退步。

这三道题只能用来演示计算方法,数量不足以判断系统能否上线。正式测试还要增加更多真实问法,并记录资料版本、Embedding 模型版本、分块方式、Top-K、检索权重和代码版本,才能正确比较两次结果。

再检查最终回答

即使检索命中正确片段,LLM 仍然可能回答错误。因此,对于每个答案都需要检查以下内容:

检查项 要问的问题
答案是否正确 金额、日期、错误码和结论是否符合标准答案?
是否漏掉重点 测试题要求的关键条件是否都说到了?
是否忠于资料 答案中的每个事实能否在本次找到的资料里找到依据?
引用是否有效 [来源 N] 是否存在,而且真的支持它前面的结论?
拒答是否正确 知识库没有答案时,模型是否说明资料不足?

有些工具把"是否忠于资料"称为 faithfulness 或 groundedness,检查的是模型有没有说出资料中不存在的内容。

"忠于资料"不等于"事实正确"。如果知识库里保存的是过期制度,模型即使逐字复述,答案仍然会错。因此,测试使用的文档还要经过有效性和版本检查。

容易判断的内容可以直接用代码检查,例如引用编号是否存在、答案是否为空、是否包含正确金额。比较灵活的自然语言答案可以由人工抽查,也可以让另一个 LLM 按固定规则协助评分。不过,LLM 评审也会出错,不能把它当作绝对正确的裁判;重要问题仍要保留人工复核。

为整条链路设定通过条件

对最终用户来说,一道题只有同时满足下面几项才算真正成功:

  1. 搜索模块找到了正确资料。
  2. 答案包含必须说明的要点。
  3. 答案没有编造资料之外的事实。
  4. 引用确实能够支持答案。
  5. 响应时间、费用和资料权限符合要求。

高风险问题还应该设置"一票否决"。例如金额、日期、身份或权限只要有一项说错,整道题就算失败,不能因为语言流畅或其他分数较高就算通过。

修改系统后,应使用同一套测试题与旧版本比较。例如分别测试纯向量、纯 BM25 和混合检索,或者只改变 Top-K,再观察结果。一次只改变一个主要条件,才知道提升或退步是由什么造成的。

RAG 没有适用于所有公司的统一及格分数。一个内部闲聊助手和一个财务制度助手,犯错的后果完全不同。项目应根据自己的风险决定发布条件,例如:关键问题必须全部答对;无法回答的问题不能编造;普通问题的命中率不能低于旧版本;响应时间不能超过产品预算。这些是项目自己的验收规则,不是行业统一标准。

当测试失败时,可以按下面的顺序排查:

看到的现象 先检查什么
原文没有出现在任何可搜索片段中 文档是否解析成功、分块是否合理、索引是否更新
正确片段存在,却没有排进前几名 向量模型、关键词搜索、查询改写和排序权重
正确片段已经交给 LLM,答案仍然错误 提示词、模型、资料冲突和拒答规则
答案正确,但引用对不上 来源编号映射和引用检查
测试环境正常,线上偶尔失败 文档或模型版本、真实用户问法、外部服务错误和响应延迟

上线以后怎样监控 RAG

离线评估在发布前或版本比较时运行,用带标准答案的测试题检查已知问题是否退步。线上监控记录系统处理用户请求时的耗时、错误、检索结果和反馈,用来发现测试集尚未覆盖的问题。

线上请求通常没有参考答案,所以看板无法自动判断每个回答是否符合事实;离线测试集又只覆盖已经收录的问法。两者需要这样配合:发布前用离线评估拦截已知回归,上线后从异常请求和用户反馈中抽样,由人工确认问题,再把脱敏后的失败样本加入评估集。

每个线上请求都应保存一条 trace(调用链记录)。它用同一个请求 ID 串起 Embedding、检索、重排、工具调用和 LLM 生成等步骤;其中每个步骤称为一个 span(步骤记录)。

一条 trace 要记录什么

下面这些字段分别用于复现请求、定位故障和统计趋势:

记录对象 至少保存什么 用于回答什么问题
请求与版本 requestId、时间、环境、应用版本、会话 ID;用户或使用方采用不可逆标识 哪个版本、哪类用户遇到了问题?
知识库 语料快照、文档更新时间、Embedding 模型与 revision、分块和索引版本 当时检索的是哪一版资料,能否复现排名?
检索输入 原始查询或脱敏摘要、改写后的查询、Top-K、两路权重、过滤条件 查询是否改写错误,权限或过滤条件是否排除了正确资料?
检索输出 chunk/source ID、排名、原始分、归一化分、最终分、结果数量和每次检索耗时 正确片段在哪一步掉出 Top-K,延迟是否来自检索?
生成过程 prompt 版本、模型版本、实际送入的来源 ID、工具调用及步骤数 LLM 接收了哪些资料,是否发生重复搜索或多余调用?
运行结果 总耗时、分阶段耗时、token、估算成本、错误类型、回答或拒答类别、引用 ID 请求为何失败或变慢,成本由哪一步产生?
质量标签 用户反馈、改写后重问、转人工、自动评审结果和人工复核标签 哪些请求需要抽样复核,哪些失败应加入评估集?

问题、文档片段和回答可能包含私有数据。生产环境不应默认把所有原文、完整 prompt、向量或模型隐藏推理写进日志;应按数据分类决定是否脱敏、截断、加密或只保存 ID,并设置访问控制和保留期限。调试所需的可见性与数据最小化需要一起设计。

看板和告警应该看什么

前面的 Hit@K、MRR 等指标需要预先知道正确 chunk,适合离线测试。线上请求通常没有标准答案,因此看板不能直接显示"回答正确率"。看板先负责发现系统在哪个环节出现异常,再从异常请求中抽取 trace 做人工检查。

可以按照一次请求经过的顺序安排看板:

想确认什么 看板记录什么 出现异常时先检查什么
资料是否已经进入索引 解析失败的文档数、已索引文档数、chunk 总数、最近一次索引时间 文档上传或解析是否失败,索引任务是否中断
搜索服务是否正常 检索报错比例、没有返回任何 chunk 的请求比例、检索耗时、Agent 是否反复搜索 检索服务是否超时,过滤条件是否把资料全部排除
LLM 是否正常生成 模型报错和超时、引用编号是否存在、系统拒答的请求比例、每次请求消耗的 token 和费用 模型服务是否异常,引用编号是否映射错误,提示词是否导致过度拒答
用户和权限是否出现问题 点赞/点踩、用户是否马上换一种问法重问、转人工次数、是否检索到越权资料 回答是否难以使用,权限过滤是否失效

检索耗时不能只看平均值,因为少量特别慢的请求会被平均值掩盖。常用的 P95 可以这样理解:把 100 次请求按耗时从快到慢排列,第 95 个请求的耗时就是 P95。如果检索 P95 是 800 毫秒,大约有 95 次请求能在 800 毫秒内完成,剩下 5 次更慢。产品可以据此规定"检索 P95 不得超过 800 毫秒"之类的目标。

除了基本的报错和延迟之外,看板还可以记录 Top-1 向量相似度和拒答率,但这两个数字只能帮助筛选问题,不能直接判断回答是否正确。

这里的 Top-1 向量相似度,是向量检索第一名的余弦相似度,不是答案正确率。只有使用相同的 Embedding 模型、相近的资料和相近的问题时,前后分数才适合比较。更换 Embedding 模型以后,计算分数的标准也变了,此时不能把新旧分数直接对比。

拒答率是系统回答"现有资料不足"的请求所占比例。拒答率上升可能有两种原因:检索出现问题,导致系统没有找到原本存在的资料;或者用户开始询问知识库没有收录的新问题。工程师需要抽查对应的 trace,才能区分这两种情况。点赞、点踩和"没有继续追问"也一样,只能说明用户的行为,不能证明答案符合事实。

设置告警时,不要把所有请求混在一起取平均。看板至少要能按应用版本、索引版本、使用方、语言和问题类型筛选。例如,索引更新后只有某家公司的中文财务问题出错,全站平均值可能变化很小,分组后才能看到问题集中在哪里。

告警条件应写清楚发生了什么,以及需要检查哪个环节。例如:

  • 更新索引后,固定测试题的 Hit@5 低于上一版本,先停止发布并检查新索引。
  • 不存在的来源编号突然增多,先检查来源编号的生成和映射。
  • 检索 P95 连续超过产品规定的时间,先检查检索服务以及保存索引的数据库或文件。
  • 文档数或 chunk 数在没有更新资料时突然减少,先检查索引任务是否失败。

发布新版本时,可以分三步进行:

  1. 先运行带标准答案的离线测试,未达到要求就停止发布。
  2. 风险较高的改动先使用影子流量:新版本处理少量线上问题,但它生成的答案不展示给用户。工程师只比较新旧版本的耗时、报错和检索结果。
  3. 影子测试没有发现明显错误后,再让一小部分用户收到新版本答案,继续观察反馈和异常请求。

新索引、prompt 和生成模型最好分开发布。一次只改一项,出现问题时才能判断原因。

现成工具分别负责什么

指标计算、trace 的字段约定和可视化平台属于不同层次:

类型 代表方案 作用与边界
评估指标/库 RAGAS、自定义规则和信息检索指标 计算忠实度、上下文质量和检索指标;部分指标不要求参考答案,但仍依赖评分模型、项目规则或人工标签,不能单独证明事实正确
通用遥测 OpenTelemetry + 指标/日志/trace 后端 采集耗时、错误和调用链;RAG 专用字段仍要按实际管道补充
AI trace 字段约定 OpenInference 在 OpenTelemetry 之上规定检索、重排、Embedding 和 LLM 等步骤怎样记录,并统一文档 ID、分数等字段
LLM 应用平台 LangSmith 提供 trace、数据集、离线/在线评估、反馈、看板和告警
开源可自托管平台 Arize Phoenix 通过 OpenTelemetry/OpenInference 接收 trace,并提供评估、数据集和版本实验

例如,LangSmith 或 Phoenix 可以把一次请求的查询、检索结果、prompt 和模型输出放在同一个页面中。调用记录只能说明系统当时使用了哪份资料,不能判断这份资料现在是否仍然有效。如果知识库没有记录"星河科技差旅制度"的生效日期、失效日期或当前版本,平台就无法自动判断它是否已经过期。判断回答是否正确,仍要依赖经过确认的有效文档、参考答案、代码规则或人工复核。

选型时先确认私有问题和文档片段能否发送到第三方、是否需要自托管、TypeScript SDK 能否覆盖当前调用链、采样成本如何控制,以及原始 trace 能否导出。无论使用哪种平台,业务字段都应保持稳定并记录语义约定版本,避免平台升级后无法比较历史数据。

成本与边界

RAG 的成本不只有 LLM 处理文本所消耗的 token。建立索引需要解析文档并生成向量,用户查询时还要完成检索和排序。如果允许模型循环调用工具,同一个问题可能触发多次检索和模型调用。

Top-K 太小容易漏掉正确片段,太大则会把更多无关内容交给模型。文本片段太小,条件和结论可能被拆开;片段太大,又不容易准确定位答案。每增加一个向量数据库、Embedding 模型或重排模型,都应该用测试结果证明它解决了具体问题。

这个 Demo 没有加入 OCR、独立重排模型、近似最近邻索引(Approximate Nearest Neighbor,ANN)、权限系统和多租户能力,也还没有评估运行器、持久化 trace、质量看板和告警。这个项目主要用于理解 RAG 流程和制作小规模原型;进入生产环境前,还要根据实际数据量、质量目标和权限要求补齐相应能力。

总结

RAG 由检索和生成两个环节组成,向量数据库只是检索层的一种常见实现。

Embedding 负责把文本变成可比较的向量;Chunking 决定可检索片段的颗粒度;向量搜索擅长语义,BM25 擅长精确词项;融合与 Top-K 决定哪些资料进入上下文;LLM 则负责在明确边界内生成答案和引用。

可靠的 RAG 系统需要给出答案,也要保留检索结果、排序分和引用位置,方便用户核对。资料不足时,系统应该明确拒绝回答。本文的实测中,默认混合权重曾把错误片段排到第一,说明检索质量需要单独测量,参数也要根据评估结果调整。上线后还要结合 trace、看板、告警和抽样复核继续发现新问题;一次回答流畅、引用格式正确,仍不足以证明系统可靠。

在更复杂的系统中,如果固定的一次检索不足以完成任务,可以把 BM25、向量搜索、SQL、grep 和文件读取分别做成 Agent 工具。当前 Demo 没有实现这套多工具架构,只提供了一个可再次调用的混合检索工具。无论采用哪种方式,最终都要检查系统能否在可接受的成本和权限边界内找到正确资料,并忠实地使用这些资料。

参考资料

相关推荐
wuqingshun3141598 小时前
SpringBoot是如何实现自动配置的
java·spring boot·后端
zhouhui0018 小时前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
●VON8 小时前
鸿蒙 PC Markdown 编辑器质量流水线:Web 构建、回归与 Release 门禁
前端·华为·编辑器·harmonyos·鸿蒙
acheding8 小时前
File System Access API 实战:让网页真正读写本地文件
前端·javascript·vue.js·编辑器·markdown
何时梦醒8 小时前
⚛️ React 19 + TypeScript 深度学习笔记 —— 从组件化思维到 WebGPU 端侧 AI 落地
前端·javascript·人工智能
唐老板8 小时前
给 AI 套上缰绳:Harness Engineering 是什么
ai编程
橘子星8 小时前
在浏览器里跑大模型!用 WebGPU 零成本部署 DeepSeek-R1
前端·typescript
ymluo8 小时前
WebRTC M80:VP9 多层(SVC:联播)崩溃问题定位与修复建议
后端
我要割麦子8 小时前
从零到一手撸 Agent 系列 — 第 4 篇:工具的契约 — Tool 接口与注册表
agent·ai编程