第 4 课:RAG 第一波 —— Embedding + 向量检索 + Chunk
本节目标:理解 RAG 为什么是 LLM 的第二条命脉、掌握 Embedding/向量检索/Chunk 三件套的工程实现、以及换 Embedding 模型的完整迁移流程。
学完本课后,推荐阅读 附04:切片策略深度 —— 五种切片策略对比、元数据的真正价值、Embedding 模型选型速查。
让 LLM 学会"查资料",是 Knowledge Agent 的第二条命脉。
1. 为什么必须 RAG:LLM 的两个根本缺陷
| 缺陷 | 场景 | 后果 |
|---|---|---|
| 知识有截止日期 | "2024 年公司年报说营收多少?" | 要么说不知道,要么幻觉编数字 |
| 不知道私有数据 | "公司请假流程是什么?" | 看不到你的 HR 文档、Wiki |
一个错误的解法:把所有文档塞进 prompt。
三个致命问题:token 爆炸(500万字塞不下)、token 烧钱(每次问都付全量)、大海捞针(LLM 在长上下文里精准定位能力弱)。
RAG = Retrieval-Augmented Generation:提前把文档存到向量库 → 用户问时搜出最相关的几段 → 只把这几段塞 prompt → LLM 基于它们回答。把"检索"交给向量库,把"生成"交给 LLM,各干各擅长的。
2. RAG 最小闭环
入库阶段(offline) 查询阶段(online)
───────────────── ─────────────────
原始文档 用户问题
↓ chunk(切片) ↓ embed
小块文本(500-1000字) 问题向量
↓ embed(生成向量) ↓ 向量相似度搜索
1536维向量 + 原文 top-K 相关 chunks
↓ 入库 ↓ 拼到 prompt
ChromaDB "基于以下资料回答:[chunks]\n问题:..."
↓ LLM
回答
| 步骤 | 课程版文件 |
|---|---|
| chunk | services/ingestion_service.py |
| embed | services/embedding_service.py |
| 入库/检索 | services/vector_store.py |
| 检索编排 | services/knowledge_service.py |
3. Embedding:把文字变成向量
直觉:Embedding = 把一段文字压缩成高维空间里的一个点。意思越接近,距离越近。
"我饿了" → [0.12, -0.45, 0.78, ...] ← 语义相似
"我想吃东西" → [0.15, -0.43, 0.81, ...] ← 数值非常接近
"今天天气真好" → [-0.67, 0.22, -0.11, ...] ← 差很多
注意:这是语义层面的接近,不是字面。"我饿了"和"我想吃东西"字面完全不同,但向量很近。这就是它比关键词搜索强的地方。
实现:30行薄封装
def embed_batch(self, texts: list[str]) -> list[list[float]]:
if len(texts) > _BATCH_LIMIT:
raise ValueError(f"超过批次上限 {_BATCH_LIMIT}")
resp = self._client.embeddings.create(
model=settings.embedding_model,
input=texts,
)
vectors = [d.embedding for d in resp.data]
# 维度校验
if any(len(v) != settings.embedding_dim for v in vectors):
logger.warning("embedding 维度不匹配!")
return vectors
三个关键点:
- 批量调用:一次几百条一起 embed,比一条一条快 100 倍。但上限 2048
- 顺序保证:OpenAI 保证返回顺序与输入一致——文档明写了才敢依赖
- 维度校验:模型升级可能返回不同维度,warning 是兜底
注意:致命陷阱:入库和查询必须用同一个 Embedding 模型。两个不同模型的向量空间不可比较,混合使用返回的是垃圾结果。如果换模型,必须全量重建。
4. Vector Store:向量怎么搜得快
问题:100万段文档,每段1536维向量。朴素做法——挨个算 cosine 相似度——太慢。
解法:向量索引(HNSW/IVF)。100万段文档,查询只算几百次相似度,单次查询不到 10ms,牺牲约 5% 精度。
课程版用 ChromaDB,175行封装:
class VectorStore:
def __init__(self, persist_dir=None, collection_name=None):
self._client = chromadb.PersistentClient(
path=str(persist_dir or settings.chroma_persist_dir),
)
self._collection = self._client.get_or_create_collection(
name=collection_name or settings.chroma_collection,
metadata={"hnsw:space": "cosine"}, # 用 cosine 距离
)
三个细节:PersistentClient 数据落盘重启不丢、collection_name 可按租户/业务域隔离、hnsw:space="cosine" 是文本 embedding 的标准选择。
4 个核心方法:upsert(入库)、search(查询)、delete_by_doc(按文档删)、count(统计)。
一个反直觉的细节:ChromaDB 返回 distance(越小越相似),但业务要 score(越大越相似)。转换
score = 1 - distance/2在vector_store层统一做——上层只看到 score,换库只改这一个文件。
5. Chunk:为什么必须切片,怎么切才好
为什么不能整篇 embed:一个 1536 维向量表达不了一本书(信息压缩严重)、检索粒度太粗("请假流程"命中整本《员工手册》)、prompt 装不下。
三种策略对比
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| fixed | 每500字一刀 | 最简单 | 经常切断句子 |
| overlap(默认) | 500字一刀,邻块重叠100字 | 缓解切断 | 数据冗余 |
| semantic | 按 Markdown 标题 + 句子边界切 | 语义最完整,保留 heading_path | 只对结构化文档有效 |
课程版自动选:.md 文件用 semantic,其他用 overlap。
if chunk_mode is None:
chunk_mode = "semantic" if source.lower().endswith(".md") else "overlap"
heading_path 是宝藏字段:
heading_path = "员工手册 > 第三章请假 > 3.2 病假"
chunk_text = "病假需提供医院证明..."
它让 LLM 在 prompt 里看到语境、让 hybrid retrieval 可以按章节过滤、让引用展示时能溯源。
chunk_size 怎么定
| chunk_size | 适合 | 缺点 |
|---|---|---|
| 200-400字 | 精准问答 | 上下文太少 |
| 500-800字 | 通用 RAG(推荐) | 平衡点 |
| 1200-2000字 | 长文摘要 | 检索粒度粗 |
6. 完整入库链路:6步闭环
def ingest_text(self, *, text, source, title=None, tags=None,
doc_id=None, chunk_mode=None):
# 1. 校验 + 决定 chunk_mode
if chunk_mode is None:
chunk_mode = "semantic" if source.endswith(".md") else "overlap"
# 2. 生成确定性 doc_id
doc_id = doc_id or _make_doc_id(text, source) # sha256(source+text)[:16]
# 3. 切片
pieces = chunk_pieces(text, size=settings.chunk_size,
overlap=settings.chunk_overlap, mode=chunk_mode)
# 4. 批量 embed
embeddings = embedding_service.embed_batch([p["text"] for p in pieces])
# 5. 构造元数据(宝藏!)
metadatas = [{
"doc_id": doc_id, "chunk_index": i,
"source": source, "title": title or source,
"heading_path": p["heading_path"],
"tags": ",".join(tags) if tags else "",
"chunk_chars": len(p["text"]),
"language": _detect_lang(p["text"]),
} for i, p in enumerate(pieces)]
# 6. upsert 到 ChromaDB
vector_store.upsert(ids=ids, embeddings=embeddings,
documents=texts, metadatas=metadatas)
元数据是检索灵活性的基础:source 过滤来源、heading_path 给 LLM 看语境、language 中英文分库、tags 业务标签筛选。入库时存得越多,检索时灵活性越大。后悔 = 重新入库 = 重新付 embedding 费。
7. 查询链路:3步
def search(self, query, *, top_k=5, where=None):
qv = embedding_service.embed(query) # 问题 → 向量
hits = vector_store.search(qv, top_k=top_k, where=where) # 向量 → chunks
logger.info("[search] hits=%d top_score=%.4f", len(hits),
hits[0]["score"] if hits else 0.0)
return hits
入库和查询是同一个
embedding_service、同一个模型。这是 RAG 不能出错的承诺。
8. 纯向量检索的天花板(下一课伏笔)
| 翻车场景 | 例子 | 原因 |
|---|---|---|
| 精确字符串匹配 | "产品代号 X9-2024A 的售价" | embedding 难区分 X9-2024A vs X9-2024B |
| 长尾低频术语 | "QHSE 合规要求" | 训练语料中这个缩写很少出现 |
| 用户表述差 | "销假的规定" vs 文档写"复职手续" | 口语和书面语的语义鸿沟 |
解法(下一课):hybrid retrieval(向量+关键词双路召回+RRF融合)、query rewrite(同义改写)、HyDE(让LLM先编假设答案用它的向量去检索)。
9. 自测:5个问题
问题 1:_make_doc_id 用 sha256(source+text)[:16] 生成确定性 ID,为什么不直接用 uuid.uuid4()?
问题 2:embed_batch 上限 2048,要入库 5000 段文本,怎么改?为什么不在 embedding_service 内部自动切分?
问题 3:为什么 score 转换(1-distance/2)放在 vector_store 层而不是上层自己做?
问题 4:入库时双写 ChromaDB + FTS(SQLite 全文索引),FTS 失败为什么不阻塞向量入库?这种"双写"模式有什么风险?
问题 5(最重要):要换新的 embedding 模型(3072维),全公司 10 万篇文档。a) 为什么不能只改配置?b) 完整迁移流程?c) 迁移期间用户怎么不受影响?
答案与解析
问题 1:sha256 确定性 ID vs UUID
如果用 uuid.uuid4():同一篇《员工手册》入库2次 → 2个不同 doc_id → 向量库存了2份完全相同的内容。后果:数据膨胀、搜索重复、删除找不全。
sha256 的妙处:输入相同 → doc_id 必然相同。配合 upsert,重复入库直接覆盖,零副本。这是幂等操作的精髓——"重复执行不产生副作用"。
陷阱:文章内容变了 → doc_id 变 → 老的不会自动删。生产要配合 source → doc_id 映射表,检测到变化先 delete_by_doc 再 ingest。
问题 2:超限切分 + 为什么不在底层做
def _embed_in_chunks(texts, batch_size=2000):
out = []
for i in range(0, len(texts), batch_size):
out.extend(embedding_service.embed_batch(texts[i:i+batch_size]))
return out
为什么不在 embedding_service 内部切分:① embedding_service 是协议级封装,1:1 反映 API 约束,内部切分会让调用方不知道实际发了几次请求;② 切分策略是业务决策——串行/并行/限速/断点续传,不同场景不同策略;③ 5批里第3批失败,调用方可以精准重试。
底层封装只承诺协议契约。业务级的批次切分、并发控制、重试策略在调用方做。
问题 3:score 转换为什么放在底层
4 个好处:① DRY——10个调用方不用各写一遍换算;② 换库只改一个文件——ChromaDB/Pinecone/Qdrant 的距离定义各不相同,上层完全无感;③ 抽象层级匹配——上层关心"相关性分数"这个业务概念,不关心底层 distance 是 cosine 还是 L2;④ 错误归因清晰——检索不准时问题锁定在距离本身。
底层封装的"译码器职责":把不同实现的 API 差异翻译成上层统一的业务语义。
问题 4:FTS 双写的容错与风险
a) FTS 失败不阻塞——主副清晰:向量库是主(没它不能用),FTS 是副(缺它退化到纯向量检索)。软策略(性能增强)fail-open,硬安全(权限审计)fail-stop。非关键依赖拖死关键路径是生产系统最忌的事。
b) 三大风险:
| 风险 | 现象 | 解法 |
|---|---|---|
| 数据不一致 | 向量库有、FTS 没有(或反过来) | 周期性对账 reconciliation |
| 原子性缺失 | 100个chunk写一半FTS挂了 | 异步队列保证最终一致 |
| 顺序问题 | 先FTS后向量→FTS有残留→幽灵命中 | 先向量后FTS,用不到的残留丢弃 |
双写永远不安全。能容忍最终一致就接受不一致+加对账;必须强一致就找单数据源。
问题 5:Embedding 模型迁移
a) 不能只改配置:维度不同(1536 vs 3072 → ChromaDB 建 collection 时锁定维度,直接拒绝)、向量空间不可比较(两个模型的坐标系毫无对应关系)、质量可能倒退(新模型对特定领域未必更好)。
b) 完整流程:
Step 1: 双轨准备 → 新建 docs_v2 collection(新维度),老 docs 不动
Step 2: 离线全量重 embed → 一次性脚本跑 10万篇,做断点续传,监控成本
Step 3: 数据验证 → golden set 评测,V2 召回率必须 ≥ V1,倒退禁止上线
Step 4: 灰度发布 → feature flag 按用户 hash 分组,1%→10%→30%→50%→100%,每阶段盯监控
Step 5: 下线老版 → 100% 稳定一周后删 docs 回收空间
c) 用户不受影响:双写(新增文档同时写 v1+v2)、feature flag 控制查询路径(可一键回滚)、按用户分组灰度(同一用户不跳变)、双轨监控(任何一项 v2 差于 v1 立即回滚)。
生产数据迁移的铁律:永远要有"双轨期"——新旧并存、可灰度、可回滚。
本节要点
- RAG = 向量检索 + LLM 生成。各干各擅长的,比硬塞全部文档进 prompt 优雅一万倍
- Embedding 入库和查询必须同一个模型。换模型 = 全量重 embed + 双轨灰度——项目里最重的一类工程操作
- Chunk 不是"切就行"。semantic > overlap > fixed。结构化文档用 semantic 保留 heading_path
- score 转换放底层,换库只改一个文件——"封装变化点"是分层的工程价值
- 元数据是检索灵活性的基础——入库时存得越多,检索时越灵活。后悔 = 重新付 embedding 费