上一篇文章里,我们用一个本地文章搜索 Demo 跑通了语义检索:
文章内容 → Embedding
用户问题 → Embedding
计算相似度 → 返回最相关的文章
这一步解决的是“怎么找到语义相近的内容”。
但把 Demo 换成真实知识库后,问题马上会复杂起来:
- 一份 PDF 有几十页,应该整份向量化,还是拆开处理?
- 找到相关内容后,怎样知道它来自哪个文件、哪一章?
- 文档越来越多,向量应该存在哪里?
- 用户提问时,检索和大模型回答分别发生在哪一步?
如果这些问题没有解决,我们得到的还只是一个语义搜索 Demo,不是一套可以持续维护的 RAG 知识库。
本文继续往下拆,重点看 RAG 中的 Document、文档切分、metadata 和向量数据库分别解决什么问题。
上一篇文章:RAG 是什么?从关键词搜索到语义搜索的完整过程
RAG 实际上包含两个阶段
很多 RAG 流程图只画用户提问后的部分:
用户提问 → 检索资料 → 交给大模型 → 生成回答
但在用户能够搜索之前,系统还要先把原始资料处理成知识库。
因此,一套完整的 RAG 可以分成两个阶段。
建库阶段
加载原始文件
↓
清洗并切分文档
↓
把文本块转换成向量
↓
保存向量、原文和 metadata
这个阶段通常提前执行。
文档发生增加、修改或删除时,知识库也要同步更新。
查询阶段
用户问题
↓
把问题转换成向量
↓
检索相似文本块
↓
把问题和文本块组成 Prompt
↓
大模型生成回答
建库阶段决定系统里有什么知识,查询阶段决定当前问题能找到哪些知识。
这两个阶段缺少任何一个,都不是完整 RAG。
为什么不能把整份文档直接向量化?
假设知识库里有一份 80 页的产品说明书。
如果把整份说明书当成一个向量,会出现两个问题。
第一,文档包含很多不同主题。
用户只想问“如何申请退款”,整份文档里却同时包含注册、支付、配送、售后和隐私政策。一个向量很难准确表达所有主题。
第二,即使检索命中了这份说明书,也不能把 80 页内容全部放进 Prompt。
大量无关内容不只消耗 Token,还可能干扰模型判断。
因此,真实 RAG 会先把长文档切成多个具有相对完整语义的文本块,也就是 Chunk。
产品说明书
├── Chunk 1:账号注册
├── Chunk 2:商品支付
├── Chunk 3:物流配送
├── Chunk 4:退款条件
└── Chunk 5:退款操作步骤
用户询问退款时,只需要检索 Chunk 4 和 Chunk 5,不需要把整份说明书交给大模型。
文本块不是越大越好
切分文档时,最难的不是“能不能切”,而是“在哪里切”。
文本块过大
- 一个 Chunk 混入多个主题
- 检索结果包含较多无关内容
- 占用更多上下文窗口
- 大模型更难定位真正需要的信息
文本块过小
- 一句话可能被拆成两半
- 标题和正文可能失去联系
- 单个文本块缺少完整语义
- 检索到了局部内容,却无法回答完整问题
因此,切分时通常优先保留自然语义边界:
章节 → 标题 → 段落 → 句子 → Token 数限制
还可以让相邻 Chunk 保留少量重叠内容,避免重要信息正好被切在边界两侧。
例如:
Chunk 1:退款需要满足以下条件……申请入口位于订单详情页。
Chunk 2:申请入口位于订单详情页。提交后可查看审核状态……
“申请入口位于订单详情页”同时出现在两个文本块中,这就是 Chunk Overlap。
它会增加少量重复数据,但能降低上下文被切断的风险。
LangChain 为什么需要 Document?
文档切开以后,系统不能只保存一段普通字符串。
除了正文,我们还需要知道:
- 内容来自哪个文件
- 属于哪一章
- 对应哪个人物或业务类型
- 原始页面在哪里
- 回答时怎样展示来源
LangChain 使用 Document 表示一个可参与检索的文档片段。
最小结构如下:
import { Document } from '@langchain/core/documents';
const document = new Document({
pageContent: '需要向量化和检索的正文内容',
metadata: {
chapter: 1,
source: '产品说明书.pdf',
type: '退款规则'
}
});
它主要包含两部分:
pageContent:正文内容
metadata:描述正文的附加信息
pageContent 负责语义
pageContent 是真正需要被理解和检索的正文。
在常见的 LangChain 向量存储流程中,Embedding Model 默认处理的就是这部分内容:
new Document({
pageContent: '东东是光光最好的朋友……'
});
它会被转换成向量,用来参与后续的相似度计算。
因此,pageContent 的质量会直接影响检索结果:
- 内容是否完整
- 是否混入无关字符
- 文本块大小是否合理
- 标题和正文是否保留了语义联系
如果原始文档清洗得不好,后面的 Embedding 和向量数据库也无法自动修复内容质量。
metadata 负责过滤和溯源
metadata 不负责表达正文语义,它记录的是与文本块有关的结构化信息。
示例中把“光光和东东”的故事拆成多个 Document:
new Document({
pageContent: `有一天,学校要举办一场足球比赛,
光光非常兴奋,他邀请东东一起参加……`,
metadata: {
chapter: 3,
character: '光光和东东',
type: '友情情节',
mood: '鼓励'
}
});
这里的职责非常清楚:
pageContent 告诉系统“这一段讲了什么”
metadata 告诉系统“这一段来自哪里、属于什么”
metadata 常见用途有两个。
1. 过滤检索范围
例如只检索某一章:
chapter = 3
或者只查询某种内容:
type = 友情情节
这样可以先通过结构化条件缩小范围,再做向量相似度检索。
2. 展示答案来源
模型回答后,可以把 source、chapter、page 等信息一起返回:
答案依据:产品说明书.pdf,第 4 章,第 18 页
这能让用户检查原文,也让 RAG 的回答更容易追溯。
如果知识库只保存正文和向量,却不保存 metadata,最后可能“找到了答案,但说不清答案从哪里来”。
生成模型和 Embedding Model 不是一回事
示例中创建了两个模型对象:
import {
ChatOpenAI,
OpenAIEmbeddings
} from '@langchain/openai';
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
}
});
虽然它们都通过兼容接口调用,但职责不同。
| 对象 | 作用 | 输入 | 输出 |
|---|---|---|---|
ChatOpenAI | 理解上下文并生成回答 | 问题、文档片段、Prompt | 自然语言答案 |
OpenAIEmbeddings | 把文本转换成向量 | 文档或问题 | 数字向量 |
生成模型擅长组织答案,Embedding Model 擅长表达语义位置。
两者不一定来自同一个模型服务,但知识库文档和用户问题必须使用兼容的 Embedding Model,保证它们处于同一个向量空间中。
否则,文档向量和问题向量没有可比性,相似度分数也就失去了意义。
向量数据库到底保存什么?
Embedding Model 生成向量以后,需要有地方保存。
一个 RAG 文本块通常至少要保存三类信息:
向量:用于计算语义相似度
原文:检索命中后放进 Prompt
metadata:用于过滤、定位和展示来源
可以把它想象成下面这条记录:
{
"vector": [0.012, -0.031, 0.087],
"pageContent": "东东是光光最好的朋友……",
"metadata": {
"chapter": 2,
"character": "东东",
"type": "角色介绍"
}
}
真实向量往往有数百或数千个维度,这里只展示了三个数字。
向量数据库的核心能力是:
给它一个查询向量,快速找出最相近的若干向量
但检索最终需要的不是那串数字,而是向量对应的原始文本。
因此,一次查询大致会经历:
用户问题
↓
生成问题向量
↓
向量数据库返回 Top K 相似记录
↓
取出记录中的 pageContent 和 metadata
↓
把 pageContent 放进 Prompt
↓
让大模型生成答案
为什么不能把所有检索结果都交给模型?
向量检索通常会返回相似度最高的 Top K 条结果。
K 并不是越大越好。
结果过少,可能缺少回答问题所需的上下文;结果过多,又会引入无关内容、增加 Token 消耗。
除了结果数量,还需要关注相似度阈值。
假设数据库里根本没有用户问题对应的资料,即使取相似度最高的一条,它也可能并不相关。
因此,真实项目通常需要组合判断:
Top K:最多取多少条
相似度阈值:低于多少就认为没有可靠资料
metadata 过滤:限定资料范围
如果没有找到足够可靠的内容,Prompt 应该允许模型明确回答“现有资料不足”,而不是继续猜测。
这才是 RAG 降低幻觉的关键:不仅要找到资料,也要承认没有资料。
当前示例完成到了哪一步?
示例代码已经完成了:
创建生成模型
创建 Embedding Model
使用 Document 组织文本块
为每个文本块保存 metadata
准备用户问题
但它还没有完成:
调用 Embedding Model 生成文档向量
把文档写入向量数据库
把用户问题转换成查询向量
执行相似度检索
把检索结果加入 Prompt
调用生成模型输出答案
所以,这段代码目前完成的是 RAG 的数据准备,而不是一套已经跑通的完整 RAG。
把边界说清楚很重要。
定义了 Document 不等于已经完成检索,创建了 OpenAIEmbeddings 也不等于文档已经被向量化。
只有当下面这条链路真正连通,RAG 才算工作起来:
Document
→ Embedding
→ Vector Store
→ Retriever
→ Prompt
→ LLM
总结
上一篇文章解决的是“怎样通过向量找到语义相近的内容”。
这一篇继续补上真实知识库中的三个关键结构:
- Chunk:把长文档切成适合检索的语义单元
- Document:把正文和结构化信息组织在一起
- Vector Store:保存向量、原文和 metadata,并执行相似度检索
可以把完整 RAG 记成下面这条流程:
原始资料
→ 文档清洗与切分
→ Document(pageContent + metadata)
→ Embedding
→ 向量数据库
→ 语义检索
→ Prompt 增强
→ 大模型生成答案
RAG 的效果并不只由大模型决定。
原始文档质量决定知识库的上限,切分方式决定知识怎样被保存,检索策略决定模型能够看到什么,Prompt 才决定模型如何使用这些资料。