从零理解 RAG:解决 LLM 幻觉痛点

0 阅读11分钟

一、 为什么我们需要 RAG?(直击 LLM 的痛点)

要理解 RAG,首先得明白我们为什么要大费周章地搞这一套流程。

  • 大模型的知识来源局限: LLM(大型语言模型)的知识来源于其训练阶段,训练时我们会给它投喂庞大的数据集。但是,训练一旦结束,它的知识库就“冻结”了,无法实时获取企业内部的私有数据或最新的外部资讯。
  • 致命的“幻觉”(Hallucination): AIGC(人工智能生成内容)存在一个普遍问题:当你问了 LLM 一个它不知道(或超出其训练数据)的问题时,它并不会总是坦诚地说“我不知道”,而是会“认认真真地胡乱回答”。这就是所谓的幻觉。
  • RAG 的解法: RAG(Retrieval-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. 语义搜索的执行流程

  1. 准备知识库:将各种类型的文件(文本、PDF、MP3、视频)转化为文本,切片成文档碎片(Document),然后提前全部 Embedding(向量化)并存入向量数据库。
  2. 提问向量化:将用户的原始 Prompt 也进行 Embedding 处理,转化为向量。
  3. 计算相似度:在可视化的向量空间中,通过计算提问向量与知识库向量之间的 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 对象不仅包含文本,还包含关键的元数据(Metadata)

new 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,代表相同语义下,中文可能被拆解成了更多的 Token

3. 为什么它在 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. 增强 Prompt(Augmented)与最终生成(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,最终给出了完美的解答。