什么是 RAG

它的本质是用软件工程将一个“动态资料库”外挂给大模型。其分为两个阶段:离线索引和在线检索增强。其中离线索引阶段将私有 Markdown 文档切分成大小适宜的文本块(Chunk),转化为高维数学向量,存入索引引擎。在线检索增强在用户提问时,先通过语义算法和关键词匹配召回最相关的 Top-K 个文档片段,将这些客观片段动态注入 Prompt,最后驱动 LLM 总结出带有真实引用溯源的回答。

项目实战

文档切块

在了解什么是 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,单次交互扣费几毛甚至几块钱,首字响应延迟更是高达十几秒。

因此,文档分块 的工程本质,绝非随意切断文字,而是在“语义完整性”与“检索聚焦度”之间寻找最佳的黄金分割点。很多刚入门的开发者最喜欢的写法是按固定字符切分:

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 的类型先梳理好:

// 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
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;
}

光看上面的代码有些难以理解。我们来逐步分析。假设我们要切分的文档长这样:

# 售后政策
欢迎来到售后中心。
## 退款制度
### 试用期退款
购买后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 会卡壳:

currentHeadingPath.length = level - 1;
currentHeadingPath[level - 1] = title;

假设你读到了第 5 行:### 试用期退款(三级标题,level = 3)。 此时墙上的路标数组是:

下标 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 超长切分分支。它是怎么断句的?

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) 。

看这几行逆向回溯代码:

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) 。

看这几行逆向回溯代码:

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. 然后开启下一箱:

    subText = overlapText + s; // 也就是:[句子 B] + [句子 C]
    

看最终两个箱子的对比效果:

  • 第一个箱子:句子 A。句子 B。
  • 第二个箱子:句子 B。句子 C。句子 D。

“句子 B”同时出现在了两个箱子里。如果用户提问刚好踩在句子 B 和句子 C 之间的因果逻辑上,无论系统召回哪一个箱子,大模型都能看到完整的上下文。

在真实的业务场景中,单纯依靠“标点符号 + 固定 500 字滑动窗口”,是无法彻底消除语义歧义。后面我们再展开说说生产级别的系统如何逐层破局的。

代码的最后:

  flushBlock(); // 最后的收尾
  return chunks;

回顾主循环:代码只有在“遇到下一个标题时” ,才会把手推车里的货物打包推走。
读到文章最后一篇《账号注销》的正文 注销后数据彻底清除。 时,后面已经没有任何新的 # 标题了!
如果没有最后这行显式的 flushBlock(),小推车里装的最后这段文字就永远留在了推车里,根本没被推进 chunks 结果里,文档末尾的内容就被神不知鬼不觉地吞掉了。

向量嵌入与数学度量

大模型是基于高维浮点数进行运算的。所谓 Embedding(词嵌入/文本向量化) ,就是利用深度神经网络,将人类的自然语言映射为高维欧氏空间里的一个稠密向量(Dense Vector) 。

例如,OpenAI 的 text-embedding-3-small 将每段话转换为一个包含 1536 个浮点数的数组:

"苹果公司发布了全新手机" → [ 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)2d = \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 实现向量点积、模长计算与除零保护。

数学公式:

/**
* 计算向量模长 (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
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 就大功告成了。但在生产测试第一天,你就会遭遇经典翻车:

用户提问:"请问 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.5df(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)

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

  • 文档长度惩罚(bbb 系数,默认 0.75) :一本长达 100 页的书提到了“退款”,可能只是顺带一笔;而一份 50 字的短通知提到了“退款”,说明全篇都在讲退款。bbb 系数根据当前文档长度 ∣D∣|D|∣D∣ 与库中平均长度 avgdlavgdlavgdl 对得分进行无偏矫正。

大白话就是:谁在最短的篇幅里精准命中了最少见的关键词,谁就排最前面,而且别想靠疯狂复制同一个词来刷分。(生僻词才值钱、短文章更聚焦、复读机没收益)

现在我们来手写 BM25 检索与词频倒排算法

// 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 字提了一句“此方案不支持退款”。
词数 dldldl极小极大
公式里的分母变小变得极大
最终得分分子小分母更小,得分暴增分母被篇幅急剧放大,得分大幅缩减
最终结果排名靠前沉底,被判定为长篇顺带提及

混合检索与倒数排序融合

混合检索,就是让向量语义检索(理解意图同义词)与 BM25 关键词检索(捕获精确字面量)并发运行,取长补短:

用户输入: "我想了解一下 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][0, 1][0,1] 之间;
  • BM25 分数是基于对数与词频的无界统计值,得分经常在 [0,30][0, 30][0,30] 甚至更高,直接相加会导致 BM25 的权重被放大几十倍,完全碾压向量检索,混合检索退化为纯文本搜索。

最主流的优雅解法,是 RRF(Reciprocal Rank Fusion,倒数排序融合) 。
它抛弃绝对得分数值,只看文档在各路检索中的相对排名(Rank) :

RRF_Score(d)=∑m∈M1k+rankm(d)RRF\_Score(d) = \sum_{m \in M} \frac{1}{k + rank_m(d)}RRF_Score(d)=∑m∈M​k+rankm​(d)1​

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

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

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

  • 倒数特性:排名越靠前(rankrankrank 越小),1k+rank\frac{1}{k + rank}k+rank1​越大,得分越高!

// 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
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 文本,一步步加工成可检索的向量与倒排索引。

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 在用户提问时的核心。它包含了三大步骤:追问改写 ➔ 双路分流检索 ➔ 上下文打桩组装。

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)”漏斗架构。

  1. 上下文标号打桩:[${i + 1}] (来源: ...) 的妙用

把几个 chunk 的正文直接用换行拼在一起,大模型回答时根本不知道一句话出自哪里。
这里在拼装 context 时,特意打上了格式化的桩:

[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 编排代码一行都不用改。

多轮对话追问改写与引用溯源

在真实对话中,用户的沟通是充满代词和上下文依赖的:

Round 1:
用户: "请问贵公司的专业版套餐是多少钱?"
助手: "专业版是每月 99 元。"

Round 2:
用户: "那它支持多少人协同?"

如果直接拿 Round 2 的“那它支持多少人协同?”丢进向量检索库,结果是什么?
“它”到底是指专业版?企业版?还是某个外部软件?检索库没有任何历史会话,召回结果必定彻底偏离,导致问答完全崩溃。

我们现在实现智能追问改写器,通过一个小参数大模型调用,将历史对话与模糊追问改写为一个自包含(Self-contained)的完整搜索词。查看源码 lib/rag/query-rewriter.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:

// 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 维的标准单位向量:

// 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,1][0, 1][0,1] 之间。

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

代码阅读指引

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

资料展示:
下面是我整理的AI大模型       学习资料和工具包预览,适合收藏后按主题逐步学习

Logo

更多推荐