413 分钟

RAG 第一波 —— Embedding + 向量检索 + Chunk

理解 RAG 的最小闭环:Embedding 怎么把文字变向量、ChromaDB 怎么快速检索、Chunk 怎么切才不丢语义。

RAGEmbeddingChromaDB向量检索Python
进度保存在本机浏览器;验收通过后再点更稳妥

第 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 最小闭环

code
入库阶段(offline)              查询阶段(online)
─────────────────              ─────────────────
原始文档                         用户问题
  ↓ chunk(切片)                   ↓ embed
小块文本(500-1000字)             问题向量
  ↓ embed(生成向量)               ↓ 向量相似度搜索
1536维向量 + 原文                top-K 相关 chunks
  ↓ 入库                           ↓ 拼到 prompt
ChromaDB                        "基于以下资料回答:[chunks]\n问题:..."
                                   ↓ LLM
                                 回答
步骤课程版文件
chunkservices/ingestion_service.py
embedservices/embedding_service.py
入库/检索services/vector_store.py
检索编排services/knowledge_service.py

3. Embedding:把文字变成向量

直觉:Embedding = 把一段文字压缩成高维空间里的一个点。意思越接近,距离越近。

Code
"我饿了"        → [0.12, -0.45, 0.78, ...]   ← 语义相似
"我想吃东西"     → [0.15, -0.43, 0.81, ...]   ← 数值非常接近
"今天天气真好"   → [-0.67, 0.22, -0.11, ...]  ← 差很多

注意:这是语义层面的接近,不是字面。"我饿了"和"我想吃东西"字面完全不同,但向量很近。这就是它比关键词搜索强的地方。

实现:30行薄封装

python
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

三个关键点:

  1. 批量调用:一次几百条一起 embed,比一条一条快 100 倍。但上限 2048
  2. 顺序保证:OpenAI 保证返回顺序与输入一致——文档明写了才敢依赖
  3. 维度校验:模型升级可能返回不同维度,warning 是兜底

注意:致命陷阱:入库和查询必须用同一个 Embedding 模型。两个不同模型的向量空间不可比较,混合使用返回的是垃圾结果。如果换模型,必须全量重建。


4. Vector Store:向量怎么搜得快

问题:100万段文档,每段1536维向量。朴素做法——挨个算 cosine 相似度——太慢。

解法:向量索引(HNSW/IVF)。100万段文档,查询只算几百次相似度,单次查询不到 10ms,牺牲约 5% 精度。

课程版用 ChromaDB,175行封装

python
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/2vector_store 层统一做——上层只看到 score,换库只改这一个文件。


5. Chunk:为什么必须切片,怎么切才好

为什么不能整篇 embed:一个 1536 维向量表达不了一本书(信息压缩严重)、检索粒度太粗("请假流程"命中整本《员工手册》)、prompt 装不下。

三种策略对比

策略做法优点缺点
fixed每500字一刀最简单经常切断句子
overlap(默认)500字一刀,邻块重叠100字缓解切断数据冗余
semantic按 Markdown 标题 + 句子边界切语义最完整,保留 heading_path只对结构化文档有效

课程版自动选.md 文件用 semantic,其他用 overlap。

python
if chunk_mode is None:
    chunk_mode = "semantic" if source.lower().endswith(".md") else "overlap"

heading_path 是宝藏字段

code
heading_path = "员工手册 > 第三章请假 > 3.2 病假"
chunk_text = "病假需提供医院证明..."

它让 LLM 在 prompt 里看到语境、让 hybrid retrieval 可以按章节过滤、让引用展示时能溯源。

chunk_size 怎么定

chunk_size适合缺点
200-400字精准问答上下文太少
500-800字通用 RAG(推荐)平衡点
1200-2000字长文摘要检索粒度粗

6. 完整入库链路:6步闭环

python
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步

Code
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_idsha256(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:超限切分 + 为什么不在底层做

python
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) 完整流程

Code
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 立即回滚)。

生产数据迁移的铁律:永远要有"双轨期"——新旧并存、可灰度、可回滚。


本节要点

  1. RAG = 向量检索 + LLM 生成。各干各擅长的,比硬塞全部文档进 prompt 优雅一万倍
  2. Embedding 入库和查询必须同一个模型。换模型 = 全量重 embed + 双轨灰度——项目里最重的一类工程操作
  3. Chunk 不是"切就行"。semantic > overlap > fixed。结构化文档用 semantic 保留 heading_path
  4. score 转换放底层,换库只改一个文件——"封装变化点"是分层的工程价值
  5. 元数据是检索灵活性的基础——入库时存得越多,检索时越灵活。后悔 = 重新付 embedding 费