在接触大语言模型(LLM)开发的过程中,我们很快会遇到一个核心痛点:模型的知识是有截止日期的,且容易产生“幻觉”。当用户询问模型训练数据之外的私有知识或最新信息时,模型往往会一本正经地胡说八道。
为了解决这个问题,RAG(检索增强生成)成为了目前最主流的方案。最近我通过编写几个 Demo 代码,对 RAG 的核心流程——特别是数据加载、文本切片(Splitter)和向量检索——做了一次深入的实践。今天想抛开那些花哨的图表,从代码实现的细节出发,踏实地聊聊 RAG 到底是怎么工作的,以及我们在写代码时需要权衡哪些细节。
一、为什么我们需要 RAG?
简单来说,LLM 就像一个记忆力超群但无法联网的学霸。它背下了海量的书(训练数据),但它不知道你公司内部的员工手册,也不知道昨天刚发生的新闻。
如果我们强行问它不知道的事情,它为了完成“回答问题”这个指令,会基于概率预测下一个字,从而产生幻觉。RAG 的思路很像“开卷考试”:
- 检索(Retrieval):先去知识库(课本)里找到和问题相关的段落。
- 增强(Augmentation):把找到的段落和问题一起打包。
- 生成(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 有几个值得借鉴的设计点:
- 角色设定:虽然这里没显式写“你是一个老师”,但通过“老师的回答”和“温暖生动的语言”,隐式约束了回答风格。
- 边界控制:明确指示“如果故事中没有提及,就说..."。这是防止幻觉的关键。如果不加这句话,LLM 可能会利用它自带的训练数据去强行回答,导致答案与知识库不符。
- 结构清晰:使用分隔符将“背景知识”和“用户问题”分开,帮助模型区分哪些是参考信息,哪些是任务指令。
六、从 Demo 到生产的距离
通过这几个文件的练习,我们跑通了 RAG 的最小闭环。但从 Demo 到生产环境,还有几座大山需要跨越:
- 向量数据库的选择:
MemoryVectorStore存在内存里,重启就没了,且数据量大时性能极差。生产环境需要持久化、支持高并量的向量数据库。 - 数据更新策略:知识库里的文档变了怎么办?是全部重新 Embedding,还是只更新变动的部分?这需要设计好的 ETL 流程。
- 检索优化:单纯的向量检索(Vector Search)有时不够精准。高级方案会结合关键词检索(BM25),即混合检索(Hybrid Search),再对结果进行重排序(Rerank),以进一步提升准确率。
- 成本与延迟:每一次问答都涉及 Embedding 调用和 LLM 调用,成本和耗时是线性的。如何通过缓存、更小的模型蒸馏来优化,是工程化的重点。
总结
RAG 并不是一个黑盒魔法,它是由数据加载、文本切片、向量化、检索、生成等多个确定性步骤组成的流水线。
在这个过程中,文本切片(Splitter)的质量往往决定了检索的上限。如果切分得支离破碎,再强大的模型也难以拼凑出完整的语义。而Prompt 的约束则决定了生成的下限,防止模型脱离知识库自由发挥。
希望这篇基于代码实践的分析,能帮你更踏实地理解 RAG 的每一个环节。技术没有捷径,理解原理,调优细节,才能构建出真正可用的 AI 应用。