跑了个最简 RAG demo 后,我终于搞懂向量检索那堆分数到底啥意思

昨天晚上跟着 LangChain 的文档写内存版 RAG,控制台输出结果的那一刻我直接愣住了 ------ 明明文档内容和问题高度相关,原始分怎么反而最低?我盯着屏幕盯了三分钟,甚至怀疑是 embedding 模型抽风了,翻了半天源码和文档才反应过来,我把「距离」和「相似度」搞反了。

说起来挺丢人的,RAG 这个词听了快半年,各种架构图看了一张又一张,真到自己动手跑最小 demo 的时候,还是在最基础的地方栽了跟头。今天就顺着我踩坑的顺序,把整个流程捋一遍,也算给自己做个笔记。

先从把我整懵的那行输出说起

事情是这样的,我准备了 7 段小故事当知识库,问了一句「东东和光光是怎么成为朋友的」,按道理应该返回最相关的 3 条文档。结果控制台打出来这么个东西:

我第一反应是:不对啊,第三条原始分 0.71,第一条 0.78,按理说分越高越相似,怎么第三条排在最前面?

我还特意去核对了内容,第三章讲足球比赛、第六章讲成为好朋友,明显和问题更贴合,原始分反而更低。当时我脑子里闪过好几种可能:是不是我文档顺序放错了?是不是 k 参数写反了?是不是向量库存的时候顺序乱了?

折腾了快二十分钟我才拍了下大腿 ------ 这返回的根本不是相似度,是向量距离。距离越小,说明两个向量在空间里越近,语义才越像。

记死这个结论 LangChain 里 similaritySearchWithScore 返回的原始分,默认是余弦距离 ,取值 0~1,越小越相似。想转成我们直觉里的「相似度」,用 1 减去这个值就行,数值越大越相关。

就这么个小细节,卡了我半小时。说出来挺可笑,但真上手的时候,没人提醒还真容易绕进去。

先掰扯清楚:RAG 到底在干一件什么事

在贴代码之前,我先说说我现在对 RAG 的理解,不是教科书上的定义,就是我自己的大白话。

RAG 全称 Retrieval-Augmented Generation,检索增强生成。你就把它当成开卷考试

  • 大模型本身是个背书很强的学生,但它只记得训练数据里的东西,你给它新故事、新文档,它不知道。
  • 不开卷的话,它就会瞎编,也就是所谓的幻觉。
  • RAG 就是考试前先把参考资料都给你,你答题的时候,先从资料里翻和问题最相关的几段,再照着这几段组织语言写答案。

整个流程拆成三步:

  1. 预处理:把你的资料拆成一段一段的,每一段转成向量(就是一串数字,用来表示语义),存到向量库里。
  2. 检索:用户提了问题,先把问题也转成向量,去向量库里找距离最近的几段文本。
  3. 增强生成:把找到的几段文本和问题拼在一起,丢给大模型,让它只根据这些材料回答问题。

就这么简单。没有什么玄乎的东西,本质就是「先搜再答」,用来解决大模型不知道新知识、容易胡说八道的问题。

第一步:先把你的知识库拆成「文档」

在 LangChain 里,最基础的单位叫 Document,也就是文档。我一开始以为文档就是一整篇文章,后来才明白,它其实就是你向量化的最小单位。

一个 Document 长这样:

javascript 复制代码
new Document({
  pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩...`,
  metadata: { 
    chapter: 2, 
    character: "东东", 
    type: "角色介绍", 
    mood: "温馨"
  },
})

这里有个我一开始踩的小坑:我以为 metadata 里的内容也会参与向量化,还特意把关键词塞进去,结果怎么搜都搜不到。后来才搞懂:

  • 只有 pageContent 会被转成向量,参与语义匹配。
  • metadata 就是附属信息,不参与计算,主要用来过滤、溯源、展示。

比如你可以按章节过滤,只在第 3 到第 5 章里搜索;也可以在返回结果的时候告诉用户,答案来自第几章、是什么类型的内容。它是给业务逻辑用的,不是给向量模型看的。

我这次 demo 一共准备了 7 段文档,从角色介绍到故事结局,每段对应一个章节,元数据里标了角色、类型、情绪。说实话,量很小,但用来跑通流程完全够用了。

第二步:塞进内存向量库,顺便说下我踩的版本坑

文档准备好了,下一步就是把它们全部转成向量,存起来。入门阶段不用急着上 PostgreSQL + 向量插件,先用内存版跑通流程最实在。

LangChain 里内存向量库叫 MemoryVectorStore,这里有个版本坑我得提一嘴。 最开始我按照老教程从 @langchain/vectorstores/memory 导入,直接报错找不到模块。查了最新文档才发现,现在它被挪到 @langchain/classic 包里了。

javascript 复制代码
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { OpenAIEmbeddings } from '@langchain/openai';

// 初始化 embedding 模型,用来把文本转成向量
const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.DASHSCOPE_API_KEY,
    model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
    configuration: {
        baseURL: process.env.DASHSCOPE_API_BASE_URL,
    }
})

// 一行代码搞定:把文档数组转成向量,存入内存
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

就这一行 fromDocuments,内部帮你做了所有事:遍历每一个 Document,把 pageContent 丢给 embedding 模型拿到向量,再把向量和原文对应存起来。你不用管向量具体长啥样,也不用管怎么存,调用就行。

注意 LangChain 版本迭代特别快,导入路径、API 经常变。遇到报错先去看官方最新文档,别死抱着老教程较劲,我已经在这上面浪费过好几次时间了。

检索的两种姿势:retriever 和 similaritySearch 我一开始分不清

向量库建好之后,就可以开始搜了。我最开始发现有两种搜法,愣了好久不知道该用哪个。

第一种是直接调用向量库的 similaritySearchWithScore

javascript 复制代码
// 纯向量相似度搜索,返回最相关的 3 条,带原始分数
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

第二种是先转成检索器 retriever,再调用 invoke

javascript 复制代码
// 把向量库转成标准检索器
const retriever = vectorStore.asRetriever({
  k: 3, // 返回最相似的 3 条
});

// 标准检索入口
const docs = await retriever.invoke(question);

我一开始觉得这不就是换了个写法吗,多此一举。后来翻了下源码和说明才明白区别在哪:

  • similaritySearch 就是纯纯的向量计算,算距离、排序、返回 top k,干的都是体力活。
  • retriever 是更高一层的标准抽象,它除了做相似度搜索,后面还可以插拔过滤规则、去重逻辑、重排序(rerank)、多路召回等等。真正做业务的时候,你应该面向 retriever 写代码,而不是直接调向量库。

打个比方的话,similaritySearch 就是仓库保管员,你要啥它给你拿啥;retriever 是前台接待,你提需求,它去仓库找,还会帮你筛掉不合格的、排好优先级再给你。

入门 demo 里看不出区别,等后面要加元数据过滤、要加重排模型的时候,你就会感谢自己一开始用了 retriever。

最坑的地方:这个分数到底怎么看

绕回最开始坑我的那个分数问题。

为什么 retriever.invoke 不直接返回分数,而 similaritySearchWithScore 会返回?因为 retriever 后面可能会做重排,分数含义会变,所以它不保证返回原始向量分。

但我们学习的时候,肯定想看看每条结果到底有多相关,这时候就要自己处理一下:

javascript 复制代码
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

docs.forEach((doc, i) => {
  const scoreResult = scoredResults.find(([scoredDoc]) => 
    scoredDoc.pageContent === doc.pageContent
  )
  // 原始分是余弦距离,越小越相似
  const score = scoreResult ? scoreResult[1] : null;
  // 转成直觉上的相似度:越大越像,范围 0~1
  const similarity = score !== null ? (1 - score).toFixed(4) : "N/A";
  
  console.log(`[文档 ${i + 1}] 相似度指标: ${similarity} (原始分: ${score})`);
  // 别问我为什么知道要 1 减,对着结果反推了十分钟
})

我当时就是对着输出反推的:最相关的文档原始分最小,那肯定是距离。用 1 减掉之后,数值越大相似度越高,就符合我们正常人的直觉了。

说句题外话,不同向量库返回的分数含义可能不一样,有的是余弦相似度(越大越好),有的是欧氏距离(越小越好),还有的是归一化后的分数。用一个新库的时候,先拿已知相关的文本测一下,别想当然,不然很容易像我一样绕进去。

最后一步:把搜到的片段塞给大模型,才叫完整的 RAG

前面做的所有事情,其实都只完成了 R------Retrieval(检索)。还差 A 和 G,也就是增强和生成。

这一步说穿了特别朴素:把检索到的几段文本拼起来,当成背景资料,和问题一起放到 prompt 里,丢给大模型。

javascript 复制代码
// 把检索到的文档拼成上下文
const context = docs.map((doc, i) => `[片段${i}]\n ${doc.pageContent}`).join('\n\n--------\n\n');

const prompt = `
你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。

故事片段:
${context}

问题:${question}

老师的回答:
`;

// 丢给大模型生成答案
const response = await model.invoke(prompt);
console.log(response.content);

没有用什么 RetrievalQAChain,也没有用什么高级链,就是手动拼 prompt。我觉得入门的时候就应该这么写,你才清楚每一步都在干嘛。等你理解了本质,再去用框架封装好的链也不迟。

这一步的核心作用就是给大模型划边界:答案只能从给你的片段里出,不许自己瞎编。这就是 RAG 能减少幻觉的根本原因 ------ 不是模型变聪明了,是我们把参考资料喂到它嘴边了。

贴一份能直接跑的完整代码

前面拆成一步一步讲,怕有人拼不起来,我把完整的 demo 代码贴在这里。只要装了依赖、配好环境变量,直接 node 就能跑。

先装依赖:

bash 复制代码
npm install @langchain/openai @langchain/classic @langchain/core dotenv

然后在项目根目录建个 .env 文件,填你自己的配置,我这里用的是兼容 OpenAI 格式的接口,你换成别的同格式服务也一样:

javascript 复制代码
DASHSCOPE_API_KEY=你的api_key
DASHSCOPE_API_MODEL=qwen-plus
DASHSCOPE_EMBEDDINGS_MODEL=text-embedding-v2
DASHSCOPE_API_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

完整可运行代码如下,我把注释都写上了,哪里是干嘛的一眼能看清:

javascript 复制代码
import 'dotenv/config';
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { MemoryVectorStore } from '@langchain/classic/vectorstores/memory';
import { Document } from '@langchain/core/documents';

// 初始化大模型,用来最后生成回答
const model = new ChatOpenAI({
    temperature: 0,
    model: process.env.DASHSCOPE_API_MODEL,
    apiKey: process.env.DASHSCOPE_API_KEY,
    configuration: {
        baseURL: process.env.DASHSCOPE_API_BASE_URL,
    }
});

// 初始化向量模型,用来把文本转成向量
const embeddings = new OpenAIEmbeddings({
    apiKey: process.env.DASHSCOPE_API_KEY,
    model: process.env.DASHSCOPE_EMBEDDINGS_MODEL,
    configuration: {
        baseURL: process.env.DASHSCOPE_API_BASE_URL,
    }
})

// 知识库:7段小故事,每段一个Document对象
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);
console.log('向量库初始化完成');

// 转成标准检索器,设置返回top3
const retriever = vectorStore.asRetriever({
  k: 3,
});

const question = "东东和光光是怎么成为朋友的";
console.log('='.repeat(80));
console.log('问题:', question);
console.log('='.repeat(80));

// 检索相关文档
const docs = await retriever.invoke(question);

// 带分数的相似度查询,用来对比分数
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

console.log('\n[检索到的文档及相似度评分]')
docs.forEach((doc, i) => {
  const scoreResult = scoredResults.find(([scoredDoc]) => 
    scoredDoc.pageContent === doc.pageContent
  )
  const score = scoreResult ? scoreResult[1] : null;
  // 原始分是距离,越小越相似;1减之后变成相似度,越大越相关
  const similarity = score !== null ? (1-score).toFixed(4) : "N/A";
  console.log(`\n[文档 ${i + 1}] 相似度指标: ${similarity} (原始分: ${score})`);
  console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
  console.log(`元数据:章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}, 类型=${doc.metadata.type}`);
})

// 拼接上下文,构造prompt
const context = docs.map((doc, i) => `[片段${i+1}]\n ${doc.pageContent}`).join('\n\n--------\n\n');

const prompt = `
你是一个讲友情故事的老师。基于以下故事片段回答问题,用温暖生动的语言。如果故事中没有提到,就说"这个故事里还没有提到这个细节"。

故事片段:
${context}

问题:${question}

老师的回答:
`;

// 调用大模型生成最终答案
const response = await model.invoke(prompt);
console.log('\n' + '='.repeat(80));
console.log('最终回答:');
console.log('='.repeat(80));
console.log(response.content);

运行提示 注意这个文件后缀要是 .mjs,或者你在 package.json 里加 "type": "module",不然 import 语法会报错。别问我怎么知道的,第一次跑的时候卡了五分钟。

跑通之后,我踩过的三个坑列给你

整个 demo 跑下来,代码没几行,但坑是真不少,挑三个最容易踩的说。

第一个就是前面说的分数问题。别默认分数越高越相似,先搞清楚返回的是距离还是相似度。不同库、不同版本可能不一样,动手测一下最保险。

第二个是别把关键语义信息塞到 metadata 里。metadata 不参与向量化,你把关键词、摘要都塞进去,检索的时候根本匹配不上。要让模型搜到的内容,老老实实写在 pageContent 里。

第三个是入门别一上来就整复杂组件。我最开始看教程,又是 PG 向量插件,又是文本分割器,又是重排模型,头都大了。其实先拿内存向量库、几段手写的文本跑通完整流程,比啥都强。流程通了,再一步步加组件,每一步你都知道加的是啥、有啥用。

最后说两句

说实话,跑通这个 demo 之前,我对 RAG 的认知一直停留在架构图层面,感觉很复杂、很高深。真自己动手写完几十行代码,看着控制台输出检索结果和最终答案的时候,突然就通透了。

说白了,RAG 没什么神秘的。核心思想就三件事:把知识拆好存好,提问时先搜相关内容,再让模型照着内容回答。剩下的所有复杂组件,不管是文本分块、向量数据库、重排模型,都是围绕这三步做优化。

当然它也不是万能的。如果你的文档拆得太碎,上下文丢了,搜出来的东西可能驴唇不对马嘴;如果问题太偏,向量检索根本找不到对应片段,那回答质量还不如直接问大模型。技术选型永远要看场景。

我也是刚啃完这块内容,写下来权当复盘。如果哪里说得不对,欢迎在评论区指出来;如果你也刚跑通第一个 RAG demo,也可以留个言,我想看看大家都是在哪一步卡最久。

相关推荐
147API2 小时前
OpenAI新增工作区Admin keys,管理自动化怎么拆权限
运维·人工智能·自动化·openai·api
触底反弹2 小时前
🧠 别再让 AI 裸奔了!用 Skills 让 AI 秒变专属员工
人工智能
科技新芯2 小时前
蚂蚁集团发布三层AI布局,智能体商业落地提速
人工智能
人工干智能2 小时前
科普:Pandas 索引器 loc 与 iloc 的编程思维
java·人工智能·pandas
ddshub_cc2 小时前
理解 Loop Engineering 与 AI 编程成本优化
大数据·人工智能·gpt·prompt·ai编程
喵喵喵~~~~2 小时前
2026 指挥控制台源头工厂深度测评:按行业项目落地能力择优采购指南
大数据·运维·人工智能·智能家居
喵喵喵~~~~2 小时前
2026 调度操作台厂商横向实测:多行业落地案例拆解,指挥中心控制台怎么选
大数据·人工智能
GuWen_yue2 小时前
告别LLM无本地文件能力!30行Node手写MCP文件读取服务,AI自由读写本地代码
人工智能
Xuantong_903 小时前
AI概念与企业落地实用指南
人工智能