本篇把第一篇的名词映射到真实代码。阅读目标不是背 Python,而是能够沿数据流解释: “这一行接收什么、产生什么、为什么下一步需要它”。
1. 推荐阅读方式
不要从 src/config.py 开始逐文件死读。先记住两个入口,再沿调用链向下:
建立索引:python -m src.cli ingest
提出问题:python -m src.cli search / ask
每读一个模块都问四个问题:
- 输入是什么?
- 输出是什么?
- 它只负责哪一件事?
- 如果这里出错,后面的现象是什么?
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 | 片段在文档中的顺序 |
heading | Markdown 标题路径 |
content | 检索后交给 LLM 的原文 |
metadata | JSONB 结构化信息 |
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 单文档事务保证什么
一次更新包含:
- 插入或更新
documents; - 删除这篇文档的旧 Chunk;
- 插入全部新 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。第二次在不修改文章的情况下执行,文档应被跳过,这就是内容哈希驱动
的增量索引。
11.3 search
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. 自测题
- 为什么
content_hash不能替代 Embedding? - 为什么
embedding_text比content多出标题和章节? CHUNK_SIZE=1200、OVERLAP=200时,下一块为什么前进 1000 字符?- Embedding API 返回 1536 维时,系统为什么应在写数据库前失败?
- 单文档事务避免了哪一种“半更新”状态?
- 为什么
<=>排序使用升序,而显示相似度却越大越好? search正确但ask错误时,你会检查哪些内容?