W4. RAG 让 Agent 拥有"外部记忆"

前言

从 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 Tokens150,000×10=1,500,000 Tokens 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. 正则匹配到了行首有 1 个 #,说明这是一个 一级标题(H1,level = 1) 。

  2. 此时小推车(currentLines)还是空的,flushBlock() 什么也不做。

  3. 墙上的路标更新为:['售后政策']。

  4. 把这一行丢进小推车。

    • 此时小推车 :['# 售后政策']

第 2 步:读到第 2 行 欢迎来到售后中心。

  1. 不是标题,是普通文字。

  2. 直接丢进小推车。

    • 此时小推车 :['# 售后政策', '欢迎来到售后中心。']

第 3 步:读到第 3 行 ## 退款制度(第一次触发打包)

  1. 正则匹配到有 2 个 #,是 二级标题(H2,level = 2) !

  2. 触发 flushBlock() 打包:

    • 把小推车里的文字拼起来:"# 售后政策\n欢迎来到售后中心。"
    • 贴上面包屑标签:"售后政策"
    • 生成第 1 个快递箱(Chunk 0) ,推到成品区!
    • 清空小推车!
  3. 墙上的路标更新为:['售后政策', '退款制度']。

  4. 把新标题丢进小推车。

    • 此时小推车 :['## 退款制度']

很多同学看这行代码 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 字)。

最关键的一步: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 字)。

最关键的一步: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;
  }
}

它的具象化动作是:

  1. 刚刚封箱的内容是 [句子 A + 句子 B]。

  2. 算法从屁股后往前看:

    • 看到句子 B:它的长度在 50 字以内吗?如果合适,把它抓过来当做 overlapText;
  3. 然后开启下一箱:

    typescript 复制代码
    subText = 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⃗ \vec{A} A 与 B⃗ \vec{B} B 的相似性有两种直观方式:

  1. 欧几里得距离(Euclidean Distance) :计算多维空间中两点间的绝对直线距离 d= ∑(Ai−Bi)2 d = \sqrt{\sum (A_i - B_i)^2} d=∑(Ai−Bi)2 ;
  2. 余弦相似度(Cosine Similarity) :计算两个向量夹角的余弦值 cos⁡(θ)= A⃗⋅B⃗ ∥A⃗∥∥B⃗∥ cos⁡(θ)= \frac{\vec{A} \cdot \vec{B}}{\|\vec{A}\| \|\vec{B}\|} cos⁡(θ)=∥A ∥∥B ∥A ⋅B 。

在 1536 维的高维超空间中,欧几里得距离会遭遇数学崩塌------高维空间里的所有数据点之间的绝对距离都会趋于一致。

更严重的是文本长短会直接干扰模长。一段长达 500 字的详细说明和一句 10 个字的核心总结,哪怕含义完全相同,长文本的向量模长也会极大,导致两者的绝对欧式距离巨大。

而余弦相似度关注的是向量在超球体上的"方向(Angle)",消除了文本长度引起的绝对模长偏差,取值范围严格落在 −1,1−1,1 −1,1 之间,是语义检索领域的绝对黄金指标。

我们不依赖任何外部代数数学库,用纯原生 TypeScript 实现向量点积、模长计算与除零保护。

数学公式:

A⃗⋅B⃗= ∑i=1n AiBi \vec{A} \cdot \vec{B} = \sum_{i=1}^{n} A_i B_i A ⋅B =∑i=1nAiBi

∥A⃗∥= ∑i=1n Ai2 \|\vec{A}\| = \sqrt{\sum_{i=1}^{n} A_i^2} ∥A ∥=∑i=1nAi2

cos⁡(θ)= A⃗⋅B⃗ ∥A⃗∥×∥B⃗∥ \cos(\theta) = \frac{\vec{A} \cdot \vec{B}}{\|\vec{A}\| \times \|\vec{B}\|} 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⁡ ( N−df(qi)+0.5 df(qi)+0.5 +1) IDF(q_i) = \ln\left(\frac{N - df(q_i) + 0.5}{df(q_i) + 0.5} + 1\right) IDF(qi)=ln(df(qi)+0.5N−df(qi)+0.5+1)

  • 词频饱和度( k1 k_1 k1 系数,默认 1.2) :一篇文档里"退款"出现了 1 次,得分大幅增加;但如果出现了 50 次,得分会收敛饱和,防止文章刷关键词作弊。

  • 文档长度惩罚( bb b 系数,默认 0.75) :一本长达 100 页的书提到了"退款",可能只是顺带一笔;而一份 50 字的短通知提到了"退款",说明全篇都在讲退款。 bb b 系数根据当前文档长度 ∣D∣|D| ∣D∣ 与库中平均长度 avgdlavgdl 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 字提了一句"此方案不支持退款"。
词数 dldl 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,10, 1 0,1 之间;
  • BM25 分数是基于对数与词频的无界统计值,得分经常在 0,300, 30 0,30 甚至更高,直接相加会导致 BM25 的权重被放大几十倍,完全碾压向量检索,混合检索退化为纯文本搜索。

最主流的优雅解法,是 RRF(Reciprocal Rank Fusion,倒数排序融合) 。

它抛弃绝对得分数值,只看文档在各路检索中的相对排名(Rank) :

RRF_Score(d)= ∑m∈M 1k+rankm(d) RRF\Score(d) = \sum{m \in M} \frac{1}{k + rank_m(d)} RRF_Score(d)=∑m∈Mk+rankm(d)1

  • M:检索通道列表(这里有两路:向量通路、BM25 通路);

  • rankm(d) rank_m(d) rankm(d) 是文档 dd d 在第 mm m 个检索结果列表中的位置(1, 2, 3...);

  • kk k 是平滑常数(学术界通用默认值为 60),防止第一名得分权重过度支配后续排名。

  • 倒数特性 :排名越靠前( rankrank rank 越小), 1k+rank \frac{1}{k + 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,
  };
}

上面的代码看了注释应该都能理解,这里着重讲两个点:

  1. 批量向量化:不能在循环里一条一条调用 embedFn(text),否则 100 个 chunk 就是 100 次 HTTP 往返请求,不仅极慢还会打满 API 频率。这里的写法是 embedFn(texts) 一次性批量发送一个数组,上游一次网络请求返回全部向量。

  2. 原子切换:先在局部变量 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)
  };
}
  1. 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],做成漂亮的点击跳转溯源高亮卡片。

  1. 依赖注入(Dependency Injection, DI)模式**

注意到 indexDocuments 和 query 都没有直接 import 具体的 OpenAI 库,而是接收:

  • embedFn: BatchEmbedFunction
  • callLLM: CallLLMFunction

这有什么好处?

  1. 极致的可测试性 :写单元测试时,不需要真正消耗 API 额度,随时传一个 vi.fn().mockResolvedValue(...) 就能瞬间完成测试;
  2. 灵活换源 :未来无论是切换到官方 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 提出了三大约束指标:

  1. 真实度(Faithfulness,1~5 分) :回答是否完全忠实于检索到的参考文档?有没有背着文档自己瞎编(幻觉)?
  2. 答案相关度(Answer Relevance,1~5 分) :回答是否直截了当地解答了用户的问题?有没有顾左右而言他?
  3. 上下文精确度(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 中设计了双轨架构:

  1. 支持独立网关分离,可以在 .env.local 中单独定义 EMBEDDING_BASE_URL 和 EMBEDDING_API_KEY。区分 Chat 和 Embedding 的模型。
  2. 当远程网关返回 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⃗ \vec{A} \cdot \vec{B} A ⋅B ,几何上所有向量都被投影在了 1536 维超球体的表面,计算极快且绝对在 0,10, 1 0,1 之间。

在生成级别的系统中这种"优雅降级"是需要考虑到的,保障系统不掉链子。

代码阅读指引

不要一开始就去啃复杂的数学公式,公式通过理解记忆,不必死记硬背。最科学的阅读顺序是:先看TS定义的类型契约,再看核心代码,接着逐个文件细看,把内容串起来。最后看前端如何消费和渲染。

结语

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

相关推荐
用户308169987648519 分钟前
gzip 和 br 压同一份 JSON:7.6MB 压到 175KB,差 3.8 倍
前端
掘金者阿豪28 分钟前
电商视觉要被 Agent 重做了:一个平台跑通主图、详情页、实拍、复刻和视频
前端
爱勇宝41 分钟前
《道德经》第 13 章:别让评价系统控制你的心
前端·后端·产品
掘金者阿豪1 小时前
集中式与分布式数据库怎么选?从架构原理到电科金仓的实践思考
前端·后端
墨林陌1 小时前
AI 热点日报(2026-11-06):乌镇峰会闭幕发布全球开源AI倡议,腾讯混元Hy4发布即开源
前端
亿元程序员2 小时前
8年游戏主程从公司毕业了,能干些啥?
前端
Cosolar2 小时前
极简智库(MiniDocs):docsify、VitePress、Notion 等知识库搭建服务的完美替代
前端·后端·架构
涛涛ing2 小时前
那些“每个决定都正确”的项目,最后是怎么烂掉的
前端
徐小夕3 小时前
万字长文!DeepSeek Super-agent × 企业知识库架构深度拆解与落地实践
前端·vue.js·github