前言
从 W1 的流式基石,到 W2 的生产级解析,再到 W3 的 Agent 工具调用,我们的 AI 已经能够感知环境并做出行动。但此时的 Agent 依然是一个"失忆者"------它不知道企业内部的规章制度,不了解私有产品的文档,甚至无法记住用户几分钟前提过的话题。
在 W4 中,我们将从零手写一套完整的 RAG(检索增强生成)引擎!这不仅仅是简单的向量检索,我们一起实现基于 Markdown 层级的语义分块、手写 BM25 算法、倒数排序融合(RRF)混合检索,甚至是一套基于大模型的多轮追问改写与 RAG 质量评估系统(LLM-as-Judge)。只有亲手推导过公式,你才能在面对幻觉时游刃有余。
源码地址:github.com/yuhano1937-...
下面我们开始正式步入本章的学习。
大模型在应用时的困境
在 W1 至 W3 中,我们构建了生产级的流式打字机和能调计算器与 npm 的智能体。但只要应用跨入企业内网,纯语言大模型的底层局限就会瞬间暴发:
-
时效截止线 :大模型的参数在预训练结束那一刻就被静态固化。它不知道昨天的股票收盘点位,不知道公司今天上午刚更新的报销制度,甚至无法确定当前精确的现实时间。
-
企业私有盲区 :你公司的敏感情报、产品代码库、客服 FAQ、内网 Confluence 页面,绝不可能提前被 OpenAI 或开源大模型抓取并预训练进千亿权重中。
-
上下文窗口与成本黑洞 :大模型参数虽然不可改,但有人会问:"现在不是支持 128K 甚至 1M 上下文吗?我把 500 页的 PDF 直接复制粘贴进 Prompt 不就行了?"这种做法在生产环境中绝对是灾难:
- 调用成本爆炸:每一次提问都要反反复复将几十万 Token 传向云端,一次普通的 FAQ 问答成本高达数元人民币;
- 推理延迟激增:首字到达时间(TTFT)从几百毫秒飙升至十几秒,打字机交互完全瘫痪;
- Lost in the Middle :研究表明,当长文本超过一定阈值,大模型对上下文开头和结尾的注意力最集中,而位于正文深处的关键事实会被严重遗忘忽略。
有的同学可能说既然模型没学过我的私有文档,我直接把文档内容写在上下文中就好了。假设一本企业员工手册共 20 万中文字符,约合 15 万 Token。 用户在对话中问了 10 句话,在不拆分检索的情况下,系统将累计向上游传输:
150,000×10=1,500,000 Tokens
这不仅耗尽用户等待的耐心,还会迅速打满企业 API 的 TPM(每分钟 Token 速率上限)导致 429 熔断。
还有的同学可能会想到"那我花钱微调一个专属垂直模型不就行了吗?",面对企业知识库的场景,微调是完全错误的路线。我们看个对比表就一目了然了:
| 比较维度 | 检索增强生成 (RAG) | 模型全量/参数微调 (Fine-Tuning) |
|---|---|---|
| 知识更新周期 | 毫秒级(文档丢进知识库立即建立索引) | 天/周级(需要整理数据、训练、合并权重) |
| 可追溯性 | 精确输出引用来源第几页第几行 | 无法指出某句话出自哪个文档 |
| 准确率与幻觉 | 严格基于检索出的客观段落作答 | 知识被模糊压缩在概率权重中,依然会幻觉 |
| 权限隔离 | 极简单(根据用户部门过滤检索结果列表) | 不可能(模型一旦学会知识,无法针对普通员工隐匿) |
| 计算成本 | 几行 TypeScript 内存索引,近乎零成本 | 昂贵的 GPU 集群算力,单次训练数千至数万元 |
什么是 RAG
它的本质是用软件工程将一个"动态资料库"外挂给大模型。其分为两个阶段:离线索引 和在线检索增强。其中离线索引阶段将私有 Markdown 文档切分成大小适宜的文本块(Chunk),转化为高维数学向量,存入索引引擎。在线检索增强在用户提问时,先通过语义算法和关键词匹配召回最相关的 Top-K 个文档片段,将这些客观片段动态注入 Prompt,最后驱动 LLM 总结出带有真实引用溯源的回答。
项目实战
文档切块
项目依然是在 W3 的基础上进行演进,拷贝一份代码即可接着开发 W4 的练习项目。在了解什么是 RAG 后,我们先来完成文档分块(Chunking)。文档分块是将一篇长篇大论的长文档拆分成多个独立、自包含语义的小文本段落的工程。那么为什么文档一定要切块呢?
面对手头一份 3 万字的企业产品白皮书,初学者的第一个直觉往往是:
"既然我们有 Embedding 接口,为什么不能把这整篇 3 万字的文件当作一个整体,一次性传给 Embedding 模型算出一个向量,然后存进数据库?"
如果真这么做,系统会在检索的第一秒直接崩溃。 为什么?
想象一下,如果你去图书馆想查"如何办理退款",图书管理员递给你一本厚达 2000 页、把公司成立历史、产品架构、员工考勤、离职审批、退款规则全部缝合装订在一起的《企业大百科全书》,并对你说:"都在这里面了,自己翻吧。" 你一定会抓狂,你只需要第 158 页关于"退款政策"的那 200 字段落,而不是要整本书。
同理,大模型也是如此------你如果把 3 万字的整篇百科塞进 Prompt,大模型不仅要耗费大量时间通读长篇废话,还极易产生幻觉,反而忽视了真正关键的退款条款。
在现代深度学习中,Embedding 模型(如 text-embedding-3-small)将文本压缩成一个固定维度的向量(如 1536 维)。当文本短小精悍时向量能极其精准地聚焦在"退款、费用、退单、时限"等核心特征轴上。当文本包罗万象时白皮书里既有"Docker 部署",又有"OAuth 鉴权",还有"发票申请"。当深度学习模型试图用这 1536 个浮点数去概括所有五花八门的语义时,数学上的向量均值池化会把所有鲜明的特征互相抵消,最终坍缩为一个毫无特征区分度的"中庸平庸向量"。
再从物理限制以及成本方面考虑,市面上几乎所有主流 Embedding 模型都有严格的单次输入长度限制(通常为 512 ~ 8192 Tokens)。面对超长的手册,如果不切块,超长部分直接被底层静默截断丢弃。每次问答,系统如果检索出整篇几万字的长文档喂给大模型,用户问一句话就要烧掉数万 Token,单次交互扣费几毛甚至几块钱,首字响应延迟更是高达十几秒。
因此,文档分块 的工程本质,绝非随意切断文字,而是在"语义完整性"与"检索聚焦度"之间寻找最佳的黄金分割点。很多刚入门的开发者最喜欢的写法是按固定字符切分:
ts
const chunks = [];
for (let i = 0; i < text.length; i += 500) {
chunks.push(text.slice(i, i + 500));
}
来看真实案例。假设原文为:
"...我们支持七天无理由退款。但是,若商品防伪贴纸已经破损,则不享受任何退换服务。同时,特殊定制商品不支持退货..."
当按固定 500 字符切分时,机械截断刀恰好落在转折词之间:
- Chunk A :
...我们支持七天无理由退款。但是,若商品防伪贴纸已经 - Chunk B :
破损,则不享受任何退换服务。同时,特殊定制商品不支持退货...
带来的灾难性后果就是:
- 用户提问:"防伪贴纸坏了还能退款吗?"
- 检索器召回了 Chunk A(包含了"七天无理由退款"和"防伪贴纸"),大模型在阅读 Chunk A 时,由于后半句关键限制被一刀腰斩,直接给出致命的相反回答:"支持七天无理由退款!"
从上述的案例我们不难推导出切分的一些原则:
- 必须根据文档格式(Markdown 的标题
#)进行语义自然段切分。 - 必须引入滑动重叠窗口 ,让相邻两个 Chunk 产生重合交叠,杜绝任何关键边界语义被撕裂。
我们要实现一个 Markdown 结构化感知的分块器。逐行扫描 Markdown 文档,利用正则 ^(#{1,6})\s+(.*)$ 捕捉各级标题,当扫描到三级标题 ### 时,动态截断并重构当前标题路径数组 [h1, h2, h3],生成如 产品常见问题 > 退款与计费 > 退款政策是什么? 的面包屑。当单个自然段文本长度突破 maxChunkSize(默认 500 字)时,绝不硬切,而是正则按中文句号、问号、叹号 /[^.。\n]+[.。\n]*/g 进行句子切分,在新 Chunk 开始处,向前回溯 overlapSize 长度的句子进行前置拼接,保证上下文逻辑完美衔接。
在编码前我们先把 TS 的类型先梳理好:
ts
// lib/rag/types.ts
import { ZodSchema } from 'zod';
/**
* 文本块元数据契约
* 必须保留完整的物理溯源坐标,用于后续精准溯源高亮(Citations)
*/
export interface ChunkMetadata {
/** 来源文件相对路径或文件名(如 product-faq.md) */
source: string;
/** Markdown 标题面包屑路径(如 "产品常见问题 > 退款与计费 > 退款政策") */
headingPath: string;
/** 在原始文件中的起始行号(1-based) */
startLine: number;
/** 在原始文件中的结束行号(1-based) */
endLine: number;
/** 文档块在所属文件内的递增序号 */
chunkIndex: number;
}
/**
* 结构化文本分块结果
*/
export interface TextChunk {
/** 全局唯一确定性 ID,格式:${source}#${chunkIndex} */
id: string;
/** 分块正文纯文本 */
content: string;
/** 关联的溯源元数据 */
metadata: ChunkMetadata;
}
/**
* 已向量化的存储记录
*/
export interface VectorDocument {
id: string;
content: string;
/** 1536 维或 1024 维的高维浮点向量 */
embedding: number[];
metadata: ChunkMetadata;
}
/**
* 统一检索输出模型
*/
export interface SearchResult {
id: string;
content: string;
/** 归一化相关性得分(越高越相关) */
score: number;
metadata: ChunkMetadata;
}
建立好了约束,我们开始编写分块器。完整源码 lib/rag/chunker.ts:
ts
// lib/rag/chunker.ts
import { TextChunk, ChunkerOptions } from './types';
/**
* Markdown 结构化感知文本分块器
*/
export function chunkMarkdown(content: string, options: ChunkerOptions): TextChunk[] {
const maxChunkSize = options.maxChunkSize ?? 500;
const overlapSize = options.overlapSize ?? 50;
const chunks: TextChunk[] = [];
let chunkIndex = 0;
const lines = content.split('\n');
let currentHeadingPath: string[] = [];
let currentLines: string[] = [];
let startLine = 1;
// 将缓冲区中的内容组装并刷出成独立块
const flushBlock = () => {
const text = currentLines.join('\n').trim();
if (!text) {
currentLines = [];
return;
}
const heading = currentHeadingPath.filter(Boolean).join(' > ');
const endLine = startLine + currentLines.length - 1;
// 场景 A:当前段落未超长,作为一个紧凑的整体直接输出
if (text.length <= maxChunkSize) {
chunks.push({
id: `${options.source}#${chunkIndex++}`,
content: text,
metadata: {
source: options.source,
headingPath: heading,
startLine,
endLine,
chunkIndex: chunkIndex - 1,
},
});
} else {
// 场景 B:超长自然段,按句子标点进行语义二级切分
const sentences = text.match(/[^.。\n]+[.。\n]*/g) || [text];
let subText = '';
for (let i = 0; i < sentences.length; i++) {
const s = sentences[i];
if (subText.length + s.length > maxChunkSize && subText.length > 0) {
chunks.push({
id: `${options.source}#${chunkIndex++}`,
content: subText.trim(),
metadata: {
source: options.source,
headingPath: heading,
startLine,
endLine,
chunkIndex: chunkIndex - 1,
},
});
// 滑动重叠窗口计算:从当前 subText 尾部提取 overlap 字符作为下一块的开头
let overlapText = '';
const overlapSentences = subText.match(/[^.。\n]+[.。\n]*/g) || [];
for (let j = overlapSentences.length - 1; j >= 0; j--) {
if (overlapText.length + overlapSentences[j].length <= overlapSize) {
overlapText = overlapSentences[j] + overlapText;
} else {
break;
}
}
if (!overlapText && subText.length > overlapSize) {
overlapText = subText.substring(subText.length - overlapSize);
}
subText = overlapText + s;
} else {
subText += s;
}
}
if (subText.trim()) {
chunks.push({
id: `${options.source}#${chunkIndex++}`,
content: subText.trim(),
metadata: {
source: options.source,
headingPath: heading,
startLine,
endLine,
chunkIndex: chunkIndex - 1,
},
});
}
}
currentLines = [];
};
// 逐行扫描 Markdown
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushBlock(); // 遇到新标题,立即刷出前面的未决正文
const level = headingMatch[1].length;
const title = headingMatch[2].trim();
// 根据标题层级更新路径栈
currentHeadingPath.length = level - 1;
currentHeadingPath[level - 1] = title;
startLine = i + 1;
currentLines.push(line);
} else {
if (currentLines.length === 0) {
startLine = i + 1;
}
currentLines.push(line);
}
}
flushBlock();
return chunks;
}
光看上面的代码有些难以理解。我们来逐步分析。假设我们要切分的文档长这样:
markdown
# 售后政策
欢迎来到售后中心。
## 退款制度
### 试用期退款
购买后30天内无条件退款。款项原路退回。
### 逾期退款
超过30天需人工审核。审核通过后按天折算。已满60天不再受理。
## 账号注销
注销后数据彻底清除。
在代码一启动时,你脑海里要想象桌上有 3 个东西:
-
墙上的路标牌子(
currentHeadingPath) :- 它是一个字符串数组,记录我们当前在文档的哪个"树枝"上。
- 比如
['售后政策', '退款制度', '试用期退款']。
-
手里的小推车(
currentLines缓冲区) :- 只要读到普通正文,就先把这行文字扔进小推车里攒着。
-
已经打包好的快递箱堆(
chunks结果数组) :- 每次把小推车里的货打包贴上快递单,推走,变成一个最终的 Chunk。
核心主循环只有一句话:逐行读取这个 Markdown 文件。我们来看看扫描过程:
第 1 步:读到第 1 行 # 售后政策
-
正则匹配到了行首有
1个#,说明这是一个 一级标题(H1,level = 1) 。 -
此时小推车(
currentLines)还是空的,flushBlock()什么也不做。 -
墙上的路标更新为:
['售后政策']。 -
把这一行丢进小推车。
- 此时小推车 :
['# 售后政策']
- 此时小推车 :
第 2 步:读到第 2 行 欢迎来到售后中心。
-
不是标题,是普通文字。
-
直接丢进小推车。
- 此时小推车 :
['# 售后政策', '欢迎来到售后中心。']
- 此时小推车 :
第 3 步:读到第 3 行 ## 退款制度(第一次触发打包)
-
正则匹配到有
2个#,是 二级标题(H2,level = 2) ! -
触发
flushBlock()打包:- 把小推车里的文字拼起来:
"# 售后政策\n欢迎来到售后中心。" - 贴上面包屑标签:
"售后政策" - 生成第 1 个快递箱(
Chunk 0) ,推到成品区! - 清空小推车!
- 把小推车里的文字拼起来:
-
墙上的路标更新为:
['售后政策', '退款制度']。 -
把新标题丢进小推车。
- 此时小推车 :
['## 退款制度']
- 此时小推车 :
很多同学看这行代码 currentHeadingPath.length = level - 1 会卡壳:
ts
currentHeadingPath.length = level - 1;
currentHeadingPath[level - 1] = title;
假设你读到了第 5 行:### 试用期退款(三级标题,level = 3)。 此时墙上的路标数组是:
text
下标 0: "售后政策" (H1)
下标 1: "退款制度" (H2)
下标 2: "试用期退款" (H3)
数组长度 = 3
接下来,第 7 行读到了同级的 ### 逾期退款(也是 H3,level = 3):
length = 3 - 1 = 2➔ 数组被瞬间切断,删掉了末尾的"试用期退款",只剩['售后政策', '退款制度'];path[2] = '逾期退款'➔ 把新标题填上去。- 变成:
['售后政策', '退款制度', '逾期退款']。
那如果突然跳回大标题呢? 比如读到了第 9 行 ## 账号注销(二级标题,level = 2):
length = 2 - 1 = 1➔ 数组直接被砍成只剩 1 项!原来的"退款制度"和"逾期退款"被一瞬间全部削掉 ,只留下了根节点['售后政策']!- 然后
path[1] = '账号注销'。 - 变成:
['售后政策', '账号注销']。
它利用了 JS 修改 array.length 会自动截断数组的特性,实现了"只要同级或上一级标题出现,自动把以前的子标题清空退出栈"。
代码里第二段最难懂的地方(行 42~93)超长文本切分与 Overlap(重叠窗口)到底是怎么运行的?很多文章讲"滑动窗口"讲得像天书,我们直接拿具体的几句话做实验。
假设小推车里的字数超过了 500 字,触发了 else 超长切分分支。它是怎么断句的?
ts
const sentences = text.match(/[^.。\n]+[.。\n]*/g);
这个正则的意思极其朴素:不要管字数,先拿剪刀沿着"句号"把段落剪成一根一根的完整句子! 假设被剪成了 4 句话:
- 句子 A(200 字)
- 句子 B(200 字)
- 句子 C(200 字)
- 句子 D(100 字)
开始往盒子里装句子(maxChunkSize = 500)
-
拿起句子 A(200字)➔ 没满 500,装入;
-
拿起句子 B(200字)➔ 此时 200 + 200 = 400,还没满 500,装入;
-
拿起句子 C(200字)➔ 400 + 200 = 600!超过 500 了!
-
必须立刻封箱!
- 当前箱子(Chunk X) :
[句子 A + 句子 B](共 400 字)。
- 当前箱子(Chunk X) :
最关键的一步:Overlap 是怎么"偷"前文的?
现在要把剩下的句子 C 装进下一个箱子。
如果直接装 [句子 C],句子 C 和前面两句就彻底"断联"了!我们希望下一箱的开头附带上一箱末尾的一小截内容(比如上一箱的句子 B) 。
看这几行逆向回溯代码:
ts
let overlapText = "";
const overlapSentences = subText.match(...) || [];
// 倒着往前数!
for (let j = overlapSentences.length - 1; j >= 0; j--) {
if (overlapText.length + overlapSentences[j].length <= overlapSize) {
overlapText = overlapSentences[j] + overlapText;
} else {
break;
}
}
这个正则的意思极其朴素:不要管字数,先拿剪刀沿着"句号"把段落剪成一根一根的完整句子! 假设被剪成了 4 句话:
- 句子 A(200 字)
- 句子 B(200 字)
- 句子 C(200 字)
- 句子 D(100 字)
始往盒子里装句子(maxChunkSize = 500)
-
拿起句子 A(200字)➔ 没满 500,装入;
-
拿起句子 B(200字)➔ 此时 200 + 200 = 400,还没满 500,装入;
-
拿起句子 C(200字)➔ 400 + 200 = 600!超过 500 了!
-
必须立刻封箱!
- 当前箱子(Chunk X) :
[句子 A + 句子 B](共 400 字)。
- 当前箱子(Chunk X) :
最关键的一步:Overlap 是怎么"偷"前文的?
现在要把剩下的句子 C 装进下一个箱子。
如果直接装 [句子 C],句子 C 和前面两句就彻底"断联"了!我们希望下一箱的开头附带上一箱末尾的一小截内容(比如上一箱的句子 B) 。
看这几行逆向回溯代码:
ini
typescript
let overlapText = "";
const overlapSentences = subText.match(...) || [];
// 倒着往前数!
for (let j = overlapSentences.length - 1; j >= 0; j--) {
if (overlapText.length + overlapSentences[j].length <= overlapSize) {
overlapText = overlapSentences[j] + overlapText;
} else {
break;
}
}
它的具象化动作是:
-
刚刚封箱的内容是
[句子 A + 句子 B]。 -
算法从屁股后往前看:
- 看到句子 B:它的长度在 50 字以内吗?如果合适,把它抓过来当做
overlapText;
- 看到句子 B:它的长度在 50 字以内吗?如果合适,把它抓过来当做
-
然后开启下一箱:
typescriptsubText = overlapText + s; // 也就是:[句子 B] + [句子 C]
看最终两个箱子的对比效果:
- 第一个箱子 :
句子 A。句子 B。 - 第二个箱子 :
句子 B。句子 C。句子 D。
"句子 B"同时出现在了两个箱子里 。如果用户提问刚好踩在句子 B 和句子 C 之间的因果逻辑上,无论系统召回哪一个箱子,大模型都能看到完整的上下文。
在真实的业务场景中,单纯依靠"标点符号 + 固定 500 字滑动窗口",是无法彻底消除语义歧义。后面我们再展开说说生产级别的系统如何逐层破局的。
代码的最后:
ts
flushBlock(); // 最后的收尾
return chunks;
回顾主循环:代码只有在"遇到下一个标题时" ,才会把手推车里的货物打包推走。
读到文章最后一篇《账号注销》的正文 注销后数据彻底清除。 时,后面已经没有任何新的 # 标题了!
如果没有最后这行显式的 flushBlock(),小推车里装的最后这段文字就永远留在了推车里,根本没被推进 chunks 结果里,文档末尾的内容就被神不知鬼不觉地吞掉了。
向量嵌入与数学度量
大模型是基于高维浮点数进行运算的。所谓 Embedding(词嵌入/文本向量化) ,就是利用深度神经网络,将人类的自然语言映射为高维欧氏空间里的一个稠密向量(Dense Vector) 。
例如,OpenAI 的 text-embedding-3-small 将每段话转换为一个包含 1536 个浮点数的数组:
text
"苹果公司发布了全新手机" → [ 0.0123, -0.0456, 0.0891, ... (共 1536 维) ]
"iPhone 999 正式开启预售" → [ 0.0118, -0.0461, 0.0885, ... (语义高度接近,向量方向近乎重合) ]
"今天中午我吃了一碗兰州拉面" → [ -0.0762, 0.0215, -0.0034, ... (语义无关,向量方向正交或远离) ]
在数学上,度量两个向量 A 与 B 的相似性有两种直观方式:
- 欧几里得距离(Euclidean Distance) :计算多维空间中两点间的绝对直线距离 d=∑(Ai−Bi)2 ;
- 余弦相似度(Cosine Similarity) :计算两个向量夹角的余弦值 cos(θ)=∥A ∥∥B ∥A ⋅B 。
在 1536 维的高维超空间中,欧几里得距离会遭遇数学崩塌------高维空间里的所有数据点之间的绝对距离都会趋于一致。
更严重的是文本长短会直接干扰模长。一段长达 500 字的详细说明和一句 10 个字的核心总结,哪怕含义完全相同,长文本的向量模长也会极大,导致两者的绝对欧式距离巨大。
而余弦相似度关注的是向量在超球体上的"方向(Angle)",消除了文本长度引起的绝对模长偏差,取值范围严格落在 −1,1 之间,是语义检索领域的绝对黄金指标。
我们不依赖任何外部代数数学库,用纯原生 TypeScript 实现向量点积、模长计算与除零保护。
数学公式:
A ⋅B =∑i=1nAiBi
∥A ∥=∑i=1nAi2
cos(θ)=∥A ∥×∥B ∥A ⋅B
查看源码 lib/rag/similarity.ts:
ts
/**
* 计算向量模长 (L2 Norm)
*/
export function magnitude(v: number[]): number {
return Math.sqrt(v.reduce((sum, val) => sum + val * val, 0));
}
/**
* 计算两向量点积 (Dot Product)
*/
export function dotProduct(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(`向量维度不匹配: a 维数为 ${a.length}, b 维数为 ${b.length}`);
}
return a.reduce((sum, val, i) => sum + val * b[i], 0);
}
/**
* 计算余弦相似度 (Cosine Similarity)
* 严格包含除零防御,保证边界安全性
*/
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error(`向量维度不匹配: a 维数为 ${a.length}, b 维数为 ${b.length}`);
}
const magA = magnitude(a);
const magB = magnitude(b);
if (magA === 0 || magB === 0) {
return 0; // 核心防御:防止全零向量引发除以 0 导致返回 NaN
}
return dotProduct(a, b) / (magA * magB);
}
向量的存储
向量存储(Vector Store)本质上是一个包含高维向量索引的键值数据库 。每一条记录包含: { id, content, embedding: number[], metadata }。 在检索时,它接受用户提问计算出的 queryEmbedding,遍历库中向量计算相似度并返回前 Top-K 个结果。
这里我们不使用成熟的向量存储数据库,在中小规模知识库(数万个 Chunk 以内)中,现代化 CPU 纯内存暴力遍历计算 1000 次 1536 维余弦相似度仅需不到 3 毫秒。这里我们还是继续手写轻量内存向量引擎,学习向量召回的每个技术细节。
查看完整源码 lib/rag/vector-store.ts:
ts
// lib/rag/vector-store.ts
import { VectorDocument, SearchOptions, SearchResult } from './types';
import { cosineSimilarity } from './similarity';
export class MemoryVectorStore {
// 基于内存 Map 维持 O(1) 索引读写
private documents: Map<string, VectorDocument> = new Map();
add(doc: VectorDocument): void {
this.documents.set(doc.id, doc);
}
addBatch(docs: VectorDocument[]): void {
for (const doc of docs) {
this.add(doc);
}
}
/**
* 向量近似最近邻检索
* 1. 遍历全部文档计算 Cosine Similarity
* 2. 阈值初筛(过滤掉低于 threshold 的无意义噪音)
* 3. 按相似度得分降序排序并截取 Top-K
*/
search(queryEmbedding: number[], options?: SearchOptions): SearchResult[] {
const topK = options?.topK ?? 3;
const threshold = options?.threshold ?? 0.0;
const results: SearchResult[] = [];
for (const doc of this.documents.values()) {
const score = cosineSimilarity(queryEmbedding, doc.embedding);
if (score >= threshold) {
results.push({
id: doc.id,
content: doc.content,
score,
metadata: doc.metadata,
});
}
}
results.sort((a, b) => b.score - a.score);
return results.slice(0, topK);
}
delete(id: string): boolean {
return this.documents.delete(id);
}
clear(): void {
this.documents.clear();
}
get size(): number {
return this.documents.size;
}
}
许多人以为只要搞定向量检索,RAG 就大功告成了。但在生产测试第一天,你就会遭遇经典翻车:
text
用户提问:"请问 API 接口认证的错误状态码 401 是什么含义?"
此时的纯向量检索结果:
- Top 1 召回 :
"HTTP 403 Forbidden:您没有操作该资源的权限..."(相似度 0.81) - Top 2 召回 :
"系统身份认证与安全架构概述..."(相似度 0.79) - 真正的目标段落 :
"401 Unauthorized:未授权,API Key 无效或过期"(相似度 0.75,被挤出前排)
为什么语义向量在精准匹配上会翻车?
因为向量模型是在海量语料上学习宏观语义相关性的。"401"和"403"在向量空间中的位置极其接近(它们都是 HTTP 鉴权错误状态码)。向量模型并不具备计算机对特定字符串字面量的绝对精确敏感性 。对于 错误码、人名、手机号、专业缩写 ,纯语义向量往往"抓大放小",彻底失真。
BM25 算法
为了弥补这一致命短板,我们必须请出信息检索领域统治了半个世纪的经典王者 ------ BM25(Best Matching 25)算法 。
BM25 不是简单的关键词出现次数统计(词频 TF),它引入了三大核心数学约束:

-
IDF(逆文档频率) :如果一个词在所有文档中频繁出现(如"的"、"是"),它不重要;如果只在极少数文档中出现(如"退款"、"401"),它的权重极高。
IDF(qi)=ln(df(qi)+0.5N−df(qi)+0.5+1)
-
词频饱和度( k1 系数,默认 1.2) :一篇文档里"退款"出现了 1 次,得分大幅增加;但如果出现了 50 次,得分会收敛饱和,防止文章刷关键词作弊。
-
文档长度惩罚( b 系数,默认 0.75) :一本长达 100 页的书提到了"退款",可能只是顺带一笔;而一份 50 字的短通知提到了"退款",说明全篇都在讲退款。 b 系数根据当前文档长度 ∣D∣ 与库中平均长度 avgdl 对得分进行无偏矫正。
大白话就是:谁在最短的篇幅里精准命中了最少见的关键词,谁就排最前面,而且别想靠疯狂复制同一个词来刷分。(生僻词才值钱 、短文章更聚焦 、复读机没收益)
现在我们来手写 BM25 检索与词频倒排算法,查看完整源码 lib/rag/bm25.ts:
ts
// lib/rag/bm25.ts
import { TextChunk, BM25Options, SearchResult } from './types';
export class BM25Index {
private k1: number;
private b: number;
private documents: Map<string, TextChunk> = new Map();
private docTokens: Map<string, string[]> = new Map();
private df: Map<string, number> = new Map(); // 文档频率表
private totalTokenCount: number = 0;
constructor(options?: BM25Options) {
this.k1 = options?.k1 ?? 1.2;
this.b = options?.b ?? 0.75;
}
/**
* 中文与标点切词策略:按空白与中文标点符号进行分词并转为小写
*/
private tokenize(text: string): string[] {
return text.toLowerCase().split(/[ \s,。!?、;:]+/).filter((t) => t.trim() !== '');
}
addDocument(doc: TextChunk): void {
// 避免重复添加同一个文档导致统计污染
if (this.documents.has(doc.id)) return;
this.documents.set(doc.id, doc);
const tokens = this.tokenize(doc.content);
this.docTokens.set(doc.id, tokens);
// 累加全库词数
this.totalTokenCount += tokens.length;
// 单篇去重
const uniqueTokens = new Set(tokens);
for (const token of uniqueTokens) {
this.df.set(token, (this.df.get(token) || 0) + 1);
}
}
addDocuments(docs: TextChunk[]): void {
for (const doc of docs) {
this.addDocument(doc);
}
}
search(query: string, topK?: number): SearchResult[] {
const k = topK ?? 10;
const queryTokens = this.tokenize(query);
// N:库中文档总数
const N = this.documents.size;
// avgdl:全库平均每篇文档有多少词
const avgdl = N > 0 ? this.totalTokenCount / N : 0;
const results: SearchResult[] = [];
// 遍历库中的每一篇文档
for (const [id, doc] of this.documents.entries()) {
const tokens = this.docTokens.get(id) || [];
const dl = tokens.length;
let score = 0;
// 1. 统计当前文档内的词频 TF(Term Frequency)
const tfMap = new Map<string, number>();
for (const t of tokens) {
tfMap.set(t, (tfMap.get(t) || 0) + 1);
}
// 2. 拿用户提问中的每一个词,在当前文档中累加得分
for (const qt of queryTokens) {
const tf = tfMap.get(qt) || 0;
if (tf === 0) continue;
const df = this.df.get(qt) || 0;
// 3. 计算 IDF(逆文档频率):词越罕见,算出来的 idf 越大
const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
// 4. BM25 核心得分公式计算
score += idf * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + (this.b * dl) / avgdl)));
}
// 5. 只要得分大于 0,就纳入候选结果
if (score > 0) {
results.push({
id,
content: doc.content,
score,
metadata: doc.metadata,
});
}
}
// 6. 按得分降序排序,截取前 Top-K 个最相关的片段
results.sort((a, b) => b.score - a.score);
return results.slice(0, k);
}
get documentCount(): number {
return this.documents.size;
}
}
用一组直观数据,看懂公式。假设我们库里有两篇文档,用户搜索关键词 "退款" :

| 对比维度 | 文档 A(50 字短通知) | 文档 B(10,000 字大长文) |
|---|---|---|
| 内容 | "购买 30 天内可无条件退款。" | 一篇关于企业架构白皮书,第 8000 字提了一句"此方案不支持退款"。 |
| 词数 dl | 极小 | 极大 |
| 公式里的分母 | 变小 | 变得极大 |
| 最终得分 | 分子小分母更小,得分暴增 | 分母被篇幅急剧放大,得分大幅缩减 |
| 最终结果 | 排名靠前 | 沉底,被判定为长篇顺带提及 |
混合检索与倒数排序融合
混合检索,就是让向量语义检索(理解意图同义词)与 BM25 关键词检索(捕获精确字面量)并发运行,取长补短:
text
用户输入: "我想了解一下 401 报错"
├─► 向量检索 ──► 召回语义相关的段落 [DocA(0.85), DocB(0.80), DocC(0.75)]
└─► BM25 检索 ──► 召回精确命中"401"的段落 [DocC(12.4), DocD(8.2), DocA(5.1)]
很多初学者写混合搜索,喜欢拍脑袋写出这种代码: finalScore = 0.5 * vectorScore + 0.5 * bm25Score。这在数学上完全是不合法的量纲灾难。
- 余弦相似度得分严格处于 0,1 之间;
- BM25 分数是基于对数与词频的无界统计值,得分经常在 0,30 甚至更高,直接相加会导致 BM25 的权重被放大几十倍,完全碾压向量检索,混合检索退化为纯文本搜索。
最主流的优雅解法,是 RRF(Reciprocal Rank Fusion,倒数排序融合) 。
它抛弃绝对得分数值,只看文档在各路检索中的相对排名(Rank) :
RRF_Score(d)=∑m∈Mk+rankm(d)1
-
M:检索通道列表(这里有两路:向量通路、BM25 通路);
-
rankm(d) 是文档 d 在第 m 个检索结果列表中的位置(1, 2, 3...);
-
k 是平滑常数(学术界通用默认值为 60),防止第一名得分权重过度支配后续排名。
-
倒数特性 :排名越靠前( rank 越小), k+rank1越大,得分越高!
查看源码 lib/rag/hybrid-search.ts:
ts
// lib/rag/hybrid-search.ts
import { SearchResult } from './types';
/**
* 纯 RRF (Reciprocal Rank Fusion) 融合函数
*/
export function rrfFusion(
vectorResults: SearchResult[],
bm25Results: SearchResult[],
options?: { k?: number; topK?: number }
): SearchResult[] {
// 默认平滑常数 60
const k = options?.k ?? 60;
// 最终融合后只需要前 5 条
const topK = options?.topK ?? 5;
// 哈希表:key 是文档的唯一 ID,value 保存累加后的 RRF 得分与文档本体
const scoreMap = new Map<string, { score: number; item: SearchResult }>();
// 累加计算单路排名的倒数分数
const addResults = (results: SearchResult[]) => {
results.forEach((result, index) => {
const rank = index + 1; // 排名从 1 开始
const rrfScore = 1 / (k + rank);
const existing = scoreMap.get(result.id);
if (existing) {
// 核心:如果两路检索都命中了这篇文档,得分直接叠加
existing.score += rrfScore;
} else {
// 第一次出现,记录进 Map
scoreMap.set(result.id, {
score: rrfScore,
item: { ...result },
});
}
});
};
addResults(vectorResults);
addResults(bm25Results);
// 重新映射为结果数组并按 RRF 得分降序排序
const fusedResults = Array.from(scoreMap.values()).map(({ score, item }) => ({
...item,
score,
}));
// 按最终融合后的 RRF 得分从大到小降序排列
fusedResults.sort((a, b) => b.score - a.score);
// 截取前 topK(如前 5 名)返回给上层 Pipeline
return fusedResults.slice(0, topK);
}
RAG Pipeline 编排层
RAG Pipeline 是整个知识库应用的总指挥。它的职责是:
-
离线处理 :协调
Chunker、createEmbeddings、VectorStore、BM25完成多份文件的分块和批量入库; -
在线查询 :协调
QueryRewriter、并发双路检索、rrfFusion聚合、组装防注入增强 Prompt。
之前手写的 chunker(分块器)、vector-store(向量库)、bm25(词频检索)和 hybrid-search(RRF 融合)是散落在各个车间的精密零部件 ,那么 lib/rag/rag-pipeline.ts 就是把它们组装起来的中央控制中枢(Pipeline 编排引擎) 。RAG Pipeline 编排中要解耦离线建库与在线检索。离线建库是高 I/O、重计算的写入过程,而在线检索是对并发、低延迟有极高要求的实时读取过程。解耦使得我们在单机测试、批量文档更新和运行时问答之间保持了极致的灵活性。
查看完整源码 lib/rag/rag-pipeline.ts:
ts
// lib/rag/rag-pipeline.ts
import { chunkMarkdown } from './chunker';
import { MemoryVectorStore } from './vector-store';
import { BM25Index } from './bm25';
import { rrfFusion } from './hybrid-search';
import { rewriteQuery } from './query-rewriter';
import { VectorDocument, SearchResult, BatchEmbedFunction, EmbedFunction, CallLLMFunction } from './types';
// 单例持有,内存常驻
const vectorStore = new MemoryVectorStore();
let bm25Index = new BM25Index();
export interface IndexResult {
documentCount: number;
chunkCount: number;
durationMs: number;
}
/**
* 离线建库编排流水线
*/
export async function indexDocuments(
files: { name: string; content: string }[],
embedFn: BatchEmbedFunction
): Promise<IndexResult> {
const startTime = Date.now();
let totalChunks = 0;
vectorStore.clear();
const newBm25 = new BM25Index();
for (const file of files) {
// 1. Markdown 语义分块
const chunks = chunkMarkdown(file.content, { source: file.name });
totalChunks += chunks.length;
if (chunks.length === 0) continue;
// 2. 批量向量嵌入
const texts = chunks.map((c) => c.content);
const embeddings = await embedFn(texts);
// 3. 组装向量记录并写入向量库
const vectorDocs: VectorDocument[] = chunks.map((chunk, i) => ({
...chunk,
embedding: embeddings[i],
}));
vectorStore.addBatch(vectorDocs);
// 4. 同步写入 BM25 词频倒排索引
newBm25.addDocuments(chunks);
}
bm25Index = newBm25;
return {
documentCount: files.length,
chunkCount: totalChunks,
durationMs: Date.now() - startTime,
};
}
export interface QueryOptions {
searchMode?: 'vector' | 'bm25' | 'hybrid';
history?: Array<{ role: 'user' | 'assistant'; content: string }>;
topK?: number;
}
export interface QueryResult {
question: string;
rewrittenQuery: string;
citations: SearchResult[];
context: string;
}
/**
* 在线检索与上下文增强编排流水线
*/
export async function query(
question: string,
embedFn: EmbedFunction,
callLLM: CallLLMFunction,
options?: QueryOptions
): Promise<QueryResult> {
const searchMode = options?.searchMode || 'hybrid';
const topK = options?.topK || 5;
// 1. 多轮上下文追问改写(消解代词指代)
let actualQuery = question;
if (options?.history && options.history.length > 0) {
actualQuery = await rewriteQuery(options.history, question, callLLM);
}
// 2. 分流检索
let results: SearchResult[] = [];
if (searchMode === 'vector') {
const queryEmbedding = await embedFn(actualQuery);
results = vectorStore.search(queryEmbedding, { topK });
} else if (searchMode === 'bm25') {
results = bm25Index.search(actualQuery, topK);
} else {
// 混合检索模式:并发双路检索 + RRF 融合
const queryEmbedding = await embedFn(actualQuery);
const vectorResults = vectorStore.search(queryEmbedding, { topK: 20 });
const bm25Results = bm25Index.search(actualQuery, 20);
results = rrfFusion(vectorResults, bm25Results, { topK });
}
// 3. 上下文带标号组织(供 LLM 引用)
const context = results
.map((r, i) => `[${i + 1}] (来源: ${r.metadata.source} > ${r.metadata.headingPath})\n${r.content}`)
.join('\n\n');
return {
question,
rewrittenQuery: actualQuery,
citations: results,
context,
};
}
现在我们来逐步解读上述代码:
离线建库编排
这是整个系统的 "知识灌入通道" 。它的任务是:把用户上传或知识库目录里的 Markdown 文本,一步步加工成可检索的向量与倒排索引。

ts
export async function indexDocuments(
files: { name: string; content: string }[],
embedFn: BatchEmbedFunction
): Promise<IndexResult> {
const startTime = Date.now();
let totalChunks = 0;
// 1. 清空旧数据,准备全量重建
vectorStore.clear();
const newBm25 = new BM25Index();
for (const file of files) {
// 2. 第一步:调用 Markdown 分块器,按标题与自然段切出带元数据的 Chunk
const chunks = chunkMarkdown(file.content, { source: file.name });
totalChunks += chunks.length;
if (chunks.length === 0) continue;
// 3. 第二步:提取所有 chunk 的纯文本,发起【批量】向量化
const texts = chunks.map((c) => c.content);
const embeddings = await embedFn(texts);
// 4. 第三步:将返回的高维向量(1536维)与 chunk 拼装为 VectorDocument 存入向量库
const vectorDocs: VectorDocument[] = chunks.map((chunk, i) => ({
...chunk,
embedding: embeddings[i],
}));
vectorStore.addBatch(vectorDocs);
// 5. 第四步:同步将分块喂给 BM25 引擎,建立词频与文档频率倒排索引
newBm25.addDocuments(chunks);
}
// 6. 原子切换:用建好的新 BM25 实例替换全局引用
bm25Index = newBm25;
return {
documentCount: files.length,
chunkCount: totalChunks,
durationMs: Date.now() - startTime,
};
}
上面的代码看了注释应该都能理解,这里着重讲两个点:
-
批量向量化:不能在循环里一条一条调用 embedFn(text),否则 100 个 chunk 就是 100 次 HTTP 往返请求,不仅极慢还会打满 API 频率。这里的写法是
embedFn(texts)一次性批量发送一个数组,上游一次网络请求返回全部向量。 -
原子切换:先在局部变量
newBm25中建索引,直到全部文件处理完成,才用bm25Index = newBm25瞬间替换引用。防止在建库过程中有用户发起检索导致查到"半吊子"不全的数据。
在线检索与增强编排
这是 RAG 在用户提问时的核心。它包含了三大步骤:追问改写 ➔ 双路分流检索 ➔ 上下文打桩组装。
ts
export async function query(
question: string,
embedFn: EmbedFunction,
callLLM: CallLLMFunction,
options?: QueryOptions
): Promise<QueryResult> {
const searchMode = options?.searchMode || 'hybrid';
const topK = options?.topK || 5;
// 1:多轮上下文追问改写(代词消解)
let actualQuery = question;
if (options?.history && options.history.length > 0) {
actualQuery = await rewriteQuery(options.history, question, callLLM);
}
// 2:分流检索与混合融合
let results: SearchResult[] = [];
if (searchMode === 'vector') {
// 纯向量模式:算提问向量 -> 查余弦相似度前 topK
const queryEmbedding = await embedFn(actualQuery);
results = vectorStore.search(queryEmbedding, { topK });
} else if (searchMode === 'bm25') {
// 纯关键词模式:BM25 精确字面量前 topK
results = bm25Index.search(actualQuery, topK);
} else {
// 2.1:算提问向量
const queryEmbedding = await embedFn(actualQuery);
// 2.2:双路并发召回,注意!初筛扩大为 20 条候选(漏斗模型)
const vectorResults = vectorStore.search(queryEmbedding, { topK: 20 });
const bm25Results = bm25Index.search(actualQuery, 20);
// 2.3:倒数排序融合(RRF),从 40 个候选中精选出最终最相关的 Top-5
results = rrfFusion(vectorResults, bm25Results, { topK });
}
// 3:上下文带标号组织(为大模型打上 [1] [2] 引用桩)
const context = results
.map((r, i) => `[${i + 1}] (来源: ${r.metadata.source} > ${r.metadata.headingPath})\n${r.content}`)
.join('\n\n');
return {
question,
rewrittenQuery: actualQuery, // 告诉上层真实搜的词是什么(调试/溯源很关键)
citations: results, // 结构化引用列表(给前端卡片展示)
context, // 组装好的文本(直接塞进 System Prompt)
};
}
searchMode为hybrid时为什么检索时候选集是topK: 20,而最终只要topK: 5?
如果只各自找 5 条,两路集合可能交集很小(比如各 5 条完全不重叠),RRF 算法的"双路命中加权优势"就发挥不出来;扩大初筛范围召回 20 条 :让更可能包含共识的段落浮出水面,再通过 RRF 精排筛选出含金量最高的最终 Top-5。这就是经典搜索领域的 "粗排(Recall)➔ 精排(Re-rank)"漏斗架构。
上下文标号打桩:[${i + 1}] (来源: ...) 的妙用
把几个 chunk 的正文直接用换行拼在一起,大模型回答时根本不知道一句话出自哪里。
这里在拼装 context 时,特意打上了格式化的桩:
text
[1] (来源: product-faq.md > 退款与计费 > 退款政策是什么?)
购买后 30 天内可申请无条件全额退款...
[2] (来源: company-policy.md > 考勤制度 > 请假审批)
...
当把这段文本塞给大模型时,大模型在回答时便能自然而然地说出:
"根据产品规定,购买后 30 天内可无条件退款 1;若超过 30 天则需要人工审批 1..."
前端抓取到
[1]时,就可以关联回citations[0],做成漂亮的点击跳转溯源高亮卡片。
- 依赖注入(Dependency Injection, DI)模式**
注意到 indexDocuments 和 query 都没有直接 import 具体的 OpenAI 库,而是接收:
embedFn: BatchEmbedFunctioncallLLM: CallLLMFunction
这有什么好处?
- 极致的可测试性 :写单元测试时,不需要真正消耗 API 额度,随时传一个
vi.fn().mockResolvedValue(...)就能瞬间完成测试; - 灵活换源 :未来无论是切换到官方 OpenAI、通义千问、DeepSeek,还是本地免费的 Ollama,这套 Pipeline 编排代码一行都不用改。
多轮对话追问改写与引用溯源
在真实对话中,用户的沟通是充满代词和上下文依赖的:
text
Round 1:
用户: "请问贵公司的专业版套餐是多少钱?"
助手: "专业版是每月 99 元。"
Round 2:
用户: "那它支持多少人协同?"
如果直接拿 Round 2 的"那它支持多少人协同?"丢进向量检索库,结果是什么?
"它"到底是指专业版?企业版?还是某个外部软件?检索库没有任何历史会话,召回结果必定彻底偏离,导致问答完全崩溃。
我们现在实现智能追问改写器,通过一个小参数大模型调用,将历史对话与模糊追问改写为一个自包含(Self-contained)的完整搜索词。查看源码 lib/rag/query-rewriter.ts:
ts
// lib/rag/query-rewriter.ts
import { CallLLMFunction } from './types';
export async function rewriteQuery(
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
currentQuery: string,
callLLM: CallLLMFunction
): Promise<string> {
if (!conversationHistory || conversationHistory.length === 0) {
return currentQuery;
}
const systemPrompt = `你是一个专业的搜索词改写专家。
你的任务是根据用户的对话历史,将用户当前的回复(可能是追问、代词指代等)改写为一个完全独立、不依赖上下文的完整搜索查询。
如果用户的当前查询已经是一个完整的问题,并且不包含任何模糊的指代(例如"它"、"那个"、"继续"、"为什么"等单独词汇),请直接输出原查询。
除了改写后的查询,不要输出任何额外的解释或标点符号。`;
let historyText = '对话历史:\n';
conversationHistory.forEach((msg) => {
historyText += `${msg.role === 'user' ? '用户' : '助手'}: ${msg.content}\n`;
});
const userMessage = `${historyText}\n当前用户查询: ${currentQuery}\n请输出改写后的独立查询:`;
try {
const rewrittenQuery = await callLLM(systemPrompt, userMessage);
return rewrittenQuery.trim();
} catch (error) {
return currentQuery; // 降级兜底:改写失败返回原词,不中断业务流
}
}
科学评估体系:RAGAS 简化版与 LLM-as-Judge
在开发 RAG 系统时,开发者经常陷入调参玄学: "到底该切 300 字还是 800 字?Overlap 设 50 还是 100?Top-K 选 3 还是 5?"
如果没有客观量化指标,每一次改动都是"凭感觉"。你必须拥有一把能够自动打分的标尺。
业界最权威的 RAG 评估框架 RAGAS 提出了三大约束指标:
- 真实度(Faithfulness,1~5 分) :回答是否完全忠实于检索到的参考文档?有没有背着文档自己瞎编(幻觉)?
- 答案相关度(Answer Relevance,1~5 分) :回答是否直截了当地解答了用户的问题?有没有顾左右而言他?
- 上下文精确度(Context Precision,1~5 分) :检索召回的那 5 个段落,到底有几个真正对回答问题有用?夹杂了多少无关垃圾信息?
利用大模型本身强大的评判与逻辑推理能力,充当客观独立的裁判员(LLM-as-Judge),输出严格受控的 JSON 评分。查看源码 lib/rag/evaluator.ts:
ts
// lib/rag/evaluator.ts
import { CallLLMFunction, RAGEvalResult } from './types';
export async function evaluateRAG(
question: string,
answer: string,
retrievedContexts: string[],
callLLM: CallLLMFunction
): Promise<RAGEvalResult> {
const systemPrompt = `你是一个 RAG (Retrieval-Augmented Generation) 系统的评估专家。
请根据提供的 问题 (Question)、系统回答 (Answer) 和 检索到的上下文 (Contexts),从以下三个维度进行评分(1-5分,1分最差,5分最好):
1. Faithfulness (真实度): 回答是否完全基于提供的上下文?是否包含编造(幻觉)的内容?
2. Answer Relevance (答案相关度): 回答是否直接回答了问题?是否包含无关信息?
3. Context Precision (上下文精确度): 检索到的上下文是否有助于回答问题?包含多少干扰信息?
请以严格的 JSON 格式输出评估结果:
{
"faithfulness": <数字1-5>,
"answerRelevance": <数字1-5>,
"contextPrecision": <数字1-5>,
"reasoning": "<对评分的详细综合解释>"
}`;
const contextsText = retrievedContexts.map((c, i) => `[Context ${i + 1}]:\n${c}`).join('\n\n');
const userMessage = `问题: ${question}\n回答: ${answer}\n检索上下文:\n${contextsText}`;
const response = await callLLM(systemPrompt, userMessage);
try {
const jsonStr = response.replace(/^```json\s*/, '').replace(/\s*```$/, '').trim();
const result = JSON.parse(jsonStr);
return {
faithfulness: Number(result.faithfulness) || 1,
answerRelevance: Number(result.answerRelevance) || 1,
contextPrecision: Number(result.contextPrecision) || 1,
reasoning: String(result.reasoning || ''),
};
} catch (error) {
return {
faithfulness: 1,
answerRelevance: 1,
contextPrecision: 1,
reasoning: `解析失败: ${error instanceof Error ? error.message : '未知'}`,
};
}
}
容错与降级
在实际项目落地中,一个极为普遍的现状是:
许多开发者或者企业内部网关(如 DeepSeek 官方 API、小米 mimo 网关等),仅仅开放了 Chat 对话端口,并没有部署或开放 /v1/embeddings 端点 。如果你按照常规写法,客户端直接发起 openai.embeddings.create,上游直接报 404 Not Found 导致后端 500 崩溃,前端按钮永远卡死在"正在索引..."。
为了应对真实复杂的工程环境,我们在 lib/openai-client.ts 中设计了双轨架构:
- 支持独立网关分离,可以在 .env.local 中单独定义 EMBEDDING_BASE_URL 和 EMBEDDING_API_KEY。区分 Chat 和 Embedding 的模型。
- 当远程网关返回 404/500 时,不抛出异常崩溃,而是自动降级为本地
n-gram特征投影算法生成 1536 维的标准单位向量。结合本地 BM25 算法,保证就算上游没有向量接口,整套 RAG 问答与检索演示在降级后跑通。
这里着重解析本地 n-gram 特征投影算法生成 1536 维的标准单位向量:
ts
// lib/openai-client.ts
export function generateDeterministicEmbedding(text: string, dimensions = 1536): number[] {
// 初始化一个固定长度为 1536(对齐 OpenAI 维度)的全 0 浮点数组
const vector = new Array(dimensions).fill(0);
// 文本前置清洗,全部转为小写并去除首尾空白(保证大小写不敏感)
const normalized = text.toLowerCase().trim();
for (let i = 0; i < normalized.length; i++) {
// 获取当前字符的 Unicode 编码
const charCode = normalized.charCodeAt(i);
// 利用经典质数 31 配合当前字符下标做多项式哈希,然后取模映射进 0~1535 下标空间
const idx1 = (charCode * 31 + i) % dimensions;
vector[idx1] += 1.0;
// 相邻双字组合特征(Bigram 特征提取)
if (i < normalized.length - 1) {
// 将相邻两个字符绑定(如"退"+"款"),用更大的质数 131 混合哈希
const idx2 = Math.abs(charCode * 131 + normalized.charCodeAt(i + 1)) % dimensions;
// 核心细节:双字词对的特征权重给 2.0(大于单字的 1.0)
vector[idx2] += 2.0;
}
}
// 模长归一化
// 1. 计算向量的欧几里得模长
const norm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
// 2 .除以模长,并保留 6 位小数
return norm === 0 ? vector : vector.map((v) => Number((v / norm).toFixed(6)));
}
为什么单字给 1.0,双字(Bigram)给 2.0 的权重?
因为在中文中,"单个字"往往含义泛化(比如"退"可能是退步、退后、退休;"款"可能是款式、条款); 但两个连续字组合成词(如"退款") 时,所表达的业务语义极其聚焦。给双字双倍权重(+2.0),能显著提升包含相同词汇段落之间的相似度。
为什么要强制做模长归一化?
如果不做归一化,一篇 1000 字的长文章会不断累加 += 1.0,导致数值特别大,而 10 个字的短句数值极小,为了消除文本长度的干扰是必要的。另外当所有向量经过 L2 归一化后,它们的模长均为 1,此时余弦相似度直接简化为两个向量的点乘 A ⋅B ,几何上所有向量都被投影在了 1536 维超球体的表面,计算极快且绝对在 0,1 之间。
在生成级别的系统中这种"优雅降级"是需要考虑到的,保障系统不掉链子。
代码阅读指引
不要一开始就去啃复杂的数学公式,公式通过理解记忆,不必死记硬背。最科学的阅读顺序是:先看TS定义的类型契约,再看核心代码,接着逐个文件细看,把内容串起来。最后看前端如何消费和渲染。

结语
本章量很大,通过阅读本章再结合源码学习、理解、记忆。最后还是动手实战一遍。下一节我们学习 MCP。