从零理解 RAG:解决 LLM 幻觉痛点
一、 为什么我们需要 RAG直击 LLM 的痛点要理解 RAG首先得明白我们为什么要大费周章地搞这一套流程。大模型的知识来源局限LLM大型语言模型的知识来源于其训练阶段训练时我们会给它投喂庞大的数据集。但是训练一旦结束它的知识库就“冻结”了无法实时获取企业内部的私有数据或最新的外部资讯。致命的“幻觉”HallucinationAIGC人工智能生成内容存在一个普遍问题当你问了 LLM 一个它不知道或超出其训练数据的问题时它并不会总是坦诚地说“我不知道”而是会“认认真真地胡乱回答”。这就是所谓的幻觉。RAG 的解法RAGRetrieval-Augmented Generation正是为了解决 LLM 幻觉而生的终极武器。它的核心思想是不让 LLM 盲猜而是先在本地或私有数据库中检索Retrieve出相关的知识片段将这些片段作为参考资料来增强Augment用户的 Prompt最后再让 LLM 基于这些确凿的证据进行生成Generation。如果在知识库中没有匹配到相关内容我们甚至可以在 Prompt 中硬性规定它回答“不知道”从而彻底杜绝胡编乱造。二、 核心底层逻辑用“向量”降维打击“关键词”在 RAG 的检索环节中最核心的技术是向量搜索而非传统的关键词匹配。1. 关键词匹配的致命缺陷传统的文本匹配无法真正理解“语义”。举个最简单的例子“苹果”这个词在不同的语境下它既可以是用来吃的水果也可以是用来打电话的手机。如果你搜“好用的智能手机”传统系统看到文章里全是“苹果”可能根本不知道它和“手机”有关这就造成了搜索的断层。2. 向量Vector是如何表达语义的向量本质上就是用一组数字数组来表达并存储一个信息的抽象概念例如[0.1, 0.2, ......]。我们可以想象一个多维的坐标系空间向量的每一个维度都代表着一种独特的语义特征维度一食用性0 代表无1 代表极高维度二硬度0 代表液体1 代表坚硬如骨头基于这个可视化的特征空间我们可以对万事万物进行坐标定位水果[0.9, 0.3]食用性极高硬度偏低苹果[0.9, 0.5]食用性极高中等硬度香蕉[0.9, 0.1]食用性极高几乎没有硬度石头[0.1, 0.9]几乎无法食用硬度极高3. 语义搜索的执行流程准备知识库将各种类型的文件文本、PDF、MP3、视频转化为文本切片成文档碎片Document然后提前全部 Embedding向量化并存入向量数据库。提问向量化将用户的原始 Prompt 也进行 Embedding 处理转化为向量。计算相似度在可视化的向量空间中通过计算提问向量与知识库向量之间的Cosine余弦相似度找出距离最近、语义最相关的几段文档。因为“苹果”和“水果”在向量空间中的距离绝对比“苹果”和“石头”近得多三、 数据摄入Loader万物皆可 Document知识库的构建是从获取数据开始的。LangChain 社区langchain/community提供了极其丰富的 Loader 模块可以载入任何类型的文件网页、PDF、Word 等它们的最终目的都是将原始文件转换为统一的Document对象。网页抓取的利器CheerioWebBaseLoader以抓取网页内容为例我们常常会结合cheerio来使用import cheerio; // 在后端使用css选择器像操作前端一样查找DOM节点 import { CheerioWebBaseLoader } from langchain/community/document_loaders/web/cheerio; const cheerioLoader new CheerioWebBaseLoader( https://juejin.cn/post/7233327509919547452?searchId..., { selector: .main-area p // 只精准抓取正文段落 } ) const documents await cheerioLoader.load();import cheerio;的作用Cheerio 是一个在 Node.js 中使用的 HTML 解析库。这里作为副作用导入确保它被加载。它的作用是将 HTML 字符串解析成可遍历的 DOM 结构让你能在服务端像写前端 jQuery 一样用 CSS 选择器去查找和操作节点。CheerioWebBaseLoader的作用它负责发起 HTTP 请求抓取网页底层调用 cheerio 进行解析并将提取出的文本转化为 LangChain 体系内的Document对象供后续的切分和向量化使用。深入理解 Document 对象一个标准的Document对象不仅包含文本还包含关键的元数据Metadatanew Document({ pageContent: 光光是一个活泼开朗的小男孩..., metadata: { chapter: 1, character: 光光, type: 角色介绍, mood: 活泼 }, })pageContent核心文本内容它是后续参与 Embedding 向量化和被检索的主体。metadata知识块的抽象概念属性。它不直接参与语义相似度计算但在工程中极其重要常用于检索时的“前置过滤”例如只在chapter: 1的范围内进行向量搜索。四、 文本切分Splitter寻找语义与长度的完美平衡Loader 加载得到的是一整篇庞大的文档Raw Document。直接将几万字转为一个向量会导致语义极其模糊且超出大模型的上下文限制。因此我们需要将大文件切片Text Splitter转换为小的块Chunks。LangChain 提供了丰富的 Splitter 家族它们都继承自父类TextSplitter。但请注意父类切分的是文本如果你的原始文件是 MP3 或 MP4必须先转成文本才能使用。1. Splitter 家族的四大金刚CharacterTextSplitter最基础的切分器直接按指定的字符分隔符Character separator进行硬切分。缺点是不顾及语义连贯性。RecursiveCharacterTextSplitter目前最人性化、最推荐的切分器语义完整性特别好。MarkdownTextSplitter专门处理 Markdown 文档它是RecursiveCharacterTextSplitter的子类通过识别#、##、###等标题层级进行递归切分。TokenTextSplitter严格按照 Token 数量进行切割完美对齐大模型的计费和上下文限制。2. 深度拆解 RecursiveCharacterTextSplitter 的三大核心参数这是我们在 RAG 中最常用的切分器。它为什么“人性化”因为它遵循了人类阅读的天然语义逻辑。import { RecursiveCharacterTextSplitter } from langchain/textsplitters; const textSplitter new RecursiveCharacterTextSplitter({ chunkSize: 400, // 每个分块的字符数 chunkOverlap: 50, // 分块之间的重叠字符数 separators: [。, , , ] // 分割符优先使用段落分割 })separators分隔符优先级中文里的。、、、是天然的语义切割符。它会优先尝试用最高级的分隔符比如。最优先去切。chunkSize块大小系统会进行递归尝试尽量让每一块的大小无限接近你设定的chunkSize同时又保持语义完整。chunkOverlap重叠区极其关键的设计当系统尝试非首选符号比如找不到句号只能按逗号甚至强行切断时由于不得不切断语义就弱下来了。此时我们用 Overlap 牺牲一定的存储空间通常是 chunkSize 的 10% 左右进行上下文重复以此来弥补语义的断层。注意并不是每一次切分都会重叠如果恰好在句号处完美切分且实现了语义完整就不会进行无意义的重叠。3. Token 与 Character 的本质区别 (tiktoken 的重要性)在大模型的语境下计费和推理限长并不是按“字符Character”算的而是按Token算的。大模型生成文本时是按 token 不断推理生成的。不同的语言表达同一语义对应的 Token 数量可能截然不同。这就需要引入tiktoken库来精准计算import { getEncodingNameForModel, getEncoding } from js-tiktoken; // 根据模型名获取该模型使用的分词器名称 const modelName gpt-4; const encodingName getEncodingNameForModel(modelName); // gpt-4 通常使用 cl100k_base const enc getEncoding(encodingName); // 把字符串转成 token id 数组.length 即这段文本的 token 数 console.log(apple, enc.encode(apple), enc.encode(apple).length); console.log(苹果, enc.encode(苹果), enc.encode(苹果).length);为什么这很重要因为对于英文字符apple和中文字符苹果虽然字数少但底层编码的 Token 长度差异很大。在追求极高精度的生产环境中为了防范超长报错和精确控制成本我们会抛弃 Character直接使用TokenTextSplitter并传入对应的encodingName: cl100k_base这样按 token 控长和计费才是最准确的。深度解析enc.encode()方法1. 它到底是什么What在js-tiktoken这个库中enc.encode()方法的核心作用是分词与编码。它负责把人类可读的字符串精准地转换成大模型底层使用的Token ID 数组。你可以把它理解为大模型自带的“密码本翻译器”。2. 核心代码表现结合我们在测试文件中的逻辑来看看它的具体运行机制import { getEncodingNameForModel, getEncoding } from js-tiktoken; // 1. 获取模型对应的编码器名称例如 gpt-4 通常使用 cl100k_base const encodingName getEncodingNameForModel(gpt-4); // 2. 实例化这个编码器 const enc getEncoding(encodingName); // 3. 调用 encode() 进行编码它会返回一个包含 ID 的数组 const englishTokens enc.encode(apple); // 假设底层映射输出数组 [16113] console.log(englishTokens.length); // 数组长度为 1即消耗 1 个 Token const chineseTokens enc.encode(苹果); // 假设中文被映射为数组 [43081, 32190] console.log(chineseTokens.length); // 数组长度为 2代表相同语义下中文可能被拆解成了更多的 Token3. 为什么它在 RAG 链路中不可或缺Why精准的“电子秤”大模型的 API 计费标准、以及它能接收的最大上下文长度Context Window全部都是按 Token 数量计算的而不是按字符长度Character Length。enc.encode(...).length就是我们在投喂数据前用来精确测量数据体积的标尺。揭露语言差异的真相不同语言表达同一个语义对应的 Token 数量往往不同。通常情况下因为多数大模型的训练语料以英文为主中文字符在编码时往往会被切碎消耗比英文更多的 Token。指导 Chunk 切分在 RAG 的文本分块Splitter阶段如果我们只看字符长度如pageContent.length极容易造成实际传入大模型时 Token 溢出。所以在严谨的工程开发中我们会遍历切分好的文档块用enc.encode(doc.pageContent).length校验每一个块的真实 Token 长度以确保万无一失。用一句大白话总结enc.encode帮我们把文字变成了大模型认识的数字顺便帮我们算清楚了这波操作究竟要花多少钱、占多大内存。五、 从零构建 RAG 的完整代码实战理解了前面的所有零件现在我们把它们组装成一台跑得通的 RAG 机器。这里涵盖了初始化、向量存储、检索、打分和最终生成。1. 初始化模型与向量环境import dotenv/config; import { ChatOpenAI, OpenAIEmbeddings } from langchain/openai; import { MemoryVectorStore } from langchain/classic/vectorstores/memory; // 初始化聊天模型参数 temperature: 0 保证回答的严谨性拒绝发散思维 const model new ChatOpenAI({ modelName: process.env.OPENAI_API_MODEL_NAME, apiKey: process.env.OPENAI_API_KEY, configuration: { baseURL: process.env.OPENAI_API_BASE_URL }, temperature: 0, }); // 初始化向量转化模型 const embeddings new OpenAIEmbeddings({ modelName: process.env.EMBEDDING_MODEL_NAME, apiKey: process.env.OPENAI_API_KEY, configuration: { baseURL: process.env.OPENAI_API_BASE_URL }, });2. 数据入库与检索器配置这里我们使用MemoryVectorStore内存向量数据库它的特点是轻量快捷但进程一结束数据就会丢失适合本地调试。// documents 数组是我们在前面的流程中Loader Splitter准备好的文档块 const vectorStore await MemoryVectorStore.fromDocuments( documents, embeddings ) // 创建检索器k: 3 代表每次检索返回最相似的 3 条结果 const retriever vectorStore.asRetriever({ k: 3 });3. 余弦相似度计算与文档检索这是 RAG 最核心的“Retrieval”动作。系统会先将question转换为向量然后通过向量搜索和 Cosine 相似度计算在库中找到最相似的文档。const question 光光和东东各自擅长什么; // 直接调用 invoke 获取文档 const retrievedDocs await retriever.invoke(question); // 进阶如果你想查看具体的“相似度得分”需要调用 similaritySearchWithScore const scoreResults await vectorStore.similaritySearchWithScore(question, 3); console.log(\n [检索到的文档及相似度评分]); retrievedDocs.forEach((doc, i) { // 匹配打分结果 const scoreResult scoreResults.find( ([scoredDoc]) scoredDoc.pageContent doc.pageContent ); // 注意底层返回的往往是“距离Distance”所以用 1 - score 转换为直观的“相似度” const score scoreResult ? scoreResult[1] : null; const similarity score ? (1 - score).toFixed(2) : N/A; console.log(\n 文档 ${i 1} 相似度${similarity}); console.log(\n 文档内容${doc.pageContent}); console.log(\n 文档元数据${JSON.stringify(doc.metadata)}); })4. 增强 PromptAugmented与最终生成Generation最后一步将检索出来的几段相关文档retrievedDocs拼接起来融入到原始 Prompt 中让 LLM 拿着这些“增强”过的信息去完美解答。// 将检索到的片段拼接成文本上下文 const context retrievedDocs .map((doc, i) [片段${i 1}]\n ${doc.pageContent}) .join(\n\n---\n\n); // 构造增强型 Prompt并硬性规定防御机制 const prompt 你是一个讲友情故事的老师 基于以下片段回答问题用温暖生动的语言。 如果故事中没有提及就说“这个故事里没有提到这个细节” 故事片段 ${context} 问题 ${question} 老师的回答 ; // 交给 LLM 最终生成 const response await model.invoke(prompt); console.log(response.content);至此通过如果没有匹配到不知道这种 Prompt 的硬性约束我们彻底锁死了大模型胡编乱造的可能。它拿到增强的 prompt最终给出了完美的解答。