RAG实战:从零打造智能知识库问答系统

99 阅读10分钟

RAG 实战:用 LangChain 从零打造智能知识库问答系统

RAG 不是让大模型重新学习一遍知识,而是在它回答之前,先把相关资料找出来,再把资料放进 Prompt。本文从大模型幻觉讲起,结合“光光和东东”的故事数据,完整拆解一个 LangChain RAG Demo。

【首图:请插入 rag-flow-cartoon.svg,放在标题下方】

一、为什么需要 RAG?

大模型能回答什么,主要取决于训练数据。如果问题涉及企业内部文档、项目规范、产品最新说明或实时业务数据,模型可能根本没有这些知识。

更麻烦的是,模型不一定会直接说“不知道”,而可能生成一段听起来合理、实际上没有依据的内容,这就是大模型幻觉。

例如,用户问:

光光和东东是怎么成为朋友的?

如果模型没有读过这段故事,它可能会根据常见的叙事模式自行猜测。RAG 的做法是先从故事知识库里找出相关片段,再让模型根据片段回答。

用户问题
  -> 检索知识库中的相关片段
  -> 把片段加入 Prompt
  -> 大模型基于资料生成回答

RAG 的全称是 Retrieval-Augmented Generation

  • Retrieval:检索;
  • Augmented:增强上下文;
  • Generation:生成回答。

二、RAG 和微调有什么区别?

微调适合改变模型的任务能力、输出格式或表达风格;RAG 更适合给模型补充经常变化的外部知识。

对比项RAG微调
主要解决让模型获得新资料改变模型能力或风格
知识更新更新知识库即可通常需要重新训练
成本相对较低通常更高
是否方便溯源可以关联原文和元数据通常不容易追踪来源
典型场景企业文档、产品手册、客服知识库固定格式、特定任务、稳定风格

如果目标只是“让模型知道公司的内部资料”,通常应该先考虑 RAG,而不是直接微调。

【配图:请插入 rag-vs-finetune-cartoon.svg,放在本节表格之后】

三、RAG 的核心链路

RAG 可以拆成入库和查询两个阶段。

3.1 入库阶段

原始文档
  -> 文档切分
  -> Embedding 向量化
  -> 保存向量、原文和元数据

真实项目中的文档可能来自 PDF、网页、Markdown、数据库或企业内部系统。长文档通常需要按标题、章节、段落或固定长度切成多个片段。

切得太大,检索结果会包含大量无关内容;切得太小,又可能破坏语义。因此,文档切分是 RAG 效果的第一个关键因素。

3.2 查询阶段

用户问题
  -> 问题向量化
  -> 和文档向量计算相似度
  -> 返回 Top-K 片段
  -> 拼接成上下文
  -> 交给大模型生成答案

四、向量搜索是怎么工作的?

关键词搜索依赖字面匹配,但用户问题和文档的表达方式可能不同。例如“他们的友谊是怎么变深的”和“他们在比赛中互相帮助,关系变得更加亲密”,关键词不同,语义却很接近。

Embedding 模型会把文本转换成一组数字,也就是向量。语义接近的文本,通常会在向量空间中更接近。

“东东喜欢画画” -> [0.12, -0.34, 0.56, ...]

查询时需要做两次向量化:

  1. 入库时,把每个文档片段转成向量;
  2. 查询时,把用户问题转成向量。

然后计算问题向量和文档向量之间的相似度。常见方法是余弦相似度:

cos(A, B) = A · B / (|A| × |B|)

不要把向量维度简单理解成“维度越高,效果越好”。最终效果还取决于 Embedding 模型、文档切分、数据质量和检索策略。

【配图:请插入 rag-vector-search-cartoon.svg,放在本节公式之后】

五、项目结构和环境配置

rag-demo/
├── .env
├── src/
│   ├── index.mjs
│   └── custom-embeddings.mjs
└── readme.md

.env 示例:

MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

不要把真实 API Key 提交到 Git 仓库。生产环境还应通过后端服务保存密钥,避免直接暴露在浏览器端。

六、逐段解析核心代码

6.1 导入依赖

import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { CustomEmbeddings } from "./custom-embeddings.mjs";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { Document } from "@langchain/core/documents";
依赖作用
dotenv/config.env 配置加载到 process.env
ChatOpenAI调用聊天模型生成回答
CustomEmbeddings提供 Embedding 接口
MemoryVectorStore在内存中保存和搜索向量
Document封装正文和元数据

6.2 初始化聊天模型

const model = new ChatOpenAI({
  temperature: 0,
  model: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

temperature: 0 通常能让回答更稳定,但它不能保证模型绝对没有幻觉。是否产生幻觉,还取决于检索结果、Prompt、资料质量和模型本身。

baseURL 允许 LangChain 通过兼容 OpenAI 格式的接口访问不同厂商的模型服务。

6.3 初始化 Embedding

const embeddings = new CustomEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  baseURL: process.env.OPENAI_BASE_URL,
});

这里的 embeddings 不是负责写答案的聊天模型,而是负责把文本转换成向量。

入库和查询必须使用同一套向量空间。也就是说,文档和问题最好使用同一个 Embedding 模型,否则它们的向量不一定可以直接比较。

6.4 创建 Document

const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他特别擅长踢足球。`,
    metadata: {
      chapter: 1,
      character: "光光",
      type: "角色介绍",
      mood: "活泼",
    },
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他们从幼儿园就认识了。`,
    metadata: {
      chapter: 2,
      character: "东东",
      type: "角色介绍",
      mood: "温馨",
    },
  }),
];

一个 Document 主要有两个部分:

  • pageContent:正文,会参与向量化和检索;
  • metadata:附加信息,可用于过滤、分类和显示来源。

例如,可以用 chapter 标记章节,用 character 标记角色。元数据通常不参与 Embedding,但在调试和回答溯源时非常有用。

【配图:请插入 document-structure-cartoon.svg,放在本节代码之后】

6.5 创建向量存储

const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings,
);

这行代码会遍历 documents,调用 Embedding 模型把每个 pageContent 转成向量,然后把向量、原文和元数据保存起来。

Document[]
  -> embedDocuments()
  -> 向量数组
  -> { content, embedding, metadata }

MemoryVectorStore 只把数据放在当前进程的内存里,程序重启后数据会丢失。因此它适合学习和测试,生产环境应使用持久化向量数据库。

【配图:请插入 vector-db-compare-cartoon.svg,放在本节最后】

6.6 创建 Retriever

const retriever = vectorStore.asRetriever({
  k: 3,
});

Retriever 可以理解成“向量检索接口”。k: 3 表示每次返回最相关的 3 个文档片段。

k 太小,可能漏掉必要信息;k 太大,又会把无关内容带进 Prompt。实际值需要结合片段长度、问题类型和模型上下文窗口调整。

6.7 执行检索

const question = "光光和东东是怎么成为朋友的?";
const docs = await retriever.invoke(question);

调用 invoke 后,内部大致会完成:

问题
  -> embedQuery(question)
  -> 和所有文档向量计算相似度
  -> 排序
  -> 返回前 k 个 Document

调用者不需要手动处理向量化和排序,只需要处理返回的 Document[]

6.8 查看相似度评分

const scoredResults =
  await vectorStore.similaritySearchWithScore(question, 3);

返回结果通常是:

[
  [document1, score1],
  [document2, score2],
]

这里必须注意:不同向量库、不同版本的实现可能使用不同的评分定义。有的返回相似度,分数越大越相关;有的返回距离,分数越小越相关。

因此,不能看到一个分数就直接称为“百分比”。使用前应查看当前向量库的实现或官方文档,确认分数的含义和方向。

对于本示例,如果确认返回的是距离,可以这样转换:

const similarity = score != null
  ? (1 - score).toFixed(4)
  : "N/A";

但这个转换只适用于明确知道 score 是距离且范围符合预期的情况。

6.9 调试检索结果

docs.forEach((doc, index) => {
  console.log(`[文档 ${index + 1}]`);
  console.log(`内容:${doc.pageContent.substring(0, 50)}...`);
  console.log(`章节:${doc.metadata.chapter}`);
  console.log(`角色:${doc.metadata.character}`);
});

调试 RAG 时,不要只看最后的回答。先检查检索出来的文档是否真正相关,能够更快定位问题到底出在检索、Prompt 还是生成阶段。

七、为什么示例使用 CustomEmbeddings?

学习代码中的自定义 Embedding 通过正弦函数生成向量,核心逻辑类似这样:

export class CustomEmbeddings {
  constructor() {
    this.dimension = 1536;
  }

  async embedDocuments(texts) {
    return texts.map((text, index) => {
      const seed = text.length * 1000 + index;
      return Array.from(
        { length: this.dimension },
        (_, i) => Math.sin(seed + i) * 0.5,
      );
    });
  }

  async embedQuery(text) {
    const seed = text.length * 1000;
    return Array.from(
      { length: this.dimension },
      (_, i) => Math.sin(seed + i) * 0.5,
    );
  }
}

这段代码的价值是演示 LangChain 所需要的 Embedding 接口,而不是提供真正的语义理解能力。

它有一个非常重要的限制:主要根据文本长度生成向量,两个长度相同但意思完全不同的句子,可能得到相同或非常接近的向量。因此,使用它得到的检索结果不能代表真实 RAG 效果。

生产环境必须替换成真实 Embedding 模型,并确保文档向量和问题向量使用同一模型、同一维度和兼容的向量空间。

八、构建上下文并生成回答

先把检索到的文档片段拼接成上下文:

const context = docs
  .map((doc, index) => `[片段 ${index + 1}]\n${doc.pageContent}`)
  .join("\n\n-----\n\n");

然后把上下文放进 Prompt:

const prompt = `
请严格根据下面的资料回答问题。
如果资料中没有答案,请直接说“资料中没有找到相关信息”,不要编造。

资料:
${context}

问题:
${question}

请用自然、友好的语言回答,并尽量说明答案来自哪一个片段。
`;

最后调用模型:

const answer = await model.invoke(prompt);

console.log("[AI 回答]");
console.log(answer.content);

到这里,RAG 的三个步骤就完整了:

  • retriever.invoke:检索;
  • context 加入 Prompt:增强;
  • model.invoke:生成。

九、完整代码主线

把前面的步骤压缩起来,核心逻辑就是:

const vectorStore = await MemoryVectorStore.fromDocuments(
  documents,
  embeddings,
);

const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke(question);

const context = docs
  .map((doc, index) => `[片段 ${index + 1}]\n${doc.pageContent}`)
  .join("\n\n-----\n\n");

const prompt = `
请根据资料回答问题,不要编造信息。

资料:
${context}

问题:${question}
`;

const answer = await model.invoke(prompt);
console.log(answer.content);

十、RAG 不等于绝对不会幻觉

RAG 能为模型提供外部资料,但不能保证答案百分之百正确。常见问题包括:

  • 文档切分破坏了完整语义;
  • 检索没有找到真正相关的片段;
  • k 设置不合理;
  • 知识库本身过期或存在错误;
  • Prompt 没有明确要求模型以资料为依据;
  • 模型对资料理解错误。

因此,RAG 效果是多个环节共同决定的:

知识库质量 × 切分策略 × Embedding 模型
× 检索策略 × Prompt 设计 × 生成模型

十一、生产环境优化方向

11.1 使用真实 Embedding 模型

import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

真实 Embedding 模型才具备语义表示能力。选择时要确认模型接口、向量维度、语言覆盖和价格是否适合项目。

11.2 使用持久化向量数据库

方案特点
Chroma轻量,适合开发和小型项目
PostgreSQL + pgvector适合已有 PostgreSQL 技术栈的项目
Milvus面向较大规模向量检索
Pinecone托管服务,减少基础设施维护

11.3 改进检索质量

常见方向包括:

  • 调整切分长度和重叠区间;
  • 使用元数据过滤;
  • 关键词检索和向量检索混合召回;
  • 对召回结果进行重排序;
  • 设置最低相关性阈值;
  • 为回答增加来源和引用片段。

不要直接假设所有向量库都支持相同的 filter 或重排序 API。具体写法应以当前使用的向量库和 LangChain 版本文档为准。

十二、总结

RAG 的本质是:

大模型回答之前,先把与问题相关的外部知识找出来,再让模型基于这些资料生成回答。

组件作用类比
Document封装文本和元数据书本的一页
Embedding把文本转换成向量语义翻译官
VectorStore保存和搜索向量图书馆
Retriever找出相关片段图书管理员
ChatModel生成自然语言回答老师
Prompt组织问题和背景资料答题纸

完整链路可以记成:

文档切分
  -> Embedding 向量化
  -> VectorStore 存储
  -> Retriever 检索
  -> Prompt 增强
  -> ChatModel 生成

学习 RAG 时,先抓住“检索、增强、生成”这条主线,再去理解 LangChain 的 API。框架只是把这些步骤组织起来,真正决定效果的,仍然是知识库、检索策略和提示词设计。