Skip to content

RAG:把文档向量化,基于向量实现真正的语义搜索

大模型所知道的知识,取决于训练的时候给它的数据集。如果你问它最近发生的事情,或者你企业内部私有文档的一些事情,它是不知道的。

但它很可能不会说自己不知道,而是会胡乱回答——这就是所谓的幻觉(以为它自己知道)。

如何解决大模型的幻觉呢?其实也很容易想到:用户要查询的内容,我们先去内部知识库里查一下,把它放到 Prompt 里再给大模型。这样大模型通过这些文档知道了背景知识,就可以回答相应的问题了。

这就是 RAG:

  • Retrieval 检索
  • Augmented 增强
  • Generation 生成

去知识库里检索用户所问内容的相关文档片段,作为背景知识加到 Prompt 里增强它,让大模型根据这些来生成回答。这是一个很容易想到的思路,也是很贴切的名字。

但有个问题:用户问了一个问题,你怎么把相关的文档片段查出来呢?比如用户查水果的信息,你要把苹果、香蕉、草莓的相关文档查出来。想想怎么做?

关键词搜索可以么?很明显不行。这种语义搜索就需要向量(Vector)了。

为什么需要向量:语义搜索

比如按照两个维度存储信息,分为食用性和硬度:

  • 维度 1:食用性(0 = 无,1 = 高)
  • 维度 2:硬度(0 = 软/液体,1 = 硬)

那这几个概念大概是这样的向量:

  • 水果:[0.9, 0.3] 极高食用性,中低硬度
  • 苹果:[0.9, 0.5] 高食用性,硬度适中
  • 香蕉:[0.9, 0.1] 高食用性,非常软
  • 石头:[0.1, 0.9] 几乎不可食用,非常硬

可视化之后明显可以看出来:苹果、水果、香蕉这三个概念相关性很大,而水果和石头相关性就不大。

计算的话,可以通过夹角判断相似度:夹角越小相似度越高,也就是余弦相似度(两个向量夹角的余弦值)。

当然,具体的向量数据肯定不会只有二维,可能会是几百维。虽然高维没法可视化,但是原理是一样的——我们都是通过两个概念对应的向量的余弦相似度来判断相关性。也就是说,通过向量计算实现语义检索!

这就是为什么 RAG 一般都结合向量化来做:虽然基于关键词来做也是 RAG,但是那种没法语义搜索,意义不大。

那给你一个概念,怎么计算它的向量值呢?这需要用到一个专门的模型,叫嵌入模型(Embedding Model)。它和大语言模型(LLM)是不一样的,它的功能就只有把知识转成向量。这个知识可以是文本、图片、语音等,向量化之后,就都可以实现语义搜索了!写代码会用专门的嵌入模型,收费比大模型便宜很多很多。

RAG 的完整流程

加上向量化之后的 RAG 流程是这样的:

  1. 用户的 Prompt 通过嵌入模型转成向量
  2. Retriever 基于这个向量去向量数据库中检索,找到相似的向量
  3. 把对应的文档块返回,加到 Prompt 里作为背景知识
  4. 给大模型生成回答

那存的不是向量么?怎么记录向量关联的文档?文档在向量化的时候,会在向量的元信息里记录来源文档。

综上:在原始 Prompt 给到大模型之前,先查询下知识库,把相关的文档作为背景知识加入 Prompt,再让大模型回答,这就是 RAG。

整个流程可以概括为"离线建库 + 在线问答"两个阶段:

RAG 流程原理图

代码实践

我们来写代码试一下:

bash
mkdir rag-test
cd rag-test
npm init -y
pnpm install @langchain/core @langchain/openai dotenv

创建 src/hello-rag.mjs

js
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

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

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

const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友一起玩耍,他喜欢奔跑、跳跃,浑身充满了活力。`,
    metadata: {
      chapter: 1,
      character: "光光",
      type: "角色介绍",
      mood: "活泼"
    },
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力和色彩。`,
    metadata: {
      chapter: 2,
      character: "东东",
      type: "角色介绍",
      mood: "温馨"
    },
  }),
  new Document({
    pageContent: `有一天,学校要举办一场足球比赛,光光非常兴奋,他邀请东东一起参加。但是东东从来没有踢过足球,他有点担心自己踢不好。`,
    metadata: {
      chapter: 3,
      character: "光光和东东",
      type: "友情情节",
      mood: "鼓励",
    },
  }),
  new Document({
    pageContent: `接下来的日子里,光光每天放学后都会教东东踢足球。光光耐心地教东东如何控球、传球和射门,东东也认真学习,进步很快。`,
    metadata: {
      chapter: 4,
      character: "光光和东东",
      type: "友情情节",
      mood: "互助",
    },
  }),
  new Document({
    pageContent: `比赛那天终于到了,光光和东东一起站在球场上。虽然东东的技术还不够熟练,但他非常努力,而且有光光在旁边不断鼓励他。`,
    metadata: {
      chapter: 5,
      character: "光光和东东",
      type: "高潮转折",
      mood: "激动",
    },
  }),
  new Document({
    pageContent: `从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画,他们互相学习,互相帮助。`,
    metadata: {
      chapter: 6,
      character: "光光和东东",
      type: "结局",
      mood: "欢乐",
    },
  }),
  new Document({
    pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。`,
    metadata: {
      chapter: 7,
      character: "光光和东东",
      type: "尾声",
      mood: "温馨",
    },
  }),
];

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

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

const questions = [
  "东东和光光是怎么成为朋友的?"
];

for (const question of questions) {
  console.log("=".repeat(80));
  console.log(`问题: ${question}`);
  console.log("=".repeat(80));

  // 使用 retriever 获取文档
  const retrievedDocs = await retriever.invoke(question);

  // 使用 similaritySearchWithScore 获取相似度评分
  const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

  // 打印用到的文档和相似度评分
  console.log("\n【检索到的文档及相似度评分】");
  retrievedDocs.forEach((doc, i) => {
    // 找到对应的评分
    const scoredResult = scoredResults.find(([scoredDoc]) =>
      scoredDoc.pageContent === doc.pageContent
    );
    const score = scoredResult ? scoredResult[1] : null;
    const similarity = score !== null ? (1 - score).toFixed(4) : "N/A";

    console.log(`\n[文档 ${i + 1}] 相似度: ${similarity}`);
    console.log(`内容: ${doc.pageContent}`);
    console.log(`元数据: 章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}, 类型=${doc.metadata.type}`);
  });

  // 构建 prompt
  const context = retrievedDocs
    .map((doc, i) => `[片段${i + 1}]\n${doc.pageContent}`)
    .join("\n\n━━━━━\n\n");

  const prompt = `你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到相关信息,请明确说明不知道。
故事片段:
${context}
问题: ${question}
老师的回答:`;

  console.log("\n【AI 回答】");
  const response = await model.invoke(prompt);
  console.log(response.content);
  console.log("\n");
}

安装用到的包:

bash
pnpm install @langchain/classic

这里我们用到了大语言模型 LLM(ChatOpenAI),还有嵌入模型 OpenAIEmbeddings。具体的 model name 在 .env 里配置下:

env
# OpenAI API 配置
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
EMBEDDINGS_MODEL_NAME=text-embedding-v3

代码逻辑梳理:

  • 这几个 Document 比较容易理解:就是知识库里的文档(一个关于光光和东东的友情故事),可以加一些元数据(章节、角色、类型、情绪等)。这个故事直接问大模型,显然它是不知道的
  • MemoryVectorStore.fromDocuments(documents, embeddings):用嵌入模型把这些文档向量化之后存入向量数据库(这里是内存版,后面会换 Milvus)
  • asRetriever({ k: 3 }):返回一个 retriever,k 是 3 就是返回余弦相似度最大的 3 个 Document
  • retriever.invoke(question) 把 query 传入,通过向量的余弦相似度,找到语义最相关的 3 个文档片段
  • similaritySearchWithScore(question, 3) 可以同时拿到文档和相似度评分
  • 构建 context 拼进 prompt,这就是增强后的 Prompt 了,之后问大模型问题的时候,它就有背景知识了

跑一下:

bash
node ./src/hello-rag.mjs

可以看到,根据你的问题,查询到了 3 个文档,然后大模型基于这些做了回答。这样我们就跑通了 RAG 的流程!

回头看下 RAG 的流程图就完全清楚了:对 query 通过嵌入模型向量化,查询出余弦相似度最大的 3 个文档,用它增强 Prompt 后再问大模型,大模型基于这个生成回答。

常见问题

为什么不全量把所有文档都传给大模型? 三个原因:第一,Token 消耗极大;第二,LLM 有上下文大小限制;第三,数据安全问题——核心数据不会全量发送给大模型。所以 RAG 的本质可以理解为"提示词的提纯与筛选":只把最相关的几个片段传过去,信息更精准的同时大大减少了上下文。

1 - score 的相似度转换对吗? 这里有个争议:similaritySearchWithScore 返回的 score 在不同实现里可能是余弦距离(越小越接近)也可能是相似度。1 - score 是按距离转相似度的写法,如果直接返回的就是相似度就不用转。理解原理即可——这块只是展示评分,实际项目中一般不用内存向量数据库,后面会用 Milvus。

retriever.invokesimilaritySearchWithScore 是不是重复了? 是的,两个都能拿到相关文档,用其中一个就够了。代码里两个都展示是为了说明两种 API 的用法。

metadata(mood 等字段)有什么用? 除了向量化的内容外,metadata 是附加字段,用于过滤之类的操作。后面学到 Milvus 会看到:集合里除了向量字段,还可以加其他字段做过滤。

把整个代码仓库转成向量怎么做? 需要各种 loader 和 splitter,下一节讲。

嵌入模型报错或相似度全是 0/NaN? 检查嵌入模型配置是否正确,本地嵌入模型(如 LM Studio 自带模型)可能出现兼容性问题,用线上 API 的嵌入模型(如 text-embedding-v3)更稳定。

知识库能保证有最新消息吗? 不是。内部文档可以放知识库,但实时信息可以结合 Google Search 这类 Tool 或 MCP 来获取。

总结

大模型训练完后,知识就不再更新了,它没法知道最新的信息,以及一些非互联网上公开的信息。所以对于它不知道的东西,会胡乱回答,也就是幻觉问题。解决这个问题的方式就是 RAG。

  • RAG 是检索、增强、生成:基于用户的 query 去检索知识库,拿到相关文档后放到 Prompt 里增强它,之后给大模型来生成回答
  • 语义检索需要向量:关键词检索做不到语义匹配,需要用嵌入模型把知识向量化,通过向量的余弦相似度(也就是夹角大小)来计算出两个知识的相关性,从而根据用户的 query 查询出相关的文档
  • LangChain 的 APIfromDocuments 基于 embeddings 模型把文档向量化存入数据库;asRetriever 指定查询相似度最大的几个文档;similaritySearchWithScore 拿相似度评分;retriever.invoke 查询文档

只要你理解了 RAG 的流程,这些 API 自然也就会用了。想一下,如果你要做公司内部文档的智能助手,是不是就可以用 RAG 来实现呢?

基于 VitePress 构建 · 专注前端与 AI 实战