跳到正文
SL Blog 技术探索 · 工程实践 · AI 时代思考
返回
🔍 RAG 系列 · 2 / 9 查看系列简介 →

RAG 系列(二):源码导读与动手实验

文章目录
  1. 1. 推荐阅读方式
  2. 2. 项目代码地图
  3. 3. 配置:src/config.py
  4. 3.1 为什么集中管理配置
  5. 3.2 为什么 Embedding 与 LLM 使用两套配置
  6. 3.3 timer 在学习中的作用
  7. 4. Markdown 读取:src/markdown_loader.py
  8. 4.1 Document 是什么
  9. 4.2 标题如何确定
  10. 4.3 内容哈希不是 Embedding
  11. 5. 两阶段切块:src/chunker.py
  12. 5.1 第一阶段:按标题边界拆分
  13. 5.2 第二阶段:长章节按字符数继续拆分
  14. 5.3 content 与 embedding_text 为什么不同
  15. 5.4 当前切块策略的边界
  16. 6. Embedding 客户端:src/embedding.py
  17. 6.1 embed 为什么接收列表
  18. 6.2 为什么必须校验维度
  19. 6.3 dimensions 的兼容重试
  20. 7. 数据库:src/database.py 与 sql/init.sql
  21. 7.1 两张表的关系
  22. 7.2 document_chunks 每列的角色
  23. 7.3 为什么没有 HNSW / IVFFlat
  24. 8. 增量索引:src/ingest.py
  25. 8.1 完整执行顺序
  26. 8.2 为什么向量化在数据库事务之前
  27. 8.3 单文档事务保证什么
  28. 8.4 为什么变化文档要删除旧 Chunk
  29. 9. 检索:src/retrieval.py
  30. 9.1 查询必须先向量化
  31. 9.2 核心 SQL 逐句解释
  32. 9.3 search 返回的不只是文本
  33. 10. Prompt 与生成:src/llm.py
  34. 10.1 System Prompt 的作用
  35. 10.2 User Message 的结构
  36. 10.3 temperature=0.2
  37. 11. CLI:src/cli.py
  38. 11.1 doctor
  39. 11.2 ingest
  40. 11.3 search
  41. 11.4 ask
  42. 12. 六个动手实验
  43. 实验 1:区分检索与生成
  44. 实验 2:观察改写问题
  45. 实验 3:观察 Top K
  46. 实验 4:观察 Chunk Size
  47. 实验 5:理解标题增强
  48. 实验 6:提出知识库没有答案的问题
  49. 13. 推荐排错顺序
  50. 14. 自测题

本篇把第一篇的名词映射到真实代码。阅读目标不是背 Python,而是能够沿数据流解释: “这一行接收什么、产生什么、为什么下一步需要它”。

1. 推荐阅读方式

不要从 src/config.py 开始逐文件死读。先记住两个入口,再沿调用链向下:

建立索引:python -m src.cli ingest
提出问题:python -m src.cli search / ask

每读一个模块都问四个问题:

  1. 输入是什么?
  2. 输出是什么?
  3. 它只负责哪一件事?
  4. 如果这里出错,后面的现象是什么?

2. 项目代码地图

src/cli.py
├── ingest
│   └── src/ingest.py
│       ├── src/markdown_loader.py
│       ├── src/chunker.py
│       ├── src/embedding.py
│       └── src/database.py → sql/init.sql
├── search
│   └── src/retrieval.py
│       ├── src/embedding.py
│       └── src/database.py
└── ask
    ├── src/retrieval.py
    └── src/llm.py

src/config.py 被各模块共同读取,但不会主动启动任何流程。

3. 配置:src/config.py

3.1 为什么集中管理配置

RAG 有多组容易混淆的参数:

  • PostgreSQL 连接;
  • Embedding 模型、维度、批大小;
  • LLM 地址、密钥、模型;
  • TOP_K、CHUNK_SIZE、CHUNK_OVERLAP;
  • 知识库目录。

集中配置让数据链路显式依赖同一组值。尤其是 EMBEDDING_DIMENSIONS,必须与 SQL 中的 VECTOR(1024) 一致。

3.2 为什么 Embedding 与 LLM 使用两套配置

两者职责不同,供应商也可以不同:

DASHSCOPE_API_KEY / BAILIAN_BASE_URL → 只用于 Embedding
LLM_API_KEY / LLM_BASE_URL           → 只用于聊天模型

这意味着可以单独更换生成模型而不重建向量;但若更换 Embedding 模型,向量空间改变,通常必须 重新生成知识库向量。

3.3 timer 在学习中的作用

timer 是上下文管理器:

with config.timer("Vector search"):
    # 被测代码

它记录墙钟耗时,帮助你区分延迟主要来自:

  • 远端 Embedding API;
  • 本地 PostgreSQL 向量计算;
  • LLM 生成。

不要把三段耗时混成“RAG 很慢”这一个模糊结论。

4. Markdown 读取:src/markdown_loader.py

4.1 Document 是什么

Document 是“已经解析、尚未切块”的中间对象:

source_path  来源路径
title        文档标题
content_hash 原始字节的 SHA-256
body         去掉 Front Matter 后的正文
front_matter 标签、发布时间等元数据

它把磁盘文件转换为后续模块都能理解的统一结构。

4.2 标题如何确定

代码按以下优先级推导标题:

Front Matter 的 title
        ↓ 没有
正文第一个 # H1
        ↓ 没有
文件名(不含 .md)

标题不仅用于展示,还会加入 embedding_text。一个片段即使正文很短,也能通过标题保留主题信息。

4.3 内容哈希不是 Embedding

content_hash 和 Embedding 都是一组看似难读的数字,但用途完全不同:

项目SHA-256 内容哈希Embedding
目的判断字节是否完全变化比较语义是否接近
相似文本是否接近不会,改一个字也可能完全不同通常会
能否用于增量更新能不适合
能否用于语义检索不能能

5. 两阶段切块:src/chunker.py

5.1 第一阶段:按标题边界拆分

输入:

# Spring Cache

总体介绍。

## RedisCacheManager

Redis 缓存配置。

### TTL

过期时间配置。

标题栈会产生路径:

Spring Cache
Spring Cache > RedisCacheManager
Spring Cache > RedisCacheManager > TTL

标题路径比单独的 TTL 更有意义,因为它说明这里讨论的是 Spring Cache 下 Redis 管理器的 TTL, 而不是任意系统中的 TTL。

5.2 第二阶段:长章节按字符数继续拆分

假设:

CHUNK_SIZE = 1200
CHUNK_OVERLAP = 200

相邻起点每次前进:

step = 1200 - 200 = 1000

于是片段范围近似为:

Chunk 1: 字符    0 ~ 1199
Chunk 2: 字符 1000 ~ 2199
Chunk 3: 字符 2000 ~ 3199

Chunk 1 与 Chunk 2 重复 200 字符。这样一个跨越第 1100~1300 字符的解释不会被完全切断。

5.3 content 与 embedding_text 为什么不同

数据库与 LLM 使用的原文:

RedisCacheManager 可以通过 entryTtl 配置过期时间。

实际发送给 Embedding 模型的文本:

Title: Spring Cache 学习笔记

Section: Spring Cache > RedisCacheManager > TTL

RedisCacheManager 可以通过 entryTtl 配置过期时间。

标题和章节是检索提示,但回答时不需要把这些辅助前缀冒充正文,所以项目分别保存两个表示。

5.4 当前切块策略的边界

本项目 v0.1 使用字符长度,不识别完整句子、代码块或 Token 边界。这种实现足够透明,适合学习, 但可能把一句话或代码块切开。Overlap 能缓解问题,不能彻底解决。

6. Embedding 客户端:src/embedding.py

6.1 embed 为什么接收列表

索引阶段可能有上千个 Chunk。逐条发送会增加网络往返;批量请求可以减少 API 调用次数。

texts:   [文本1, 文本2, 文本3]
vectors: [向量1, 向量2, 向量3]

顺序必须保持一致,否则文本和向量会错配。写入数据库时,第 i 个 Chunk 与第 i 个向量通过 zip(chunks, vectors) 对齐。

6.2 为什么必须校验维度

数据库列固定为 VECTOR(1024)。如果 API 因模型或参数变化返回其他维度,应在任何写操作前失败:

预期 1024,实际 1536
→ 立即抛出 EmbeddingError
→ 不写数据库

这比写到一半才发现错误更安全。

6.3 dimensions 的兼容重试

代码先显式发送 dimensions=1024。某些 OpenAI 兼容端点不接受该参数,于是重试一次不带参数; 上层仍会检查实际维度。这是“兼容性 fallback”,不是忽略维度约束。

7. 数据库:src/database.py 与 sql/init.sql

7.1 两张表的关系

documents(1 篇原文)
    id = 10
       │ 一对多
       ├── document_chunks(第 0 块)
       ├── document_chunks(第 1 块)
       └── document_chunks(第 2 块)

ON DELETE CASCADE 表示删除 Document 时,它的 Chunk 会一并删除,避免孤立片段。

7.2 document_chunks 每列的角色

列用途
document_id指向原文
chunk_index片段在文档中的顺序
headingMarkdown 标题路径
content检索后交给 LLM 的原文
metadataJSONB 结构化信息
embedding用于距离计算的 1024 维向量

7.3 为什么没有 HNSW / IVFFlat

pgvector 默认可以执行精确最近邻搜索。当前数据量较小,精确搜索更容易理解和评测:每个查询会 考虑全部候选向量,不引入 ANN 索引召回率与调参问题。

未来数据量大到延迟不可接受时,再用 HNSW/IVFFlat 以召回率换速度,并单独评测这个变化。

8. 增量索引:src/ingest.py

8.1 完整执行顺序

扫描 Markdown
→ 读取已有 source_path/content_hash
→ 当前文件计算 SHA-256
→ 哈希未变:skip
→ 哈希变化:parse → chunk → embed → 写数据库

增量更新避免每次发布一篇文章都重新调用整个知识库的 Embedding API。

8.2 为什么向量化在数据库事务之前

远端 API 可能较慢或失败。代码先得到完整向量并校验维度,再开始数据库写事务,避免长时间占用 事务和锁。

8.3 单文档事务保证什么

一次更新包含:

  1. 插入或更新 documents;
  2. 删除这篇文档的旧 Chunk;
  3. 插入全部新 Chunk。

任一步失败都会 rollback。因此数据库不会处于“旧片段已删、新片段只写一半”的状态。

8.4 为什么变化文档要删除旧 Chunk

切块数量与边界可能随正文变化。按新序号逐条覆盖会遗留已经不存在的旧片段;先删除该文档所有 旧 Chunk,再插入完整新集合,状态更容易推理。

9. 检索:src/retrieval.py

9.1 查询必须先向量化

数据库保存的是文档 Chunk 向量,用户问题仍是文本。只有把问题映射到同一向量空间,才能比较:

Question Text → Query Vector ↔ Chunk Vectors

9.2 核心 SQL 逐句解释

SELECT
    dc.id,
    d.source_path,
    d.title,
    dc.heading,
    dc.content,
    1 - (dc.embedding <=> %s) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
ORDER BY dc.embedding <=> %s
LIMIT %s;
  • JOIN:取回 Chunk 对应的文档来源和标题。
  • <=>:计算余弦距离,越小越接近。
  • ORDER BY ...:默认升序,所以最近的片段排在前面。
  • 1 - distance:转换为越大越相似的展示值。
  • LIMIT:只保留 Top K。

同一个查询向量参数出现两次,一次用于展示相似度,一次用于排序。

9.3 search 返回的不只是文本

结果字典还包含 id、路径、标题、章节和相似度。这样:

  • LLM 得到正文;
  • 用户看到来源;
  • Benchmark 可以用 Chunk ID 判断命中;
  • 开发者可以分析相似度和错误召回。

10. Prompt 与生成:src/llm.py

10.1 System Prompt 的作用

System Prompt 告诉模型:

  • 你是知识库问答助手;
  • 只根据 Context 回答;
  • 信息不足时明确说无法确定;
  • 不要用 Context 外的信息猜测。

它降低越界回答概率,但不是绝对安全机制。

10.2 User Message 的结构

Context
--------------------
[Source 1]
File: ...
Section: ...
正文...

[Source 2]
...

Question
--------------------
用户问题

清晰分隔来源、Context 与 Question,可以减少模型把指令和资料混淆。

10.3 temperature=0.2

较低温度让输出更稳定、少发散,适合知识库问答。但温度不等于“事实正确开关”,真正的依据仍是 检索 Context 与 Prompt 约束。

11. CLI:src/cli.py

11.1 doctor

python -m src.cli doctor

它依次检查数据库、pgvector、表结构、向量维度、Embedding、LLM 和知识目录。先让基础依赖通过, 可以避免把网络或配置错误误判成 RAG 算法问题。

11.2 ingest

python -m src.cli ingest

观察 updated 和 skipped。第二次在不修改文章的情况下执行,文档应被跳过,这就是内容哈希驱动 的增量索引。

python -m src.cli search "RedisCacheManager 如何配置 TTL?"

只运行检索,不调用 LLM。学习和调试时应优先使用它,因为你能直接看到 Retriever 到底找了什么。

11.4 ask

python -m src.cli ask "RedisCacheManager 如何配置 TTL?"

在 search 基础上增加 Prompt 和 LLM。若 search 已经错误,不应先调 Prompt;LLM 无法可靠地 从错误资料中创造正确证据。

12. 六个动手实验

实验 1:区分检索与生成

对同一问题先运行 search,再运行 ask。记录:

  • 正确 Chunk 是否进入 Top 5?
  • 最终回答是否忠实使用了该 Chunk?

这会把一个“回答不好”的模糊问题拆成 Retriever 或 LLM 两部分。

实验 2:观察改写问题

分别搜索:

RedisCacheManager 如何配置 TTL?
Spring Cache 中缓存多久会失效?

第二句没有直接复述 TTL,可以观察 Embedding 是否仍召回同一章节。

实验 3:观察 Top K

临时分别设置 TOP_K=1、5、10,先预测再运行:

  • 正确资料何时首次出现?
  • 结果中重复或无关内容是否增加?
  • ask 的 Context 是否变得更嘈杂?

一次只改 Top K,其他参数保持不变。

实验 4:观察 Chunk Size

选择一篇长文,记录默认切块数;再单独改变 CHUNK_SIZE 并重建索引。观察:

  • Chunk 数量;
  • 单个结果的完整性;
  • 相关问题的排名。

改变切块后,旧 Benchmark Snapshot 已失效,不能把新结果与旧结果当成同一实验直接比较。

实验 5:理解标题增强

查看同一片段的 content 与 embedding_text。思考:如果正文只有“默认值为 5 分钟”,缺少标题 “RedisCacheManager > TTL”,检索器如何知道这 5 分钟指什么?

实验 6:提出知识库没有答案的问题

搜索一个确认不在知识库中的主题。向量数据库仍会返回 Top K,因为“最近”不等于“正确”。 这解释了为什么 No Answer、相似度阈值和拒答策略需要单独设计。

13. 推荐排错顺序

1. doctor 是否全部通过?
2. ingest 是否发现并更新了目标文档?
3. search 是否召回正确 Chunk?
4. Chunk 是否包含完整答案和正确标题?
5. Top K 是否合理?
6. Context 是否正确组装?
7. 最后才检查 Prompt 与 LLM。

不要在第 3 步失败时直接更换 LLM;那是在用生成模型掩盖检索问题。

14. 自测题

  1. 为什么 content_hash 不能替代 Embedding?
  2. 为什么 embedding_text 比 content 多出标题和章节?
  3. CHUNK_SIZE=1200、OVERLAP=200 时,下一块为什么前进 1000 字符?
  4. Embedding API 返回 1536 维时,系统为什么应在写数据库前失败?
  5. 单文档事务避免了哪一种“半更新”状态?
  6. 为什么 <=> 排序使用升序,而显示相似度却越大越好?
  7. search 正确但 ask 错误时,你会检查哪些内容?

上一篇:RAG 系列(一):核心原理与完整链路

下一篇:RAG 系列(三):Embedding 检索评测与 Benchmark


RAG 智能问答

针对本文继续提问:《RAG 系列(二):源码导读与动手实验》