📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 65:向量数据库入门 --- Chroma 与本周收官 🎉
FDE 学习系列教程 · 第三阶段 · 第 9 周 · Day 5(收官) 预计时长:3.5 小时 | 难度:★★★★☆ | 前置知识:Day 61-64(RAG 链路、BGE-M3、文档解析、文本分块) 对标大纲课时:3.2.5 向量数据库 ------ 选型对比:大规模用 Milvus,已有 PG 用 PGVector,快速原型用 Chroma,高性能用 Qdrant(本周收官)
📌 一句话目标:搞懂向量库在干什么,用 Chroma 把 Day63 解析、Day64 分块、Day62 向量化的手册灌成一个可查询的知识库,产出"知识库问答 v0.2"并完成本周复盘。
🧑🤝🧑 开场:四块零件,今天合体
回头看看这周你攒下的东西:
Day61 RAG 全景 八步链路图 + 最小 RAG 脚本(字符重合打分)
Day62 Embedding BGE-M3 把文字变成 1024 维向量,语义检索可用了
Day63 文档解析 MinerU / Docling 把真实 PDF 变成干净 Markdown
Day64 文本分块 四种策略对比,选出最适合手册的切法
你手里已有:一份干净的 manual_a3.clean.md、一组切好的 chunk、
一个 BGE-M3 模型、一套验证过的分块参数。
缺的 :一个能把它们【存起来、查得快】的地方。
回看第二阶段:那时你要存数据,第一个想到的是 MySQL------表、行、字段、索引、SQL 查询。今天要存的是"向量",MySQL 不会算余弦相似度,所以需要新东西:向量数据库。
第二阶段:结构化数据 → MySQL(按字段精确匹配 + 范围查询)
今天 :非结构化语义 → 向量库(按语义相似度模糊匹配)
两者是搭档,不是替代:
工单表 / 设备台账 → MySQL(第二阶段的家底)
手册 / 维修记录 → 向量库(今天的增量)
一个 chunk 用 id 就能关联回 MySQL 的设备记录
今天的产出是知识库问答 v0.2:第二阶段的设备告警工单闭环系统 + 第 7-8 周冻结的 v0.1(分类/提取/报告)+ 本周新增的手册问答。
第二阶段工单闭环(FastAPI+MySQL+Docker+飞书)
↓
v0.1(第7-8周冻结):工单分类 / 信息提取 / 巡检报告
↓
v0.2(今天)🆕:问"这台设备怎么修" → 检索手册 → 带引用回答
今天五件事,收官日内容多一点,节奏跟紧:① 向量库到底在干什么(为什么不能拿 numpy 硬算);② 四个选型怎么挑(Chroma / Qdrant / Milvus / PGVector);③ Chroma 上手:持久化、四件套概念、增删查;④ 完整项目:灌库脚本 + 查询脚本,跑通"问手册→带引用回答";⑤ 本周复盘 + 验收清单 + 第 10 周预告。
📖 一、向量库是干嘛的:存向量 + 快速找邻居
本质就两件事
向量数据库 = 存储 + 近似最近邻检索(ANN)
① 存:向量 + 原文 + 元数据(来源/页码/章节/密级)
② 查:给你一个向量,找出库里跟它最像的 K 个
就这两件事,没有别的。
用 Day 62 的类比说:向量库就是"图书管理员"的索引卡片柜------你给它一个问题向量,它按卡片快速找出最相似的几本书(chunk),把书和你一起交给模型。
为什么不直接拿 numpy 硬算?
你可能会想:Day 62 我不就是用 DOC_VECS @ qv 一行算出相似度了吗?要什么数据库?
因为那个做法只在"5 条资料"时成立。 暴力算法要把查询向量和库里每一个向量都比一次:
| 资料量 | 每次查询的计算量 | 可行性 |
|---|---|---|
| 100 条 | 100 次内积 | ✅ 瞬间 |
| 1 万条 | 1 万次 | ✅ 毫秒级,还行 |
| 100 万条 | 100 万次 × 1024 维 | ⚠️ 秒级,用户开始等 |
| 1 亿条 | 1 亿次 | ❌ 不可能,内存也放不下 |
向量库的答案是 ANN(Approximate Nearest Neighbor,近似最近邻):
不追求"绝对最相似的 K 个",而是"99% 概率最相似的 K 个"
用预先建好的索引结构跳过绝大多数不可能的候选
主流索引:HNSW(分层可导航小世界图)------ Chroma/Qdrant/Milvus 都用
直觉:给向量建"好友网络图",查询时从入口点出发每次跳到"更像目标"的邻居,
几步就到 → 复杂度从 O(n) 降到 O(log n)
代价:索引占内存,结果是"近似"的(可能漏掉第 K+1 名,概率极低)
收益:100 万条数据,查询从秒级降到毫秒级
💡 FDE 的判断标准 :资料量 < 1 万条且离线批处理 → numpy 够用,别过度设计;要在线服务、增量更新、持久化、元数据过滤 → 上向量库。客户现场手册几十份、切完几万到几十万块,必须上。
一条记录长什么样
┌─────────────────────────────────────────────────────────┐
│ id "manual_a3-0042" ← 唯一标识(你自己定) │
│ embedding [0.021, -0.113, ...] ← 1024 维向量(检索用) │
│ document "§4.3 冷却系统维护..." ← 原文(喂给模型用) │
│ metadata {source, page:47, chapter, device:"A3", acl} │
└─────────────────────────────────────────────────────────┘
embedding → 算相似度 document → 拼进 Prompt
metadata → 过滤(只看 A3)+ 溯源(标 P47)
⚠️ metadata 是被严重低估的资产。第 10 周 Day 69 的权限过滤完全靠它实现("谁能看到哪些 chunk")。今天灌库就把 metadata 设计好,别等上线返工。
📖 二、选型对比:四个选手怎么挑
大纲 3.2.5 给了明确指引:"大规模用 Milvus,已有 PG 用 PGVector,快速原型用 Chroma,高性能用 Qdrant"。展开成一张表:
| 维度 | Chroma | Qdrant | Milvus | PGVector |
|---|---|---|---|---|
| Star 量级 | ~29K | ~34K | ~46K | PG 扩展 |
| 定位 | 原型 / 嵌入式 | 生产级通用 | 大规模分布式 | PG 生态补充 |
| 部署难度 | ⭐ 极简(pip install 即用) |
⭐⭐ Docker 一条命令 | ⭐⭐⭐⭐ 需 etcd/minio 等多组件 | ⭐⭐ 给现有 PG 装扩展 |
| 运行形态 | 嵌入式(进程内)/ 单机服务 | 独立服务(gRPC+HTTP) | 分布式集群 | PostgreSQL 的一个扩展 |
| 数据规模 | 百万级以内舒适 | 千万级 | 亿级 | 千万级以内 |
| 元数据过滤 | ✅ 基础 | ✅✅ 强(payload index) | ✅✅ 强 | ✅✅ 最强(直接写 SQL) |
| 混合检索 | 一般 | ✅ 原生支持稀疏向量 | ✅ 支持 | 需配合全文检索 |
| 语言客户端 | Python/JS 为主 | Python/Go/Rust/JS/Java | 多语言 | 任何能连 PG 的语言 |
| 运维成本 | 极低 | 中 | 高 | 低(复用现有 PG 运维) |
| 适合场景 | 学习/原型/小工具 | 生产主力 | 超大规模、多租户 | 已有 PostgreSQL |
FDE 的决策树 :① 已有 PostgreSQL 且量 < 千万级 → PGVector(不引入新组件,审核最容易过);② 量 > 亿级 / 多租户 / 有专职运维 → Milvus;③ 要生产、要性能、过滤强、千万级 → Qdrant (第 10 周 Day 66 用它);④ 原型 / demo / 本地开发 / 百万级内 → Chroma(今天用它)。
💡 工程建议 :原型用 Chroma,上线换 Qdrant,两者代码差别不大(都是
add/query/ 过滤)。今天学的 collection、metadata、top-k、距离到 Qdrant 完全平移,不用担心白学。
为什么今天从 Chroma 开始 :pip install chromadb 就能跑,不需要 Docker、不需要起服务;PersistentClient 一行搞定持久化;API 极简;get_or_create_collection 幂等,脚本可反复跑。⚠️ 唯一坑是默认嵌入模型 all-MiniLM-L6-v2 是英文模型,中文效果差------我们用"自己传 BGE-M3 向量"绕开它。
🖥️ 三、Chroma 上手:从安装到查询
实操步骤 1:安装
cd fde-ai
.\.venv\Scripts\Activate.ps1
pip install chromadb
⚠️ 如果与 FlagEmbedding 的 torch/pydantic 版本冲突,按 Day 63 的经验单独建环境:
python -m venv .venv-vdb .\.venv-vdb\Scripts\Activate.ps1 pip install chromadb然后把灌库脚本和查询脚本都放这个环境里跑(它们本来就是独立进程)。
实操步骤 2:Hello Chroma ------ 先跑通最小闭环
新建 day65_hello_chroma.py:
"""Day65 第一步:Chroma 最小闭环 ------ 建库 / 灌数据 / 查询"""
import chromadb
# ① 持久化客户端:数据落盘到 ./chroma_db,重启不丢
client = chromadb.PersistentClient(path="./chroma_db")
# ② collection(集合):相当于 MySQL 里的"一张表"
# get_or_create = 有就取、没有就建 → 脚本反复跑不报错(幂等)
col = client.get_or_create_collection(
name="manual",
metadata={"hnsw:space": "cosine"}, # 相似度空间:cosine / l2 / ip
)
# ③ add:灌入 3 条(embeddings 先手写假向量,下一步换成 BGE-M3)
col.add(
ids=["c1", "c2", "c3"],
documents=[
"§4.3 冷却系统维护:冷却水进水压力正常范围 0.3~0.5MPa。",
"§6.1 加热系统:加热圈单段电阻标准 18±2 欧姆。",
"§7.4 液压系统:液压油每 500 小时更换一次。",
],
embeddings=[[0.10, 0.20, 0.30], [0.90, 0.10, 0.05], [0.15, 0.85, 0.10]],
metadatas=[
{"source": "manual_a3.pdf", "page": 47, "chapter": "4.3 冷却系统维护", "device": "A3"},
{"source": "manual_a3.pdf", "page": 62, "chapter": "6.1 加热系统", "device": "A3"},
{"source": "manual_a3.pdf", "page": 88, "chapter": "7.4 液压系统", "device": "A3"},
],
)
print("灌入后条数:", col.count())
# ④ query:用一个查询向量找最像的 2 条
res = col.query(query_embeddings=[[0.12, 0.22, 0.31]], n_results=2)
for i in range(len(res["ids"][0])):
md = res["metadatas"][0][i]
print(f" id={res['ids'][0][i]} 距离={res['distances'][0][i]:.4f} 来源=P{md['page']}")
print(f" {res['documents'][0][i][:50]}")
python day65_hello_chroma.py
输出:
collection: manual 已有条数: 0
灌入后条数: 3
查询结果:
id=c1 距离=0.0000 来源=P47
§4.3 冷却系统维护:冷却水进水压力正常范围 0.3~0.5MPa。
id=c3 距离=0.2537 来源=P88
§7.4 液压系统:液压油每 500 小时更换一次。
三个必须建立的认知:
-
返回的是距离 不是相似度 →
0.0000表示一模一样(最相关)。⚠️ 距离越小越像,跟相似度相反! -
metadatas原样带回来了 → 你终于能说出"这个答案来自 P47"。 -
./chroma_db目录已生成,关掉 Python 再进数据还在 → 持久化生效。
实操步骤 3:metadata 过滤(where)
这是向量库比 numpy 强的一大关键------先按条件圈定范围,再在范围内做语义检索。
"""Day65:where 元数据过滤"""
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection(name="manual",
metadata={"hnsw:space": "cosine"})
# 灌两条别的设备的,制造对比
col.add(ids=["c4", "c5"],
documents=["B2 冲压机冷却系统:冷却水压力 0.35~0.55MPa。",
"B2 冲压机液压系统:液压油每 400 小时更换一次。"],
embeddings=[[0.13, 0.21, 0.29], [0.16, 0.84, 0.11]],
metadatas=[{"source": "manual_b2.pdf", "page": 30, "chapter": "3.1 冷却系统", "device": "B2"},
{"source": "manual_b2.pdf", "page": 55, "chapter": "5.2 液压系统", "device": "B2"}])
QV = [0.12, 0.22, 0.31] # 一个偏向"冷却系统"的查询向量
def show(title: str, **kw):
r = col.query(query_embeddings=[QV], n_results=3, **kw)
print(f"\n【{title}】")
for i in range(len(r["ids"][0])):
md = r["metadatas"][0][i]
print(f" {md['device']} P{md['page']} 距离={r['distances'][0][i]:.4f}")
show("不过滤")
show("只看 A3", where={"device": "A3"})
show("页码 > 50", where={"page": {"$gt": 50}})
show("A3 且页码>40", where={"$and": [{"device": "A3"}, {"page": {"$gt": 40}}]})
支持的操作符:
| 操作符 | 含义 | 示例 |
|---|---|---|
$eq |
等于(默认) | {"device": "A3"} |
$ne |
不等于 | {"device": {"$ne": "B2"}} |
$gt / $gte |
大于 / 大于等于 | {"page": {"$gt": 50}} |
$lt / $lte |
小于 / 小于等于 | {"page": {"$lte": 60}} |
$in / $nin |
在 / 不在列表内 | {"device": {"$in": ["A3", "A5"]}} |
$and / $or |
且 / 或 | {"$and": [{"device":"A3"}, {"page":{"$gt":40}}]} |
📌 这个
where就是第 10 周 Day 69「权限过滤」的技术基础 。今天先把device、acl(可访问角色)这类字段灌进去,将来加权限就是加一个where条件的事。
实操步骤 4:改、删与运维操作
"""Day65:Chroma 的改 / 删 / 运维操作"""
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection(name="manual",
metadata={"hnsw:space": "cosine"})
# 改:upsert(有则更新,无则插入)------ 手册改了一页就用它
col.upsert(ids=["c1"], documents=["§4.3 冷却系统维护:压力范围修订为 0.32~0.5MPa。"],
embeddings=[[0.11, 0.20, 0.30]],
metadatas=[{"source": "manual_a3.pdf", "page": 47,
"chapter": "4.3 冷却系统维护", "device": "A3"}])
print("upsert 后:", col.get(ids=["c1"])["documents"])
# 删:按 id 或按条件
col.delete(ids=["c5"])
# col.delete(where={"device": "B2"}) # 按条件删,慎用
print("删除后条数:", col.count())
# 运维:列集合 / 翻看内容 / 删集合
print("所有集合:", [c.name for c in client.list_collections()])
print("前 3 条 id:", col.peek(limit=3)["ids"])
# client.delete_collection("manual") # 删整个集合(重建时用)
🖥️ 四、完整项目:把手册灌成可查询的知识库
现在把 Day 62/63/64 的成果全部合进来。这是本周的主线产出。
项目结构
fde-ai/
├── .env / .gitignore # Key 与 chroma_db/ 都要忽略
├── data/cleaned/manual_a3.clean.md # Day63 清洗后 ⭐(今天的原料)
├── kb/ # 今天的知识库模块
│ ├── embedder.py # Day62:BGE-M3 封装(全项目唯一入口)
│ ├── chunker.py # Day64:分块策略
│ ├── build_index.py # ① 灌库脚本(离线跑,资料变了才重跑)
│ ├── retriever.py # ② 检索封装(在线)
│ └── answer.py # ③ 问答:检索 + DeepSeek 生成 + 引用
├── day65_ask.py # 命令行入口
└── chroma_db/ # 向量库落盘目录
📌 落库脚本与查询脚本必须分离 (
build_index.pyvsretriever.py):建库是重活(编码几万条),只在资料变更时跑;查询是轻活(编码一条),每次提问都跑。混在一起会导致每次提问都重新编码整个库------这在客户现场是最典型的性能事故。
实操步骤 5:Embedding 封装
kb/embedder.py:
"""Embedding 封装:全项目只用这一个入口,保证【建库和查询用同一个模型】"""
import os
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
import numpy as np
from FlagEmbedding import BGEM3FlagModel
MODEL_NAME = "BAAI/bge-m3"
USE_FP16 = False # 无 GPU 必须为 False
_model = None
def get_model() -> BGEM3FlagModel:
global _model
if _model is None:
print(f"⏳ 加载 {MODEL_NAME} ...(首次下载约 1.2GB)")
_model = BGEM3FlagModel(MODEL_NAME, use_fp16=USE_FP16)
return _model
def embed(texts: list[str], batch_size: int = 12) -> np.ndarray:
"""文本列表 → 向量矩阵 (n, 1024),已归一化"""
out = get_model().encode(texts, batch_size=batch_size, max_length=8192)
return np.asarray(out["dense_vecs"], dtype="float32")
def embed_one(text: str) -> np.ndarray:
"""单条文本 → 向量 (1024,)"""
return embed([text], batch_size=1)[0]
⚠️ 为什么要单独封装? 因为"建库用 A 模型、查询用 B 模型"是 RAG 项目最经典的致命错误。封装成一个入口,物理上杜绝这个可能。
实操步骤 6:灌库脚本
kb/build_index.py(离线脚本,只在资料变化时跑):
"""灌库脚本:手册 Markdown → 分块 → BGE-M3 向量 → Chroma
用法:python -m kb.build_index
"""
import re
from pathlib import Path
import chromadb
from .embedder import embed
from .chunker import chunk_by_sentences # Day64 验证过的最优策略
CHROMA_DIR = Path("./chroma_db")
COLLECTION = "manual"
MANUAL = Path("data/cleaned/manual_a3.clean.md")
FALLBACK = """
# A3 型注塑机操作与维护手册
## 4.3 冷却系统维护
冷却水进水压力正常范围为 0.3~0.5MPa,低于 0.25MPa 时必须停机检查。
停机后应先关闭进水阀,挂牌上锁,再拆卸管路进行疏通。疏通完成后需进行 0.6MPa 保压试验,保压 10 分钟无渗漏方可恢复运行。
## 6.1 加热系统
加热圈共 4 段,单段电阻值标准 18±2 欧姆。固态继电器击穿后加热圈会持续通电。更换加热圈前必须切断主电源并挂牌上锁(LOTO)。
## 3.2 料筒超温处置
A3 注塑机料筒设定温度 80℃,超过 85℃ 触发高温告警。禁止在料筒温度未降至 60℃ 以下时打开防护罩。
## 7.4 液压系统
液压油每 500 小时更换一次。伺服阀阀芯卡滞会导致锁模力下降,表现为产品飞边增多。
"""
def split_with_metadata(md: str, source: str) -> list[dict]:
"""按 Markdown 章节切块,并抽出元数据(章节名 + 设备号)"""
blocks, cur_title, buf = [], "全文开头", []
def flush():
body = "\n".join(buf).strip()
if not body:
return
# 大块再按句子切(Day64 结论:按句子组装,不在句中断开)
parts = chunk_by_sentences(body, chunk_size=500, overlap=1)
for i, p in enumerate(parts):
# ⭐ 上下文前缀:把章节标题塞回块里,给向量补上"归属"语义
m = re.search(r"([A-Z]\d+)", cur_title + " " + p)
blocks.append({
"text": f"【{cur_title}】\n{p}",
"chapter": cur_title,
"device": m.group(1) if m else "UNKNOWN",
"part": f"{i+1}/{len(parts)}",
"source": source,
})
for ln in md.splitlines():
m = re.match(r"^(#{1,6})\s+(.*)", ln)
if m:
flush()
buf, cur_title = [], m.group(2).strip()
else:
buf.append(ln)
flush()
return blocks
def main():
if MANUAL.exists():
md = MANUAL.read_text(encoding="utf-8")
src_name = MANUAL.name
print(f"✅ 读取手册:{MANUAL}({len(md)} 字符)")
else:
md, src_name = FALLBACK.strip(), "sample"
print("⚠️ 未找到清洗后的手册,使用内置示例(请先完成 Day63)")
blocks = split_with_metadata(md, source=src_name)
print(f"📦 共切出 {len(blocks)} 个块,平均 "
f"{sum(len(b['text']) for b in blocks) // len(blocks)} 字")
print("⏳ 正在向量化...(CPU 上约需几十秒到几分钟)")
vecs = embed([b["text"] for b in blocks])
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
col = client.get_or_create_collection(
name=COLLECTION, metadata={"hnsw:space": "cosine"})
# 先删同 source 旧数据 → 实现增量更新(改一页不用重建整库)
old = col.get(where={"source": src_name})["ids"]
if old:
col.delete(ids=old)
print(f"🗑️ 已清除该来源的旧数据 {len(old)} 条")
col.add(
ids=[f"{src_name}-{i:04d}" for i, b in enumerate(blocks)],
documents=[b["text"] for b in blocks],
embeddings=[v.tolist() for v in vecs],
metadatas=[{"source": b["source"], "chapter": b["chapter"],
"device": b["device"], "part": b["part"]} for b in blocks],
)
print(f"✅ 灌库完成,当前库内共 {col.count()} 条,落盘于 {CHROMA_DIR.resolve()}")
if __name__ == "__main__":
main()
运行:
python -m kb.build_index
✅ 读取手册:data/cleaned/manual_a3.clean.md(1082 字符)
📦 共切出 6 个块,平均 214 字
⏳ 正在向量化...
✅ 灌库完成,当前库内共 6 条
落盘位置:E:\...\fde-ai\chroma_db
实操步骤 7:检索封装
kb/retriever.py:
"""检索封装:问题 → 相关块(带距离与元数据)"""
from pathlib import Path
import chromadb
from .embedder import embed_one
CHROMA_DIR = Path("./chroma_db")
COLLECTION = "manual"
# 经验阈值:cosine 距离大于这个值就认为"不相关",触发拒答
# (距离 = 1 - 余弦相似度,所以 0.5 对应相似度 0.5;相关片段通常 < 0.4)
MAX_DISTANCE = 0.55
def _collection():
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
return client.get_or_create_collection(name=COLLECTION,
metadata={"hnsw:space": "cosine"})
def retrieve(question: str, top_k: int = 4, device: str | None = None) -> list[dict]:
"""检索相关块;device 不为空时按设备过滤"""
col = _collection()
qv = embed_one(question)
kwargs = {"query_embeddings": [qv.tolist()], "n_results": top_k,
"include": ["documents", "metadatas", "distances"]}
if device:
kwargs["where"] = {"device": device}
res = col.query(**kwargs)
hits = []
for i, doc in enumerate(res["documents"][0]):
dist = float(res["distances"][0][i])
if dist > MAX_DISTANCE:
continue # 太远,视为不相关
md = res["metadatas"][0][i]
hits.append({
"id": res["ids"][0][i],
"text": doc,
"distance": dist,
"similarity": 1 - dist, # 换算回相似度,方便阅读
"source": md.get("source"),
"chapter": md.get("chapter"),
"device": md.get("device"),
})
return hits
def show(question: str, top_k: int = 4, device: str | None = None):
"""带打印的检索,方便调试观察"""
print(f"\n🔍 问题:{question}" + (f" (限定设备 {device})" if device else ""))
for h in retrieve(question, top_k, device):
print(f" 相似度 {h['similarity']:.3f} | {h['device']} | {h['chapter']}")
print(f" {h['text'][:70].replace(chr(10), ' ')}...")
先单独验证检索:
python -c "from kb.retriever import show; show('冷却水压力低于多少必须停机?')"
🔍 问题:冷却水压力低于多少必须停机?
相似度 0.812 | A3 | 4.3 冷却系统维护
【4.3 冷却系统维护】冷却水进水压力正常范围为 0.3~0.5MPa,低于 0.25MPa 时必须停机检查。...
相似度 0.634 | A3 | 4.3 冷却系统维护
【4.3 冷却系统维护】停机后应先关闭进水阀,挂牌上锁,再拆卸管路进行疏通。...
相似度 0.521 | A3 | 3.2 料筒超温处置
【3.2 料筒超温处置】A3 注塑机料筒设定温度 80℃,超过 85℃ 触发高温告警。...
📌 读结果:0.812 很相关、0.521 已偏弱(同主题边缘);排序是对的 (讲压力的第 1,讲超温的第 3);
chapter元数据带回来了,引用溯源有着落。
实操步骤 8:问答(检索 + 生成 + 引用)
kb/answer.py:
"""问答:检索 → 组装 Prompt → DeepSeek 生成 → 带引用输出"""
import os
from dotenv import load_dotenv
from openai import OpenAI
from .retriever import retrieve
load_dotenv()
client = OpenAI(api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com")
SYSTEM = """你是工厂设备运维助手,依据【参考资料】回答一线维修人员的问题。
【铁律】
1. 只使用【参考资料】中的信息,禁止使用资料外的知识作答
2. 每个结论后用 [n] 标注它来自哪条资料(n 为资料编号)
3. 资料中没有的内容,明确回答"资料中未提及",禁止推测和编造
4. 涉及安全的操作(断电、挂牌上锁、防护罩等)必须原样写出
5. 回答简洁,用编号步骤,每步不超过两句话
【输出格式】答案正文(带 [n])--- 参考来源:[n] 《来源文件》 章节名"""
def answer(question: str, top_k: int = 4, device: str | None = None) -> dict:
hits = retrieve(question, top_k=top_k, device=device)
if not hits:
return {"answer": "抱歉,现有手册中没有找到相关内容。"
"请补充资料或联系设备工程师。",
"sources": [], "grounded": False}
context = "\n\n".join(
f"[{i+1}] 来源:{h['source']} | 章节:{h['chapter']} | 设备:{h['device']}\n{h['text']}"
for i, h in enumerate(hits))
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"【参考资料】\n{context}\n\n【问题】{question}"},
],
temperature=0,
max_tokens=800,
)
return {
"answer": resp.choices[0].message.content,
"sources": [{"n": i + 1, "source": h["source"],
"chapter": h["chapter"], "score": round(h["similarity"], 3)}
for i, h in enumerate(hits)],
"grounded": True,
"tokens": resp.usage.total_tokens,
}
命令行入口 day65_ask.py:
"""Day65:手册问答命令行"""
import sys
from kb.answer import answer
if __name__ == "__main__":
question = " ".join(sys.argv[1:]) or "冷却水压力低于多少必须停机?"
r = answer(question)
print(f"\n问:{question}\n")
print(r["answer"])
print("\n------ 召回来源 ------")
for s in r["sources"]:
print(f" [{s['n']}] {s['source']} · {s['chapter']} 相似度 {s['score']}")
print(f"\n(消耗 {r.get('tokens', 0)} tokens, grounded={r['grounded']})")
python day65_ask.py "料筒超温怎么处理?"
问:料筒超温怎么处理?
1. 确认料筒温度是否持续超过 85℃(设定值 80℃)[1]
2. 检查冷却水管路是否结垢堵塞,冷却水压力是否低于 0.3MPa [1]
3. 检查固态继电器是否击穿导致加热圈持续通电 [1][3]
注意:料筒温度未降至 60℃ 以下时禁止打开防护罩 [2]
------ 召回来源 ------
[1] manual_a3.clean.md · 4.3 冷却系统维护 相似度 0.78
[2] manual_a3.clean.md · 3.2 料筒超温处置 相似度 0.71
[3] manual_a3.clean.md · 6.1 加热系统 相似度 0.63
(消耗 742 tokens, grounded=True)
回到 Day 61 的开场 :同样的问题,Day 61 时模型是凭空编一套流程 ;今天它照着手册答,还标出了每一条来自哪个章节。
📌 这就是"知识库问答 v0.2"。请把它和 Day 61 存档的那三份输出放在一起对比,这是你本周最有说服力的成果。
实操步骤 9:挂到 FastAPI(合进主线系统)
kb/api.py,与第二阶段的工单系统并列(Pydantic v2 + FastAPI 全部复用第二阶段所学):
"""把手册问答接进 FastAPI(v0.2 的接口)"""
from fastapi import FastAPI
from pydantic import BaseModel, Field
from .answer import answer
app = FastAPI(title="智能运维助手 v0.2", version="0.2.0")
class AskRequest(BaseModel):
question: str = Field(..., min_length=2, max_length=200,
examples=["A3 冷却水压力低于多少要停机?"])
device: str | None = Field(None, description="限定设备号,如 A3;None 表示全库检索")
top_k: int = Field(4, ge=1, le=10)
class Source(BaseModel):
n: int
source: str
chapter: str
score: float
class AskResponse(BaseModel):
question: str
answer: str
grounded: bool
sources: list[Source]
@app.post("/api/manual/ask", response_model=AskResponse, tags=["知识库问答"])
def ask_manual(req: AskRequest):
r = answer(req.question, top_k=req.top_k, device=req.device)
return AskResponse(question=req.question, answer=r["answer"],
grounded=r["grounded"],
sources=[Source(**s) for s in r["sources"]])
uvicorn kb.api:app --reload --port 8000
# 全库问答
curl -X POST http://localhost:8000/api/manual/ask -H "Content-Type: application/json" \
-d '{"question":"液压油多久换一次?"}'
# 限定设备(走 where 过滤)
curl -X POST http://localhost:8000/api/manual/ask -H "Content-Type: application/json" \
-d '{"question":"冷却水压力要求是多少?","device":"A3"}'
打开 High Performance Web Crawler API - Swagger UI 看自动生成的文档,接口自解释。
实操步骤 10:Docker 部署 Qdrant(面向生产)
Chroma 是嵌入式的,生产推荐 Qdrant。平台类工具一律 Docker 部署:
# 一条 docker run 起 Qdrant(6333=HTTP+Dashboard,6334=gRPC)
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 \
-v ${PWD}/qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
或写进 compose,与第二阶段的工单系统同栈编排:
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_storage:/qdrant/storage
restart: unless-stopped
volumes:
qdrant_storage:
docker compose up -d qdrant # 打开 http://localhost:6333/dashboard
💡 Qdrant 的 Python 客户端是
QdrantClient(host, port)+recreate_collection+upsert(points=[PointStruct(id, vector, payload)])+search(query_vector, limit)。看出规律了吗?collection / vector / payload(metadata) / id / top-k / 距离,概念跟 Chroma 一一对应。今天学的概念到 Qdrant 完全平移,第 10 周 Day 66 只是换个客户端。
📖 五、本周复盘:知识地图
| Day | 课时 | 核心知识点 | 产出 |
|---|---|---|---|
| 61 | 3.2.1 RAG 全景 | 三硬伤(截止/私域/幻觉);八步链路;不全塞的四条理由 | 最小 RAG 脚本 + FastAPI 接口 |
| 62 | 3.2.4 Embedding | 语义地图;余弦相似度;BGE-M3(1024 维/8192/三合一) | 相似度矩阵 + 向量版检索器 |
| 63 | 3.2.2 文档解析 | PDF 五坑;MinerU / Docling / PyMuPDF;表格七项质检 | full.md → 清洗脚本 → *.clean.md |
| 64 | 3.2.3 文本分块 | 三约束;固定/重叠/结构/语义 + 父子分块;上下文前缀 | 四策略对比实验 + 参数经验值 |
| 65 | 3.2.5 向量数据库 | ANN/HNSW;四选型;Chroma 持久化与 where 过滤 |
手册向量库 + 带引用问答 = v0.2 |
主线项目进展
第二阶段工单闭环 ──► v0.1(分类/提取/报告)──► v0.2(+ 知识库问答)
同一个 FastAPI 服务,能力一层层叠上去
curl /api/manual/ask ← 今天新加的接口
📋 周末验收清单
| # | 验收项 | 达标标准 | 自评 |
|---|---|---|---|
| 1 | 全局认知 | 能脱稿画出 RAG 八步链路并解释每步作用 | ☐ |
| 2 | Embedding | 能解释"为什么语义近的句子向量近",跑通 BGE-M3 | ☐ |
| 3 | 文档解析 | 真实 PDF 转出文本,表格不乱码(有质检记录) | ☐ |
| 4 | 分块 | 能用数据说明哪种分块更适合设备手册(有对比表) | ☐ |
| 5 | 向量库 | 提问能召回相关手册段落,相似度 > 0.6 | ☐ |
| 6 | 元数据 | 每条 chunk 带 source / chapter / device | ☐ |
| 7 | 引用溯源 | 答案带章节标注,能核对到原文 | ☐ |
| 8 | 拒答 | 问资料外的问题能触发"资料中未提及" | ☐ |
| 9 | 接口 | /api/manual/ask 可调用,/docs 有文档 |
☐ |
| 10 | 安全 | Key 在 .env,.gitignore 含 .env 与 chroma_db/ |
☐ |
📊 向量数据库速查表
Chroma API 速查
| 操作 | 代码 |
|---|---|
| 持久化客户端 | chromadb.PersistentClient(path="./chroma_db") |
| 建/取集合 | client.get_or_create_collection(name="manual", metadata={"hnsw:space":"cosine"}) |
| 灌数据 | col.add(ids=[...], documents=[...], embeddings=[...], metadatas=[...]) |
| 查询 | col.query(query_embeddings=[vec], n_results=5) |
| 带过滤查询 | col.query(..., where={"device": "A3"}) |
| 按 id 取 | col.get(ids=["c1"], include=["documents","metadatas"]) |
| 更新/插入 | col.upsert(...)(参数同 add) |
| 删除 | col.delete(ids=[...]) / col.delete(where={...}) |
| 条数 / 翻看 | col.count() / col.peek(limit=5) |
| 列/删集合 | client.list_collections() / client.delete_collection("manual") |
距离空间与阈值
hnsw:space |
含义 | 什么时候用 |
|---|---|---|
cosine |
余弦距离 = 1 - 余弦相似度 | 默认推荐,语义检索通用 |
l2 |
欧氏距离 | 关心绝对数值差(少见) |
ip |
内积距离 = -(内积) | 向量已归一化且追求速度 |
| 距离 | 相似度 | 判定 |
|---|---|---|
| 0.10 | 0.90 | 高度相关 ✅ |
| 0.25 | 0.75 | 相关 ✅ |
| 0.40 | 0.60 | 弱相关,要注意 ⚠️ |
| 0.55 | 0.45 | 不相关 ❌(建议设为拒答阈值) |
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
| 中文检索效果极差 | 用了 Chroma 默认嵌入模型(英文) | 自己传 BGE-M3 向量 |
| 重复跑脚本报"已存在" | 用了 create_collection |
用 get_or_create_collection |
| 检索结果全是同一段 | ids 重复被覆盖 | ids 必须唯一(source-序号) |
| 距离全是 0.99 左右 | 查询与库里向量不是同一模型出的 | 统一走 kb/embedder.py |
where 过滤没生效 |
字段名拼错 / 该条没这个字段 | col.peek() 看真实 metadata |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| 向量库两件事 | 存(向量+原文+元数据)+ 查(ANN 近似最近邻) |
| ANN / HNSW | 用索引图跳过绝大多数候选,O(log n),换来毫秒级 |
| 一条记录四件套 | id / embedding / document / metadata |
| metadata 价值 | 既用于过滤(只看 A3),也用于溯源(标 P47) |
| 选型 | Chroma 原型 / Qdrant 生产 / Milvus 大规模 / PGVector 已有 PG |
| 距离 vs 相似度 | Chroma 返回距离,越小越像;相似度 = 1 - 距离 |
| 拒答阈值 | 余弦距离 > 0.55 视为不相关,触发"资料中未提及" |
| 中文坑 | Chroma 默认模型是英文的,必须自己传 BGE-M3 向量 |
| 幂等建库 | get_or_create_collection 让脚本能反复跑 |
| 增量更新 | 按 source 先 delete 再 add,改一页不用重建整库 |
| 脚本分离 | 落库脚本重、查询脚本轻,绝不能混在一起跑 |
| v0.2 | 手册检索 + 带引用回答 + FastAPI 接口 |
🧠 核心认知 :向量数据库把"语义检索"从研究问题 变成了工程问题 ------你不再需要关心怎么算相似度,只需要关心三件事:存什么(document)、标注什么(metadata)、用什么模型编码(embedding) 。这三件事的质量,决定了你系统的天花板;而向量库本身,只是一个越来越成熟的、可以替换的零件。FDE 的价值不在于会不会用 Chroma(那只是几行 API),而在于知道该把什么存进去、标什么元数据、以及什么时候该换 Qdrant。
📋 课后练习
练习 1:扩充知识库并验证增量更新(约 45 分钟)
-
再准备一份手册(
manual_b2.pdf,或把示例手册复制改成 B2 的参数),走完 Day63 的解析与清洗,得到manual_b2.clean.md。 -
改
build_index.py支持批量灌入多份手册 (遍历data/cleaned/*.clean.md),灌进同一个 collection。 -
验证增量更新:把
manual_a3.clean.md里 0.25MPa 改成 0.28MPa,只重跑 A3 的灌库,确认 A3 更新了、B2 没被动过。 -
用
col.count()和col.peek()确认两个来源的数据都在。
练习 2:元数据过滤实战(约 40 分钟)
-
提问"冷却水压力要求是多少?",分别跑不带过滤 、
device="A3"、device="B2"三次,对比返回的章节与相似度,确认过滤生效。 -
给 metadata 加两个字段:
acl(可访问角色,如"设备部"/"全员")和doc_type("手册"/"工单"/"SOP"),重新灌库。 -
写一个
retrieve_by_role(question, role),内部用where={"acl": role}过滤------这是第 10 周权限过滤的雏形。 -
验证:用一个没有权限的角色提问,确认检索不到受限内容。
练习 3:端到端验收 + 本周成果归档(约 45 分钟)
-
用 Day 61 存档的三个"私有问题"重新问 v0.2,对比 Day 61(编造)与今天(有据 + 有引用)的输出,做成对比表存档。
-
按"周末验收清单"10 项逐条自评,把不达标的两项补做。
-
把
kb/、data/cleaned/、Day64 的分块报告整理到一个文件夹,写一份 100 字周总结。 -
思考题(写下来,第 10 周回来对照):现在有三个短板------① 设备型号专名检索会漏(Day62 观察到);② Top-3 里混着弱相关块;③ 引用只能标到章节,标不到页码。你觉得该怎么治?
🔭 下节预告
本周你搭出了一条能跑通的 RAG 链路。但"能跑通"和"生产可用"之间,还差第 10 周的整整一周。
先说清楚现在的三个短板:① 专名会漏 ------"TK-108"vs"TK-115"相似度只差 0.03,向量检索分不清精确编号;② 排序不精 ------Top-3 里混着相似度 0.5 的弱相关块,挤掉了真正该进来的;③ 溯源太粗------只能标到章节,标不到页码。
第 10 周(Day 66-70)逐个击破,主题是**「检索、重排与引用溯源 --- 让答案找得准、说得清出处」**:
| Day | 主题 | 做什么 |
|---|---|---|
| Day 66 | Qdrant 生产级向量库 | Docker 部署 Qdrant,把今天的 Chroma 库迁过去,对比两者的检索表现与运维差异 |
| Day 67 | BM25 + 混合检索 | 关键词检索补上向量检索的短板;用上 BGE-M3 的 sparse_vecs,让"A3""TK-108"这类专名精确命中 |
| Day 68 | Reranker 重排 | 用 BGE-Reranker 对 Top-20 做精排,把真正相关的顶到 Top-3,治"排序不精" |
| Day 69 | 引用溯源 + 权限过滤 | 答案精确到页码/行号并可点击回原文;用 metadata 实现"谁能看哪些 chunk" |
| Day 70 | RAGAS 评测 + 收官 | 建黄金问题集,跑 faithfulness / context precision 等指标,用数据证明你的改进有效 |
一句话:第 9 周让系统"能答",第 10 周让系统"答得准、说得清出处、还能量化证明"。
好好休息,明天进入生产级检索。本周收官快乐 🎉
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证