RAG 知识库怎么建?拆清 Chunk、Document、metadata 和向量数据库

8 阅读9分钟

上一篇文章里,我们用一个本地文章搜索 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. 展示答案来源

模型回答后,可以把 sourcechapterpage 等信息一起返回:

答案依据:产品说明书.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 才决定模型如何使用这些资料。