向量搜索真的懂语义吗?一段代码带你读懂 RAG 核心逻辑

0 阅读8分钟

在接触大语言模型(LLM)开发的过程中,我们很快会遇到一个核心痛点:模型的知识是有截止日期的,且容易产生“幻觉”。当用户询问模型训练数据之外的私有知识或最新信息时,模型往往会一本正经地胡说八道。

为了解决这个问题,RAG(检索增强生成)成为了目前最主流的方案。最近我通过编写几个 Demo 代码,对 RAG 的核心流程——特别是数据加载、文本切片(Splitter)和向量检索——做了一次深入的实践。今天想抛开那些花哨的图表,从代码实现的细节出发,踏实地聊聊 RAG 到底是怎么工作的,以及我们在写代码时需要权衡哪些细节。

一、为什么我们需要 RAG?

简单来说,LLM 就像一个记忆力超群但无法联网的学霸。它背下了海量的书(训练数据),但它不知道你公司内部的员工手册,也不知道昨天刚发生的新闻。

如果我们强行问它不知道的事情,它为了完成“回答问题”这个指令,会基于概率预测下一个字,从而产生幻觉。RAG 的思路很像“开卷考试”:

  1. 检索(Retrieval):先去知识库(课本)里找到和问题相关的段落。
  2. 增强(Augmentation):把找到的段落和问题一起打包。
  3. 生成(Generation):让 LLM 基于这些资料回答问题。

这样,模型的回答就有了依据,大大减少了胡编乱造的可能。

二、核心难点:如何让机器“理解”语义?

在传统的搜索中,我们依赖关键词匹配。如果用户搜“水果”,而文档里只写了“苹果”和“香蕉”,传统搜索可能无法命中。

RAG 使用**向量(Vector)**来解决这个问题。

向量的本质

我们可以把向量想象成一个多维坐标系。每一个维度代表一个语义特征,比如“可食用性”、“硬度”、“颜色”等。

  • 苹果可能在 [可食用性:0.9, 硬度:0.4] 的位置。
  • 香蕉可能在 [可食用性:0.9, 硬度:0.1] 的位置。
  • 石头可能在 [可食用性:0.1, 硬度:0.9] 的位置。

当用户搜索“能吃的水果”时,系统会将这个查询也转换成一个向量。通过计算余弦相似度(Cosine Similarity),系统会发现“苹果”和“香蕉”的向量方向与查询向量更接近,从而检索出来。这就是语义搜索的核心:它匹配的是意思,而不是字面。

在代码实现中,我们使用 OpenAIEmbeddings 或其他 Embedding 模型,将文本转化为这种高维向量。

三、文本切片(Splitter):被忽视的关键环节

在 RAG 流程中,很多人关注模型调优,却忽视了数据预处理。实际上,如何切分文档(Chunking)直接决定了检索的质量。

1. 为什么要切片?

LLM 的上下文窗口(Context Window)是有限的。我们不能把一本几十万字的书全部塞进 Prompt 里。此外,如果文档太长,关键的检索信息可能会被淹没在大量无关文本中,导致向量检索的准确度下降。

2. 切片策略分析

在我实践的代码中,使用了 RecursiveCharacterTextSplitter。这是一个非常经典的切片器。让我们看看它的核心配置及其背后的考量:

const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 400,       // 每个片段的最大长度
    chunkOverlap: 50,     // 片段之间的重叠长度
    separators: ['。', ',', '!', '?'] // 优先使用的分割符
})

这里有两个关键参数值得深思:

  • chunkSize(切片大小)

    • 设得太小:语义可能不完整。比如一句话被切断,前半段在片段 A,后半段在片段 B。检索时如果只命中 A,模型可能无法理解完整意思。
    • 设得太大:会消耗更多的 Token,增加成本,且可能包含过多噪声,干扰模型判断。
    • 经验值:通常 200-500 个字符(或 Token)是一个比较稳妥的区间,具体取决于你的文档类型。如果是代码,可能需要按行切分;如果是对话,可能按轮次切分。
  • chunkOverlap(重叠部分)

    • 这是很多初学者容易忽略的配置。设置 50 个字符的重叠,意味着片段 A 的末尾和片段 B 的开头有 50 个字符是重复的。
    • 作用:保证语义的连贯性。防止关键信息正好落在切分点上被“腰斩”。虽然增加了一点存储成本,但对于检索精度的提升是显著的。
  • separators(分割符)

    • 代码中优先使用了 ['。', ',', '!', '?']。这是符合中文语境的。
    • RecursiveCharacterTextSplitter 的工作原理是递归的:它首先尝试用 切分,如果切分后的块还是太大,再尝试用 切分,最后才按字符强制切分。这样能最大程度保持句子的完整性。

3. 字符数 vs Token 数

在测试代码中,我特意对比了字符长度和 Token 长度:

console.log('character length', doc.pageContent.length);
console.log('token length', enc.encode(doc.pageContent).length);

这是一个重要的细节:LLM 的计费和限制是基于 Token 的,而不是字符。在中文环境下,1 个 Token 大约对应 1.5 到 2 个汉字,但在英文或代码中比例不同。 如果在生产环境中,建议直接使用支持 Token 计数的 Splitter(如 TokenTextSplitter),或者在设置 chunkSize 时预留足够的余量,防止超出模型限制。

四、检索与评分:如何判断找得准不准?

将切片后的文档通过 Embedding 模型转化为向量,并存入向量数据库(Demo 中使用了 MemoryVectorStore,生产环境推荐 Pinecone、Milvus 等),就建立了知识库。

当用户提问时,系统会计算问题向量与库中所有文档向量的距离。

const scoreResults = await vectorStore.similaritySearchWithScore(question, 2);

这里返回的 score 通常代表距离(Distance),而不是相似度(Similarity)。

  • 距离越接近 0,表示越相似。
  • 在代码中,我做了这样一个转换来直观展示:const similarity = (1 - score).toFixed(2)
  • 如果相似度是 0.85,说明检索到的内容与问题高度相关;如果是 0.4,可能只是勉强沾边。

实际开发中的坑: 有时候检索出来的内容相似度分数很高,但内容其实不相关。这可能是因为 Embedding 模型对某些特定领域的术语理解不够。解决方法是引入元数据过滤(Metadata Filtering)。 在 hello-rag.mjs 中,我给每个文档加了 metadata(如 character, chapter):

metadata: { 
  chapter: 1, 
  character: "张三", 
  type: "角色介绍", 
}

在检索时,可以限制只检索 character: "张三" 的片段。这在处理多用户、多类别知识库时非常重要,能避免“张冠李戴”。

五、组装 Prompt:最后的临门一脚

检索到的内容只是素材,如何让 LLM 用好这些素材,取决于 Prompt 的设计。

const prompt = `
基于以下小说片段回答问题,用客观的语言。
如果小说中没有提及,就说“这个小说里没有提到这个细节”

小说片段:
${context}

问题:
${q}

老师的回答:
`;

这个 Prompt 有几个值得借鉴的设计点:

  1. 角色设定:虽然这里没显式写“你是一个老师”,但通过“老师的回答”和“温暖生动的语言”,隐式约束了回答风格。
  2. 边界控制:明确指示“如果故事中没有提及,就说..."。这是防止幻觉的关键。如果不加这句话,LLM 可能会利用它自带的训练数据去强行回答,导致答案与知识库不符。
  3. 结构清晰:使用分隔符将“背景知识”和“用户问题”分开,帮助模型区分哪些是参考信息,哪些是任务指令。

六、从 Demo 到生产的距离

通过这几个文件的练习,我们跑通了 RAG 的最小闭环。但从 Demo 到生产环境,还有几座大山需要跨越:

  1. 向量数据库的选择MemoryVectorStore 存在内存里,重启就没了,且数据量大时性能极差。生产环境需要持久化、支持高并量的向量数据库。
  2. 数据更新策略:知识库里的文档变了怎么办?是全部重新 Embedding,还是只更新变动的部分?这需要设计好的 ETL 流程。
  3. 检索优化:单纯的向量检索(Vector Search)有时不够精准。高级方案会结合关键词检索(BM25),即混合检索(Hybrid Search),再对结果进行重排序(Rerank),以进一步提升准确率。
  4. 成本与延迟:每一次问答都涉及 Embedding 调用和 LLM 调用,成本和耗时是线性的。如何通过缓存、更小的模型蒸馏来优化,是工程化的重点。

总结

RAG 并不是一个黑盒魔法,它是由数据加载、文本切片、向量化、检索、生成等多个确定性步骤组成的流水线。

在这个过程中,文本切片(Splitter)的质量往往决定了检索的上限。如果切分得支离破碎,再强大的模型也难以拼凑出完整的语义。而Prompt 的约束则决定了生成的下限,防止模型脱离知识库自由发挥。

希望这篇基于代码实践的分析,能帮你更踏实地理解 RAG 的每一个环节。技术没有捷径,理解原理,调优细节,才能构建出真正可用的 AI 应用。