导语
前段时间在搭建 RAG 应用时,发现很多同学把
Retriever当成黑盒来用:调用invoke就能搜出相关文档,至于内部发生了什么,一问三不知。更有意思的是,问到asRetriever和similaritySearchWithScore的区别时,能讲清楚的人寥寥无几。本文不打算讲大而全的 RAG 理论,而是死磕代码 ------基于我写的一个"光光和东东"友情故事检索 Demo,带你逐行拆解 LangChain 中向量存储与检索的底层实现。读完你会搞懂:
Document的pageContent和metadata在向量化时到底谁参与了计算?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);
这行代码不仅仅是"录入数据" ,它内部完成了三个关键动作:


关键细节:
- 切片是隐式发生的 :如果你的
documents里某个pageContent超过一定长度,fromDocuments内部会用默认的RecursiveCharacterTextSplitter对它进行二次切分 - 向量化是批量调用 :
embeddings.embedDocuments(texts)会将所有pageContent打包成一次 API 调用,减少网络开销 - 存储是内存结构 :
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));
}
设计考量:
- pageContent 是语义载体,需要映射到向量空间
- metadata 是业务属性,用于过滤、溯源、展示,如果参与向量化会引入噪声(比如
chapter: 1和chapter: 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 |
线上必须做重排,Retriever 的 add_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-3vs 开源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 和普通的"文档"有什么区别?
要点回答:
Document是 LangChain 的统一数据抽象,包含pageContent(内容)和metadata(元数据)pageContent参与向量化,metadata不参与metadata用于检索后的过滤、溯源、展示,可以包含来源链接、时间戳、作者等信息- Document 是 Embedding 的最小单元,每个 Document 独立进行向量化
Q2:知识库、文件、Document、向量之间是什么关系?
要点回答:
text
markdown
知识库(Knowledge Base)
└── 文件(PDF/Word/音视频/图片) ← 原始数据
└── 解析/切片后
└── Document 列表 ← Embedding 的最小单元
└── 每个 Document.pageContent
└── Embedding 模型
└── 向量(1536 维浮点数) ← 存到 VectorStore
Q3:MemoryVectorStore 和 PGVector 的使用场景分别是什么?
要点回答:
| 特性 | MemoryVectorStore | PGVector |
|---|---|---|
| 存储位置 | 内存 | PostgreSQL |
| 持久化 | 否(进程重启丢失) | 是 |
| 数据量 | < 1 万条向量 | 百万级+ |
| 适用场景 | 开发测试、Demo 演示 | 生产环境 |
| 部署复杂度 | 零配置 | 需要安装 PG 扩展 |
Q4:RAG 检索的完整链路中,哪些环节可能成为性能瓶颈?
要点回答:
- Embedding 生成:调用外部 API(如 OpenAI)的延迟通常在 100-500ms/批,是最大瓶颈
- 向量索引查询:全量扫描 vs 近似最近邻(如 HNSW)的性能差异可达 100 倍
- 文档切分粒度:太细 → 片段多、检索慢;太粗 → 信息冗余、上下文污染
生产环境优化方向:批量 embedding、使用向量索引(HNSW/IVF)、异步检索 + 缓存。
Q5:retriever.invoke(question) 内部是怎么把问题变成向量的?
要点回答:
asRetriever() 返回的 VectorStoreRetriever 实例内部持有了 embeddings 引用。当调用 invoke(question) 时,内部执行:
embeddings.embedQuery(question)→ 将问题转为向量vectorStore.similaritySearchVectorWithScore(queryVector, k)→ 用向量去匹配- 后处理(去重、过滤、重排)→ 返回文档列表
开发者"看不见"这个过程,是因为 LangChain 把它封装在了 _getRelevantDocuments 方法里。
Q6:similaritySearchWithScore 返回的分数能作为最终排序依据吗?
要点回答:
⚠️ 不建议。原因有三:
- 跨模型不可比 :不同 Embedding 模型(如 OpenAI 的
text-embedding-3与bge)的分数分布完全不同 - 缺乏精细校准:向量余弦距离是"粗排",而 Cross-Encoder 重排是"精排",后者更准确
- 阈值难以设定:因为分布不固定,你无法定一个统一的阈值来判断"是否相关"
正确做法 :用 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);