AI 应用开发 Day 10:从 RAG Demo 到工程化——Document、Chunk 与 Metadata Filter

17 阅读10分钟

AI 应用开发 Day 10:从 RAG Demo 到工程化——Document、Chunk 与 Metadata Filter

前几天已经手动实现了一个最基础的 RAG 系统:

用户问题 → Embedding → Vector Search → TopK → Rerank → LLM

Day 9 又进一步学习了 Chunk、Recall、Precision、TopK、Rerank、Hybrid Search 等 RAG 检索优化技术。

到了 Day 10,我开始思考一个更实际的问题:

如果现在不是 knowledge.txt 里面只有 5 条知识,而是公司有几千个 PDF、Word、TXT 文档,这套 RAG 应该怎么处理?

这也是 Day 10 的核心:

从“能跑的 RAG”开始向“具备工程能力的 RAG”迈进。


一、Day 10 学习目标

Day 10 主要学习以下几个概念:

  • Document Loader
  • Document
  • Chunk
  • Metadata
  • Metadata Filter
  • Incremental Update
  • RAG Pipeline

其中最重要的是:

Metadata Filter → Vector Search → Rerank → LLM

通过 Metadata 先缩小检索范围,再进行向量搜索,可以减少无意义的候选数据,提高检索效率。


二、为什么之前的 RAG 还不够工程化?

之前为了学习 RAG,我直接在代码里写:

documents = [
    {
        "text": "公司工资于每月15日发放。",
    },
    {
        "text": "公司工作时间为周一至周五,每天上午9点到下午6点。",
    },
    {
        "text": "员工入职满一年后可以享受带薪年假。",
    }
]

这种方式非常适合学习 RAG 的基本原理。

但真实企业项目显然不会这样做。

实际情况可能是:

knowledge/
├── 薪资制度.pdf
├── 员工手册.pdf
├── 上海项目合同.pdf
├── 产品说明书.docx
├── 技术规范.txt
└── 项目资料/
    ├── 项目A.pdf
    ├── 项目B.pdf
    └── 项目C.pdf

这时候我们需要解决几个问题:

  1. 怎么读取这些文件?
  2. PDF / Word 怎么转换成文本?
  3. 文本怎么切 Chunk?
  4. Chunk 怎么知道自己来自哪个文件?
  5. 查询时能不能只搜索某一类文档?
  6. 新增一个文件的时候,难道要把所有文件重新 Embedding 一遍吗?

Day 10 就是开始解决这些问题。


三、Document Loader:先把文件变成文本

大模型和 Embedding 模型最终处理的核心还是文本。

因此 PDF、Word、TXT 等文件进入 RAG 之前,需要先进行文本提取。

整体流程:

PDF / Word / TXT
        ↓
Document Loader
        ↓
Text
        ↓
Chunk
        ↓
Embedding
        ↓
Vector DB

例如:

薪资制度.pdf
        ↓
PDF Loader
        ↓
公司工资于每月15日发放。
公司年度奖金根据绩效进行发放。
员工入职满一年后可以享受带薪年假。

所以需要明确一个概念:

RAG 并不是直接理解 PDF 文件,而是先把 PDF 转换成可以处理的文本。

以后使用 LangChain、LlamaIndex 等框架时看到:

PDFLoader(...)

本质上做的就是类似的事情。


四、Document → Chunk

拿到完整文本以后,下一步仍然是 Chunk。

之前我的实现非常简单:

chunks = [
    chunk.strip()
    for chunk in text.split("\n\n")
    if chunk.strip()
]

例如:

公司工资于每月15日发放。

公司工作时间为周一至周五,每天上午9点到下午6点。

员工入职满一年后可以享受带薪年假。

通过:

text.split("\n\n")

可以得到:

Chunk 1:公司工资于每月15日发放。

Chunk 2:公司工作时间为周一至周五,每天上午9点到下午6点。

Chunk 3:员工入职满一年后可以享受带薪年假。

这种方式非常适合 Day 8、Day 9 的学习。

但真实文档通常没有这么规整。

所以后续需要进一步使用:

  • Chunk Size
  • Chunk Overlap
  • Token Chunking
  • 文本清洗

等方式进行更加合理的切分。


五、为什么 Chunk 之后还需要 Metadata?

这是 Day 10 最重要的知识之一。

之前的 Vector DB 主要保存:

ID
Document
Embedding

但是在真实企业知识库中,仅仅知道文本内容是不够的。

例如:

公司的办公地点位于上海。

我们还需要知道:

这个内容来自哪里?
属于什么类型?
来自哪个项目?
属于哪个部门?
来自 PDF 的第几页?

因此可以给 Chunk 增加 Metadata:

{
    "category": "office",
    "source": "company.txt",
    "file_type": "txt"
}

甚至可以进一步设计成:

{
    "source": "上海项目合同.pdf",
    "file_type": "pdf",
    "page": 12,
    "category": "contract",
    "project_id": "SH-2026-001"
}

最终一个知识 Chunk 可以理解成:

┌──────────────────────────────┐
│ ID                           │
│                              │
│ Text                         │
│                              │
│ Embedding                    │
│                              │
│ Metadata                     │
│   ├── source                 │
│   ├── file_type              │
│   ├── category               │
│   ├── page                   │
│   └── project_id             │
└──────────────────────────────┘

这就比单纯存一个向量强很多。


六、Day 10 实战:给 Chroma 增加 Metadata

我的知识库文件:

knowledge.txt

内容:

公司工资于每月15日发放。

公司工作时间为周一至周五,每天上午9点到下午6点。

员工入职满一年后可以享受带薪年假。

公司每周五下午提供下午茶。

公司的办公地点位于上海。

切分以后有 5 个 Chunk。

我为它们增加 Metadata:

metadatas = [
    {
        "category": "salary"
    },
    {
        "category": "work_time"
    },
    {
        "category": "vacation"
    },
    {
        "category": "afternoon_tea"
    },
    {
        "category": "office"
    }
]

然后写入 Chroma:

collection.add(
    ids=[
        f"doc_{index}"
        for index in range(len(chunks))
    ],
    documents=chunks,
    embeddings=embeddings,
    metadatas=metadatas
)

这样 Chroma 中的数据就变成:

doc_0
工资
category = salary

doc_1
工作时间
category = work_time

doc_2
年假
category = vacation

doc_3
下午茶
category = afternoon_tea

doc_4
办公地点
category = office

七、Metadata Filter

有了 Metadata 后,我们就可以在搜索之前进行过滤。

例如用户问:

公司在哪里办公?

我们已经知道这是一个 office 类型的问题。

那么就可以:

where={
    "category": "office"
}

进行过滤。

完整查询:

results = collection.query(
    query_embeddings=[query_vector],
    n_results=5,
    where={
        "category": "office"
    }
)

原本有:

5 条知识

经过 Metadata Filter:

5 条
 ↓
category = office
 ↓
1 条

最终只需要对:

公司的办公地点位于上海。

进行后续处理。


八、Metadata Filter 为什么要放在 Vector Search 前面?

这一点非常重要。

正确流程是:

Metadata Filter
       ↓
Vector Search
       ↓
Rerank
       ↓
LLM

而不是:

Vector Search
       ↓
Rerank
       ↓
Metadata Filter

为什么?

假设数据库里面有:

10000 条知识

其中真正属于:

上海项目

的只有:

100 条

那么:

10000 条
   ↓
Metadata Filter
   ↓
100 条
   ↓
Vector Search
   ↓
TopK
   ↓
Rerank

显然比:

10000 条
   ↓
Vector Search
   ↓
TopK
   ↓
Rerank
   ↓
再过滤

更加合理。

可以把 Metadata Filter 简单理解成:

先用结构化条件缩小搜索范围,再用语义搜索寻找最相关内容。

这和 SQL 中的:

WHERE project_id = 'SH-2026-001'

有一点类似。


九、Metadata Filter + Vector Search + Rerank

到这里,我们 Day 9 学习的 Rerank 也可以和 Metadata Filter 串起来。

完整流程:

用户问题
    ↓
Query Embedding
    ↓
Metadata Filter
    ↓
Vector Search
    ↓
TopK
    ↓
Rerank
    ↓
TopN
    ↓
Context
    ↓
DeepSeek
    ↓
Answer

可以进一步理解成:

Metadata Filter
    ↓
负责缩小搜索空间

Vector Search
    ↓
负责语义召回

Rerank
    ↓
负责精确排序

LLM
    ↓
负责理解 Context 并生成答案

也就是:

Filter 负责缩小范围,Vector Search 负责召回,Rerank 负责精排,LLM 负责回答。


十、Day 10 遇到的一个实际问题:为什么 Metadata 明明写了却查询不到?

这次练习中,我还遇到了一个非常典型的问题。

最开始修改代码:

metadatas = [
    {"category": "salary"},
    ...
]

然后查询:

where={
    "category": "office"
}

结果却发现:

[None, None, None, None, None]

也就是说,Chroma 中原来的数据没有 Metadata。

原因很简单:

修改建库代码,并不会自动修改已经存在的 Vector DB 数据。

之前的 Chroma 数据已经被持久化到了:

./chroma_db

当我修改:

collection.add(...)

只是修改了“以后怎么写数据”。

它不会自动把历史数据重新写一遍。


十一、删除 Chroma 重新构建

学习阶段最简单的解决方法就是删除:

chroma_db/

然后重新执行:

python file_rag_demo.py

重新:

读取文件
 ↓
Chunk
 ↓
Embedding
 ↓
Metadata
 ↓
写入 Chroma

重新构建后检查:

data = collection.get()

print(data["metadatas"])

最终得到:

[    {'category': 'salary'},    {'category': 'work_time'},    {'category': 'vacation'},    {'category': 'afternoon_tea'},    {'category': 'office'}]

然后再执行查询:

where={
    "category": "office"
}

最终成功得到:

Vector Search TopK:

0.9698 - 公司的办公地点位于上海。

Rerank:

0.9749 - 公司的办公地点位于上海。

最终交给 DeepSeek:

公司的办公地点位于上海。

DeepSeek 最终回答:

根据知识库中的信息,公司的办公地点位于上海。

至此,Metadata Filter 整个链路验证成功。


十二、这次排错让我理解了一个非常重要的工程问题

RAG 系统不是一个整体黑盒。

它实际上由多个阶段组成:

Document Loader
      ↓
Chunk
      ↓
Embedding
      ↓
Vector DB
      ↓
Metadata Filter
      ↓
Vector Search
      ↓
Rerank
      ↓
Context
      ↓
LLM

出现问题的时候不能只看最终答案。

应该逐层检查:

① 文件有没有正确读取?
        ↓
② Chunk 有没有正确生成?
        ↓
③ Embedding 有没有生成?
        ↓
④ Vector DB 有没有写进去?
        ↓
⑤ Metadata 有没有写进去?
        ↓
⑥ Filter 有没有生效?
        ↓
⑦ Vector Search 有没有找到正确内容?
        ↓
⑧ Rerank 排序是否正确?
        ↓
⑨ Context 是否正确?
        ↓
⑩ LLM 是否基于 Context 回答?

这其实已经开始从“学习 API”转向真正的工程排错思维。


十三、Incremental Update:为什么不能每次都重建知识库?

Day 10 还学习了一个非常重要的概念:

增量更新(Incremental Update)。

假设公司知识库已经有:

10000 个 Chunk

现在新增:

一个 PDF

如果每次都:

删除整个 Vector DB
        ↓
10000 个 Chunk
        ↓
重新 Embedding
        ↓
重新写入

显然效率非常低。

更合理的方式是:

原来的 10000 个 Chunk
        +
新增 PDF
        ↓
只处理新增 PDF
        ↓
Chunk
        ↓
Embedding
        ↓
写入 Vector DB

也就是:

知识库应该支持增量构建,而不是每次全量重建。

当然,在 Day 10 的学习 Demo 中,为了方便验证 Metadata,我使用了删除后重新创建 Collection 的方式。

真实项目中不会这么做。


十四、Day 10 最终 RAG 架构

经过前几天的学习,我现在的 RAG 已经从:

Question
   ↓
Embedding
   ↓
Vector Search
   ↓
LLM

逐渐升级成:

Knowledge Build
                         │
                    Documents
                         │
                         ▼
                       Chunk
                         │
                         ▼
                     Metadata
                         │
                         ▼
                  SiliconFlow Embedding
                         │
                         ▼
                       Chroma
                         │
                         │
                         ▼
User Question → Query Embedding
                         │
                         ▼
                  Metadata Filter
                         │
                         ▼
                   Vector Search
                         │
                         ▼
                       TopK
                         │
                         ▼
                      Rerank
                         │
                         ▼
                       TopN
                         │
                         ▼
                      Context
                         │
                         ▼
                     DeepSeek
                         │
                         ▼
                       Answer

这已经开始接近一个真实企业知识库的基础架构。


十五、Day 10 的几个核心认知

经过 Day 10,我对 RAG 有了几个新的理解。

1. RAG 不只是 Vector Search

真正的 RAG 检索往往是多个步骤组合:

Filter
→ Vector Search
→ Rerank

而不是简单:

Question → Vector DB → TopK

2. Metadata 是企业知识库非常重要的一部分

Vector 负责:

语义检索

Metadata 负责:

结构化过滤

两者结合后,可以同时利用:

语义信息
+
结构化信息

3. Vector DB 不只是存向量

一个真正有用的知识记录应该至少包含:

ID
Text
Embedding
Metadata

因为:

向量负责找,原文负责回答,Metadata 负责控制搜索范围。


4. RAG 是一个 Pipeline

RAG 出问题时,不能只看 LLM。

需要逐层排查:

Loader
→ Chunk
→ Embedding
→ Vector DB
→ Filter
→ Search
→ Rerank
→ Context
→ LLM

5. Demo 和生产系统的最大区别之一就是“数据更新”

Demo 可以:

删库
→
重新构建

生产系统需要考虑:

新增
修改
删除
增量 Embedding
Metadata 更新
Vector DB 同步

这也是后续真正做企业 RAG 时必须解决的问题。


十六、Day 10 总结

Day 10 并没有引入什么特别复杂的新框架,但它让我开始从另一个角度理解 RAG:

RAG 不只是让 LLM “查知识”,而是一套完整的数据处理、检索、排序和生成 Pipeline。

今天最重要的一条链路是:

Document
   ↓
Chunk
   ↓
Metadata
   ↓
Embedding
   ↓
Vector DB
   ↓
Metadata Filter
   ↓
Vector Search
   ↓
Rerank
   ↓
LLM

其中:

Metadata Filter 负责缩小范围

Vector Search 负责语义召回

Rerank 负责精确排序

LLM 负责基于检索结果生成最终答案

到这里,我的 RAG 已经不再只是一个简单的“向量搜索 Demo”,而是开始具备真实企业知识库的基本形态。

下一步进入 Day 11:

从手写 knowledge.txt ,升级到真正读取 PDF / Word / TXT 等企业文档。

也就是开始解决一个更加现实的问题:

“用户上传一个 PDF,我的 RAG 怎么自动把它变成可以检索的知识?”