LangChain RAG Retriever 的学习

导语

前段时间在搭建 RAG 应用时,发现很多同学把 Retriever 当成黑盒来用:调用 invoke 就能搜出相关文档,至于内部发生了什么,一问三不知。更有意思的是,问到 asRetrieversimilaritySearchWithScore 的区别时,能讲清楚的人寥寥无几。

本文不打算讲大而全的 RAG 理论,而是死磕代码 ------基于我写的一个"光光和东东"友情故事检索 Demo,带你逐行拆解 LangChain 中向量存储与检索的底层实现。读完你会搞懂:DocumentpageContentmetadata 在向量化时到底谁参与了计算?asRetriever 里的 k=3 背后发生了什么?为什么 score 越小反而越相似?

适合读者:用过 LangChain 但没深究过原理的初中级开发者,想搞懂 RAG 检索链路的同学。阅读时间约 12 分钟。


一、核心概念:用"图书馆"来理解 RAG 的三层结构

先看代码中最核心的一段:

typescript

csharp 复制代码
// 三个动作一次完成:切片 → 向量化 → 存储
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

注释里写得很清楚,这行代码做了三件事。用图书馆来类比:

RAG 概念 图书馆类比 代码对应
Document 一页书的内容 new Document({ pageContent, metadata })
Embedding 给每页书提炼关键词索引 OpenAIEmbeddings 模型调用
VectorStore 按索引排列的书架 MemoryVectorStore
Retriever 图书管理员(帮你找书) vectorStore.asRetriever()

设计初衷:把非结构化的自然语言文本,转换成结构化的向量空间数据,让机器能做语义级别的相似度匹配------而不是像传统关键词搜索那样只做字面匹配。


二、核心基石:Document ------ Embedding 的最小单元

2.1 什么是 Document?

typescript

go 复制代码
import { Document } from '@langchain/core/documents';

const doc = new Document({
  pageContent: `光光是一个活泼开朗的小男孩...`,  // ← 文本内容
  metadata: {                                    // ← 元数据
    chapter: 1,
    character: "光光",
    type: "角色介绍"
  }
});

🔑 重点:Document 是 Embedding 的最小单元

这意味着:

  • 每个 Document 独立进行向量化fromDocuments 会遍历所有 Document,对每个 pageContent 单独调用 embedding 接口
  • Document 之间互不影响:一个 Document 的向量只代表它自己的语义内容
  • 检索以 Document 为单位:检索器返回的是完整的 Document 对象,不会返回半个 Document

2.2 知识库由什么构成?

text

css 复制代码
知识库 (Knowledge Base)
    ├── 文件1 (PDF)
    │   ├── Document 1 (pageContent: "第1页内容...", metadata: {source: "file1.pdf", page: 1})
    │   ├── Document 2 (pageContent: "第2页内容...", metadata: {source: "file1.pdf", page: 2})
    │   └── Document 3 (pageContent: "第3页内容...", metadata: {source: "file1.pdf", page: 3})
    ├── 文件2 (Word)
    │   └── Document 4 (pageContent: "完整Word内容...", metadata: {source: "file2.docx"})
    ├── 音频文件 (语音转文字后)
    │   └── Document 5 (pageContent: "会议录音转文字...", metadata: {source: "meeting.mp3", speaker: "张三"})
    └── 图片文件 (OCR/多模态提取后)
        └── Document 6 (pageContent: "图片中的文字描述...", metadata: {source: "diagram.png", type: "流程图"})

关键认知

问题 答案
知识库由什么构成? 文件(文本、声音、图片、视频等)
这些文件能直接向量化吗? ❌ 不能,需要先提取文本内容
提取后的最小存储单位是什么? Document
什么进入 embedding 计算? 只有 pageContent

一句话总结 :知识库由各种文件构成,但 Document 才是 Embedding 的最小单元------文件必须先被解析并拆分成 Document,然后才能被向量化存储。

2.3 为什么 Document 是 Embedding 的最小单元?(设计哲学)

原因一:粒度控制

如果以"整个文件"为单位做 embedding:

  • 一个 10 页的 PDF 变成一个向量 → 信息密度极低
  • 检索时找到这个文件,但不知道具体哪一页相关
  • 上下文窗口可能被大量无关内容撑爆

如果以"段落"为 Document:

  • 每个段落独立向量化 → 高精度定位
  • 检索直接命中相关段落 → 上下文精炼

原因二:向量维度的固定性

typescript

csharp 复制代码
// OpenAI text-embedding-3-small 固定输出 1536 维
// 无论你传入 10 个字还是 500 个字,输出都是 1536 维
const vector1 = await embeddings.embedQuery("短文本");  // 1536 维
const vector2 = await embeddings.embedQuery("很长的文本...(500字)");  // 1536 维

如果以文件为单位,不同长度的文件输出维度相同但语义被稀释。以 Document 为单位,每个小块都能完整表达自己的语义。


三、核心流程拆解:"先建库,后检索"的两步走设计

3.1 为什么代码要拆成两步?

typescript

ini 复制代码
// 第一步:建库(数据写入)
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

// 第二步:包装检索接口(查询接口)
const retriever = vectorStore.asRetriever();

你的理解完全正确 :这就是典型的 "先建库,后检索" 流程。

但这里有个容易被忽略的设计意图------关注点分离

组件 职责 类比
vectorStore 数据管理(增、删、改、查向量数据) 图书馆的书架管理系统
retriever 业务交互(屏蔽向量计算复杂性,提供标准化接口) 图书管理员 + 导览台

retriever 并没有把数据库"转换"成另一种东西,而是给这个数据库穿上了一层"检索器"的外衣 。它内部持有对 vectorStore 的引用,并封装了默认的搜索参数(比如默认返回 4 条最相关文档)。

为什么非要拆成两步?

这个设计让后续的 LCEL(LangChain 表达式语言) 链可以直接接收 retriever 作为输入,而不用关心底层的索引类型(是内存、FAISS 还是 Chroma)。你在写业务逻辑时,只需要面向 retriever 编程,底层切换向量数据库对上层完全透明。


3.2 第一步深入:fromDocuments 到底做了什么?

typescript

ini 复制代码
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

这行代码不仅仅是"录入数据" ,它内部完成了三个关键动作:

关键细节

  1. 切片是隐式发生的 :如果你的 documents 里某个 pageContent 超过一定长度,fromDocuments 内部会用默认的 RecursiveCharacterTextSplitter 对它进行二次切分
  2. 向量化是批量调用embeddings.embedDocuments(texts) 会将所有 pageContent 打包成一次 API 调用,减少网络开销
  3. 存储是内存结构MemoryVectorStore 把向量和原文存在内存里的数组/Map 中,进程重启即丢失

返回值 :此时 vectorStore 就是一个包含索引的数据库实例,它已经知道每个文档片段在向量空间中的位置了。

⚠️ 容易被忽视的细节

console.log(vectorStore) 打印出的并不是文档内容,而是一个内存地址或对象结构体。真正的文本数据此时已经被切片并向量化了。如果你在传入 documents 之前没有手动切片,fromDocuments 内部的默认切片逻辑可能会影响召回精度。


3.3 第二步深入:asRetriever() 只是一个"包装器"

typescript

ini 复制代码
const retriever = vectorStore.asRetriever({ k: 3 });

本质retriever 是一个标准化调用接口 ,它内部持有对 vectorStore 的引用,并封装了默认搜索参数。

执行 retriever.invoke(question) 时内部发生了什么?

注意

注意:你看不到 embedQuery 的调用,因为它在 Retriever 内部自动完成了


四、重难点剖析(核心)

难点一:pageContent vs metadata------谁参与了向量化?

现象 :代码中 Document 有两个字段:

typescript

go 复制代码
new Document({
  pageContent: `光光是一个活泼开朗的小男孩...`,  // 参与向量化
  metadata: {                                    // 不参与向量化
    chapter: 1,
    character: "光光",
    type: "角色介绍"
  }
})

问题:设计者为什么要把它们分开?

深入源码 :看 MemoryVectorStore.fromDocuments 的调用链:

typescript

javascript 复制代码
// LangChain 源码简化版
static async fromDocuments(docs, embeddings) {
  const texts = docs.map(d => d.pageContent);  // ← 只取 pageContent
  const vectors = await embeddings.embedDocuments(texts); // ← 向量化
  // metadata 单独存储,不参与 embedding 计算
  return new MemoryVectorStore(vectors, docs.map(d => d.metadata));
}

设计考量

  1. pageContent 是语义载体,需要映射到向量空间
  2. metadata 是业务属性,用于过滤、溯源、展示,如果参与向量化会引入噪声(比如 chapter: 1chapter: 2 的数字差异会被错误地理解为语义差异)

理解思路 :把 pageContent 想象成书的内容,metadata 想象成书的 ISBN 号、书架位置、作者信息------你找书的时候靠内容,找到了再看这些附加信息。


难点二:检索器检索的是"文件"吗?

常见误解 :以为 retriever.invoke(question) 是在搜索原始 PDF/Word 文件。

正确答案 :检索器检索的是文档块(Document Chunks) ,不是原始文件。

深入剖析

回头看代码中的 fromDocuments 调用:

typescript

ini 复制代码
const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩...`,  // 这已经是一个切好的"块"
    metadata: { chapter: 1, character: "光光" }
  }),
  // 每个 Document 约 100-200 字,都是独立的知识碎片
];

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

这里的 documents 不是 原始文件(如 PDF、Word),而是经过切片(Chunking)处理后的文本块

为什么要切片?

维度 原始文件(如 10 页 PDF) 切片后的文档块
文本长度 5000+ 字 100-500 字/块
向量化粒度 粗(一个向量代表全文) 细(每个块独立向量)
检索精度 低(整个文档被混为一谈) 高(精准定位相关段落)
上下文窗口 容易超限 可控

执行 retriever.invoke(question) 时发生了什么?

结论 :当你说"检索器帮我在知识库里找答案"时,准确的说法是------检索器在成千上万个文档块中,找出与问题语义最相似的那 K 个块,然后把这些块的内容拼成上下文交给 LLM


难点三:提问的问题是怎么 Embedding 化的?(揭开黑盒)

现象 :代码里只写了 const docs = await retriever.invoke(question),没有看到对 question 调用 embeddings.embedQuery()

typescript

ini 复制代码
// 你看到的代码
const docs = await retriever.invoke(question);

// 你"没看到"的代码(藏在 Retriever 内部)
// const questionVector = await embeddings.embedQuery(question);
// const results = await vectorStore.similaritySearchVectorWithScore(questionVector, k);

问题 :为什么你看不到 prompt embedding 的代码?

答案vectorStore.asRetriever() 在内部帮你自动完成了"问题 → 向量"的转换。

深入源码

typescript

typescript 复制代码
// LangChain 源码简化版(@langchain/classic/vectorstores/memory)
class VectorStoreRetriever extends BaseRetriever {
  protected vectorStore: VectorStore;
  protected k: number;
  private embeddings?: Embeddings;  // ← 关键:持有 embeddings 引用

  async _getRelevantDocuments(query: string): Promise<Document[]> {
    // 1. 如果传入了 embeddings,自动将 query 向量化
    let queryVector: number[];
    if (this.embeddings) {
      queryVector = await this.embeddings.embedQuery(query);  // ← 这里!
    } else {
      // fallback: 某些向量存储支持 string 查询
    }
    
    // 2. 用向量去执行相似度搜索
    const results = await this.vectorStore.similaritySearchVectorWithScore(
      queryVector, 
      this.k
    );
    
    return results.map(([doc]) => doc);
  }
}

完整调用链对比

typescript

csharp 复制代码
// ========== 方式一:通过 Retriever(自动 embedding) ==========
const retriever = vectorStore.asRetriever({ k: 3 });
const docs = await retriever.invoke(question);
// 内部流程:
// question → embedQuery() → 向量 → similaritySearchVectorWithScore() → docs

// ========== 方式二:手动 embedding(你可以自己控制) ==========
const questionVector = await embeddings.embedQuery(question);
const results = await vectorStore.similaritySearchVectorWithScore(questionVector, 3);
// 结果和方式一完全一致,但方式二给了你更多控制权(比如提前缓存 query 向量)

理解思路asRetriever 就像一个"智能代理",它把"向量化"和"相似度搜索"两个步骤打包成了一个 invoke 接口。你只管提问,它帮你把问题变成向量,再去库里匹配。


难点四:similaritySearchWithScore vs retriever.invoke ------生产环境该用谁?

现象:代码里同时用了两种检索方式:

typescript

csharp 复制代码
// 方式一:通过检索器
const docs = await retriever.invoke(question);

// 方式二:直接向量搜索
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

问题:什么场景下用哪个?它们到底差在哪?

4.1 核心机制差异

维度 similaritySearchWithScore retriever.invoke
本质 纯向量检索 检索增强管道(Pipeline)
过程 Query 转向量 → 计算余弦距离 → 返回 Query 转向量 → 检索 → 后处理链路
后处理 ❌ 无 ✅ 去重、元数据过滤、Rerank 重排
返回内容 (Document, Score) 元组 Document 列表
分数可靠性 原始向量分(跨模型不可比) 经 Reranker 校准后的排序(更可靠)
扩展性 高(支持 LCEL 链式组合)

具体场景速查表

场景 推荐 API 理由
做算法实验/消融研究 similaritySearchWithScore 需要观察原始向量召回效果,裸分数能帮你分析阈值设定,排除重排器干扰
多路召回合并(ES + 向量) similaritySearchWithScore 需要自己合并多路结果并归一化分数,Retriever 的封装反而碍事
需要元数据过滤(如:只查 2026 年的文档) retriever.invoke 配合 SelfQueryRetriever,过滤逻辑更优雅
最终上线产品(高精度要求) retriever.invoke + Reranker 线上必须做重排,Retrieveradd_reranker 方式无缝接入,且支持去重
简单查 K 条,无额外逻辑 两者皆可(建议 retriever.invoke Retriever 更符合 LangChain 标准范式,便于后续维护

4.3 ⚠️ 资深工程师才知道的陷阱:Score 的"欺骗性"

typescript

csharp 复制代码
// 这是有陷阱的!
const results = await vectorStore.similaritySearchWithScore(question, 3);
// 你以为 0.25 的分数很高,但实际上...

真相

  • similaritySearchWithScore 返回的 Score 在不同向量模型(OpenAI 的 text-embedding-3 vs 开源 bge)下分布完全不同,无法跨库比较
  • 例如:OpenAI 的分数通常在 0.7-0.9 之间,而 bge 可能在 0.3-0.6 之间------同样的 0.5,在不同模型里含义天差地别

retriever.invoke 在配合 Reranker 后

  • 返回的排序是基于 Cross-Encoder 的精细相关性计算
  • 其顺序比原始向量 Score 可靠得多,且不需要纠结阈值设定

typescript

arduino 复制代码
// 生产环境推荐:Retriever + Reranker
const retriever = vectorStore.asRetriever({ k: 10 }); // 先多召回一些
const docs = await retriever.invoke(question);
// 后续接入 Reranker 重新排序,取 Top-3
// 这时文档的顺序比裸向量分更有意义

难点五:相似度分数的"反直觉"------为什么 score 越小越相似?

现象:代码里的注释写着"score 是距离,越小越相似":

typescript

ini 复制代码
const score = scoredResult ? scoredResult[1] : null;
const similarity = score != null ? (1 - score).toFixed(2) : 'N/A';

问题:为什么要用距离而不是相似度?

数学原理OpenAIEmbeddings 默认使用余弦相似度,LangChain 内部计算时做了转换:

typescript

ini 复制代码
// LangChain 源码简化:余弦距离 = 1 - 余弦相似度
// 余弦相似度范围 [-1, 1],距离范围 [0, 2]
const cosineDistance = 1 - cosineSimilarity;

所以:

  • 两个向量完全一致 → 余弦相似度 1 → 距离 0(最小)
  • 两个向量完全相反 → 余弦相似度 -1 → 距离 2(最大)

设计考量:用距离而非相似度,是因为很多下游算法(如 KNN、HNSW)天然基于"距离最小化"来设计。统一用距离,便于嵌入各类向量索引算法。

理解思路:想象两个人站在操场上,距离越近说明位置越相似。score 就是"直线距离",越小说明在向量空间里挨得越近。


五、避坑指南:新手最容易踩的 4 个坑

坑 1:把 metadata 当成 pageContent 的一部分

错误做法

typescript

go 复制代码
new Document({
  pageContent: `章节:1 角色:光光 性格:活泼 ...`  // 元数据混入正文
})

正确做法

typescript

go 复制代码
new Document({
  pageContent: `光光是一个活泼开朗的小男孩...`,  // 纯文本内容
  metadata: { chapter: 1, character: "光光" }   // 结构化存储
})

后果 :把元数据混入 pageContent 会导致"章节:1"被向量化,干扰语义匹配的纯度。


坑 2:检索时直接丢弃 score

错误做法

typescript

arduino 复制代码
const docs = await retriever.invoke(question);
// 直接用 docs,不知道每个文档的匹配程度

正确做法

typescript

javascript 复制代码
// 调试/上线前阶段,同时输出分数做质量验证
const results = await vectorStore.similaritySearchWithScore(question, 3);
results.forEach(([doc, score]) => {
  console.log(`相关性距离: ${score.toFixed(4)}`);
});

后果:不观察分数,你永远不会知道当前知识库的检索质量。分数普遍偏高说明 embedding 模型选型有问题或文档切分不合理。


坑 3:忽略 k 值对 RAG 效果的影响

错误做法

typescript

ini 复制代码
const retriever = vectorStore.asRetriever({ k: 10 }); // 贪多

正确做法

typescript

arduino 复制代码
// 根据 LLM 的上下文窗口和业务场景调整
const retriever = vectorStore.asRetriever({ 
  k: 3  // 通用场景 3-5 个片段最合适
});

后果:k 值太大,会塞入过多不相关片段,稀释有效信息,甚至撑爆上下文窗口;k 值太小,可能漏掉关键信息。


坑 4:以为 fromDocuments 会保留原始文档结构

错误理解

typescript

csharp 复制代码
// 以为传入 3 个 Document,vectorStore 里就是 3 条记录
const vectorStore = await MemoryVectorStore.fromDocuments([
  new Document({ pageContent: '超长文本...(5000字)' })
]);
// 实际上内部被切成了 N 个小块!

正确认知

fromDocuments 内部会调用 RecursiveCharacterTextSplitter 对超长文本进行二次切分。建议在传入之前手动切片 ,控制每个 Document 的大小在 200-500 字之间。

typescript

ini 复制代码
// 推荐做法:手动切片
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});

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

六、面试高频考点

Q1:LangChain 中的 Document 和普通的"文档"有什么区别?

要点回答

  1. Document 是 LangChain 的统一数据抽象,包含 pageContent(内容)和 metadata(元数据)
  2. pageContent 参与向量化,metadata 不参与
  3. metadata 用于检索后的过滤、溯源、展示,可以包含来源链接、时间戳、作者等信息
  4. Document 是 Embedding 的最小单元,每个 Document 独立进行向量化

Q2:知识库、文件、Document、向量之间是什么关系?

要点回答

text

markdown 复制代码
知识库(Knowledge Base)
    └── 文件(PDF/Word/音视频/图片)  ← 原始数据
        └── 解析/切片后
            └── Document 列表  ← Embedding 的最小单元
                └── 每个 Document.pageContent
                    └── Embedding 模型
                        └── 向量(1536 维浮点数)  ← 存到 VectorStore

Q3:MemoryVectorStorePGVector 的使用场景分别是什么?

要点回答

特性 MemoryVectorStore PGVector
存储位置 内存 PostgreSQL
持久化 否(进程重启丢失)
数据量 < 1 万条向量 百万级+
适用场景 开发测试、Demo 演示 生产环境
部署复杂度 零配置 需要安装 PG 扩展

Q4:RAG 检索的完整链路中,哪些环节可能成为性能瓶颈?

要点回答

  1. Embedding 生成:调用外部 API(如 OpenAI)的延迟通常在 100-500ms/批,是最大瓶颈
  2. 向量索引查询:全量扫描 vs 近似最近邻(如 HNSW)的性能差异可达 100 倍
  3. 文档切分粒度:太细 → 片段多、检索慢;太粗 → 信息冗余、上下文污染

生产环境优化方向:批量 embedding、使用向量索引(HNSW/IVF)、异步检索 + 缓存。

Q5:retriever.invoke(question) 内部是怎么把问题变成向量的?

要点回答

asRetriever() 返回的 VectorStoreRetriever 实例内部持有了 embeddings 引用。当调用 invoke(question) 时,内部执行:

  1. embeddings.embedQuery(question) → 将问题转为向量
  2. vectorStore.similaritySearchVectorWithScore(queryVector, k) → 用向量去匹配
  3. 后处理(去重、过滤、重排)→ 返回文档列表

开发者"看不见"这个过程,是因为 LangChain 把它封装在了 _getRelevantDocuments 方法里。

Q6:similaritySearchWithScore 返回的分数能作为最终排序依据吗?

要点回答

⚠️ 不建议。原因有三:

  1. 跨模型不可比 :不同 Embedding 模型(如 OpenAI 的 text-embedding-3bge)的分数分布完全不同
  2. 缺乏精细校准:向量余弦距离是"粗排",而 Cross-Encoder 重排是"精排",后者更准确
  3. 阈值难以设定:因为分布不固定,你无法定一个统一的阈值来判断"是否相关"

正确做法 :用 similaritySearchWithScore 做调试和实验,生产环境用 retriever.invoke + Reranker。


七、完整流程图

八、完整可运行代码(带详细注释)

typescript

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

// ============ 1. 初始化模型 ============
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({
  model: process.env.EMBEDDING_MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL }
});

// ============ 2. 构建知识库(Document 列表) ============
// ⚠️ 核心概念:
// - 知识库由各种文件(文本/音频/图片/视频)构成
// - 但 Document 才是 Embedding 的最小单元
// - 每个 Document 独立进行向量化
// - pageContent → 参与向量化 → 用于语义匹配
// - metadata → 不参与向量化 → 用于过滤、溯源、展示
const documents = [
  new Document({
    pageContent: `光光是一个活泼开朗的小男孩,他有一双明亮的大眼睛,总是带着灿烂的笑容。光光最喜欢的事情就是和朋友们一起玩耍,他特别擅长踢足球,每次在球场上奔跑时,就像一道阳光一样充满活力。`,
    metadata: { chapter: 1, character: "光光", type: "角色介绍" }
  }),
  new Document({
    pageContent: `东东是光光最好的朋友,他是一个安静而聪明的男孩。东东喜欢读书和画画,他的画总是充满了想象力。虽然性格不同,但东东和光光从幼儿园就认识了,他们一起度过了无数个快乐的时光。`,
    metadata: { chapter: 2, character: "东东", type: "角色介绍" }
  }),
  new Document({
    pageContent: `有一天,学校要举办一场足球比赛,光光非常兴奋,他邀请东东一起参加。但是东东从来没有踢过足球,他担心自己会拖累光光。光光看出了东东的担忧,他拍着东东的肩膀说:"没关系,我们一起练习,我相信你一定能行的!"`,
    metadata: { chapter: 3, character: "光光和东东", type: "友情情节" }
  }),
  new Document({
    pageContent: `接下来的日子里,光光每天放学后都会教东东踢足球。光光耐心地教东东如何控球、传球和射门,而东东虽然一开始总是踢不好,但他从不放弃。东东也用自己的方式回报光光,他画了一幅画送给光光,画上是两个小男孩在球场上一起踢球的场景。`,
    metadata: { chapter: 4, character: "光光和东东", type: "友情情节" }
  }),
  new Document({
    pageContent: `比赛那天终于到了,光光和东东一起站在球场上。虽然东东的技术还不够熟练,但他非常努力,而且他用自己的观察力帮助光光找到了对手的弱点。在关键时刻,东东传出了一个漂亮的球,光光接球后射门得分!他们赢得了比赛,更重要的是,他们的友谊变得更加深厚了。`,
    metadata: { chapter: 5, character: "光光和东东", type: "高潮转折" }
  }),
  new Document({
    pageContent: `从那以后,光光和东东成为了学校里最要好的朋友。光光教东东运动,东东教光光画画,他们互相学习,共同成长。每当有人问起他们的友谊,他们总是笑着说:"真正的朋友就是互相帮助,一起变得更好的人!"`,
    metadata: { chapter: 6, character: "光光和东东", type: "结局" }
  }),
  new Document({
    pageContent: `多年后,光光成为了一名职业足球运动员,而东东成为了一名优秀的插画师。虽然他们走上了不同的道路,但他们的友谊从未改变。东东为光光设计了球衣上的图案,光光在每场比赛后都会给东东打电话分享喜悦。他们证明了,真正的友情可以跨越时间和距离,永远闪闪发光。`,
    metadata: { chapter: 7, character: "光光和东东", type: "尾声" }
  })
];

// ============ 3. 向量存储 ============
// ⚠️ 第一步:建库(数据写入)
// fromDocuments 内部完成:
//   1. 提取每个 Document 的 pageContent
//   2. 调用 embeddings.embedDocuments() 批量向量化
//   3. 将向量和 metadata 存入内存
// 此时 vectorStore 就是一个包含索引的数据库实例
// 每个 Document 独立成为一个向量,Document 是 Embedding 的最小单元
const vectorStore = await MemoryVectorStore.fromDocuments(documents, embeddings);

console.log('📚 知识库已构建,共', documents.length, '个 Document(每个都是一个独立的向量)');

// ============ 4. 创建检索器 ============
// ⚠️ 第二步:包装检索接口(查询接口)
// asRetriever() 内部持有了 embeddings 引用
// 当 invoke 被调用时,会自动对 question 做 embedQuery()
const retriever = vectorStore.asRetriever({
  k: 3  // 返回最相似的 3 个文档块
});

// ============ 5. 执行检索 ============
const question = '东东和光光是怎么成为朋友的';

// ----- 方式一:通过 Retriever(生产推荐) -----
// 你看不到 embedQuery() 的调用,因为它在 Retriever 内部自动完成了
const docs = await retriever.invoke(question);

// ----- 方式二:直接相似度搜索(调试推荐) -----
// 这条语句内部也会对 question 做 embedQuery()
const scoredResults = await vectorStore.similaritySearchWithScore(question, 3);

// 打印评分对比
console.log('\n📊 [检索结果对比]');
console.log('retriever.invoke 返回的文档章节:', docs.map(d => d.metadata.chapter));
console.log('similaritySearchWithScore 返回的文档章节:', scoredResults.map(([d, s]) => `${d.metadata.chapter}(距离:${s.toFixed(4)})`));

// 详细输出
console.log('\n📊 [检索结果及相似度评分]');
docs.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(2) : 'N/A';
  console.log(`[${i + 1}] 相似度: ${similarity} (距离: ${score})`);
  console.log(`内容: ${doc.pageContent.substring(0, 50)}...`);
  console.log(`元数据:章节=${doc.metadata.chapter}, 角色=${doc.metadata.character}\n`);
});

// ============ 6. 增强生成(Augmented Generation) ============
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('\n📝 [LLM 回答]:\n', response.content);
相关推荐
用户298698530141 小时前
Python 一键导出 Excel 中的图表和形状为图片
后端·python·excel
用户8130077816371 小时前
PageHelper分页查询步骤
后端
是小李呀1 小时前
MyBatis、MyBatis-Plus 与全自动 ORM 的本质区别
后端
小堂子这厢有礼了1 小时前
Chet.Admin 权限模型:菜单级 + 按钮级 + 行级三层防护
前端·后端·信息可视化·前端框架·.net
SimonKing1 小时前
Spring Boot 4.0 + gRPC 极简集成:4步搞定,比REST还丝滑
java·后端·程序员
IT_陈寒2 小时前
Vue的这个响应式陷阱,让我加班到凌晨三点
前端·人工智能·后端
Wang's Blog2 小时前
Go-Zero基础入门5: 探究go-zero如何基于gRPC扩展客户端
开发语言·后端·golang·go-zero
小堂子这厢有礼了3 小时前
Chet.QuartzNet.UI v2.3.0 大更新!表格重构 + 系统配置 + 分析页全面升级!
前端·后端·ui·重构·c#·vue
cfm_29143 小时前
SpringBoot自定义Starter
java·spring boot·后端