Appearance
Day24 向量数据库 & Embedding 向量嵌入
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. 全景:向量检索其实是两条链路,不是一条
Day23 解决的是「怎么把话说给模型听」,Day24 解决的是「让模型有料可说」。初学最容易把它想成一个流程,实际生产里是离线链路和在线链路两条,节奏、SLA、失败处理完全不同。
text
【离线链路】(分钟~小时级,可重跑,追求吞吐)
原始文档(PDF/Word/HTML/数据库)
│ ① 解析:抽文本 + 保留结构(标题层级、表格、代码块)
▼
清洗后的纯文本(去页眉页脚、去水印、修乱码)
│ ② 切分 Chunk:按结构 → 段落 → 句子 逐级降级,带 overlap
▼
chunk 列表(每条都带 metadata:doc_id / 标题路径 / 页码 / 时间 / 权限标签)
│ ③ Embedding:批量调用模型(passage 模式),L2 归一化
▼
向量 + 原文 + metadata
│ ④ 写入向量库 → 建索引(HNSW / IVF)→ load 到内存
▼
可被检索的 Collection
【在线链路】(毫秒级,不可重跑,追求 P99 延迟 + 召回率)
用户问题
│ ① 查询改写(可选):指代消解、多路 query 扩展
▼
query 文本
│ ② Embedding(query 模式,必须同一个模型、同一个归一化)
▼
query 向量
│ ③ ANN 检索 TopK(+ metadata 过滤:租户、时效、文档类型)
│ ↕ 可并行一路 BM25 关键词检索
▼
候选片段 50 条
│ ④ RRF 融合 → Rerank 重排(cross-encoder)→ 截断到 TopK=3~5
▼
拼进 Prompt 的上下文 → 交给 Day23 的 LLMClient三条必须先立住的边界认知:
| 常见误解 | 实际结论 |
|---|---|
| 「向量检索是精确检索」 | 生产用的是 ANN(近似最近邻),天生牺牲一点召回率换几个数量级的速度。精确检索叫 Flat/暴力检索,只在小数据量或做评测基线时用 |
| 「向量库能替代数据库」 | 向量库没有事务、没有 join、更新语义弱。它是「检索索引」,不是事实源(source of truth)。原始数据必须另存一份在 MySQL/对象存储里,向量库要能随时全量重建 |
| 「检索不准就换更强的模型 / 开更大的窗口」 | 90% 的检索不准来自切分和召回策略,不是 Embedding 模型不够强。Day23 第 11 节的 lost-in-the-middle 已经说明:塞得多 ≠ 答得准 |
2. Embedding 到底是什么:把语义压成一个坐标
Embedding 模型(编码器)把一段文本映射成一个固定长度的浮点数组,让语义相近的文本在高维空间里距离更近。它和 Day23 的生成模型是两类东西:
| 维度 | Embedding 模型 | 生成模型(LLM) |
|---|---|---|
| 典型结构 | encoder-only(BERT 系)或专门微调的 encoder | decoder-only,自回归 |
| 输出 | 一个定长向量(如 1024 维) | 一串 token |
| 一次调用成本 | 极低(通常是 LLM 的 1/50~1/100) | 高 |
| 关键能力 | 语义压缩、相似度可比 | 推理、生成、遵循指令 |
| 典型用法 | 检索召回、聚类、去重、分类特征 | 回答、总结、抽取 |
⚠️ 不要用 LLM 去「两两判断相似度」做检索。10 万条文档就是 10 万次调用,成本和延迟都不可接受。正确分工是:Embedding 负责从海量里捞出候选(召回),Rerank 模型负责精排,LLM 只负责对最终几条做理解和生成。
向量为什么能表达语义,一句话版本:训练时用对比学习(contrastive learning)把「问题-正确答案」这类正样本对拉近、把随机负样本推远,训练完的空间里,距离就等价于语义相关度。
句向量是怎么从 token 向量变出来的(面试会问「池化」):
| 池化方式 | 做法 | 说明 |
|---|---|---|
| CLS pooling | 取 [CLS] 位置的隐状态 | BERT 系、bge 系常用 |
| Mean pooling | 所有 token 隐状态求平均 | 简单稳健,sentence-transformers 默认 |
| Last token | 取最后一个 token | decoder 结构的 embedding 模型(如部分 Qwen-Embedding)用 |
记住结论:池化方式由模型决定,不能自己换。自己改池化会让向量空间失配,检索效果断崖下跌。
3. 向量的三个工程属性:维度、归一化、精度
① 维度决定表达能力和成本,不是越大越好:
| 维度 | 代表模型 | 单条存储(float32) | 说明 |
|---|---|---|---|
| 384 | bge-small-zh | 1.5 KB | 轻量,适合边缘/海量低成本场景 |
| 768 | bge-base-zh、m3e-base | 3 KB | 性价比主力 |
| 1024 | bge-large-zh、bge-m3 | 4 KB | 中文检索常用上限 |
| 1536 / 3072 | text-embedding-3-small / large | 6 KB / 12 KB | 支持 MRL 降维截断 |
存储体积要会现场估算(面试高频):
text
原始向量体积 = 条数 × 维度 × 4 字节(float32)
例:1000 万条 × 1024 维 × 4B ≈ 40 GB
HNSW 还要额外加图结构:≈ 条数 × M × 2 × 4 字节
例:1000 万 × 16 × 2 × 4B ≈ 1.2 GB(M=16)
再加上原文、metadata、索引副本,实际内存要按 原始体积 × 1.5~2 预留② L2 归一化(把向量缩放成长度 1)是工程默认动作:
python
import numpy as np
def l2_normalize(v: np.ndarray) -> np.ndarray:
norm = np.linalg.norm(v, axis=-1, keepdims=True)
# ✅ 零向量防护:空文本/全停用词可能产出近似零向量,不处理会得到 nan
return v / np.clip(norm, 1e-12, None)归一化之后有一个非常重要的等价关系(下一节详述):余弦相似度 == 内积,而且欧氏距离的排序和余弦完全一致。所以生产里统一「入库前归一化 + 用内积(IP)度量」,既快又省。
③ 精度 / 量化:float32 不是必须的。
| 精度 | 体积 | 召回损失 | 适用 |
|---|---|---|---|
| float32 | 100% | 0 | 基线、小数据量 |
| float16 | 50% | 可忽略 | 通用优化 |
| int8(SQ8) | 25% | 1~3% | 大规模主流选择 |
| PQ 乘积量化 | 5%~10% | 5%~15% | 超大规模,必须配 rerank 补偿 |
| binary(1bit) | 3% | 较大 | 粗筛 + 原向量精排的两段式 |
4. 相似度度量:先纠正一个常见的错误说法
草稿式说法「向量检索优先用余弦相似度」不够准确,准确结论是:度量方式必须和 Embedding 模型训练时用的那个一致,而主流检索模型训练时就是按余弦/内积优化的,所以才「看起来总是余弦」。
| 度量 | 公式 | 范围 | 方向 |
|---|---|---|---|
| 余弦相似度 COSINE | A·B / (‖A‖‖B‖) | -1~1(实际常在 0~1) | 越大越相似 |
| 内积 IP | A·B | 无界 | 越大越相似 |
| 欧氏距离 L2 | ‖A-B‖ | 0~∞ | 越小越相似 |
text
关键恒等式(向量已 L2 归一化,即 ‖A‖=‖B‖=1):
cos(A,B) = A·B → 余弦 ≡ 内积
‖A-B‖² = 2 - 2·A·B = 2 - 2·cos(A,B) → L2 与余弦严格单调反相关
结论:归一化后,用 COSINE / IP / L2 检索得到的 TopK 【顺序完全相同】
不归一化时,IP 会被「长向量」带偏(长文本模长大,容易霸榜)| 工程规矩 | 原因 |
|---|---|
| 入库和查询用同一个模型、同一个版本 | 不同模型的向量空间不可比,混在一起检索结果毫无意义 |
| 入库和查询用同一种度量 | 建索引时度量就定下来了(Milvus 的 metric_type),改度量必须重建索引 |
| 分数方向要搞清楚 | Milvus 返回的字段叫 distance:L2 越小越好、IP/COSINE 越大越好。写排序和阈值时方向搞反,等于把最不相关的排到最前 |
| 不要跨模型比较分数绝对值 | A 模型 0.75 和 B 模型 0.85 没有可比性,阈值必须按模型重新标定 |
5. Embedding 模型选型与「非对称检索」的指令前缀坑
| 模型 | 维度 | 特点 | 适用 |
|---|---|---|---|
bge-large-zh-v1.5 | 1024 | 中文检索老牌强基线,开源可私有化 | 纯中文知识库 |
bge-m3 | 1024 | 多语言 + 同时产出 dense / sparse / colbert 三种表示,天然支持混合检索 | 中英混排、要做混合检索 |
text-embedding-3-small/large | 1536 / 3072 | 托管、省事,支持 MRL 降维 | 云上快速起步 |
Qwen3-Embedding 系 | 可选多档 | 中文强、支持指令定制 | 国内生产 |
bge-reranker-v2-m3 | —— | 不是 embedding,是 cross-encoder 重排模型 | 精排阶段 |
选型只看四个硬指标:中文效果(C-MTEB 榜单 + 自己业务回归集实测)、max_seq_len(512 还是 8192)、是否能私有化、单价/吞吐。
⚠️ 最隐蔽的坑:
max_seq_len截断是静默的。 很多中文模型只有 512 token,你把 2000 字的 chunk 丢进去,它不会报错,只是默默把后面全部丢掉——向量只代表了开头那段。所以 chunk 大小必须和模型窗口对齐,并且在入库前做一次「超长 chunk 告警」。
非对称检索(asymmetric retrieval)与指令前缀,这是实际使用开源模型时最容易漏掉、且直接掉 5~10 个点召回的细节:
| 概念 | 说明 |
|---|---|
| 对称检索 | query 和候选是同类文本(句子找相似句子、文档去重),两边编码方式相同 |
| 非对称检索 | query 是短问题,候选是长文档片段,两者分布差别很大。RAG 属于这一类 |
python
# ❌ 错误:bge 系做 RAG 检索时,query 不加指令前缀,白扔召回率
q_vec = model.encode("FastAPI 怎么做依赖注入")
d_vec = model.encode(chunk_text)
# ✅ 正确:query 加检索指令,passage 不加(bge-zh 系列的官方约定)
INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
q_vec = model.encode(INSTRUCTION + "FastAPI 怎么做依赖注入")
d_vec = model.encode(chunk_text) # 文档侧保持原样
# e5 系列则是两边都加、但前缀不同
# q_vec = model.encode("query: " + question)
# d_vec = model.encode("passage: " + chunk_text)| 规矩 | 说明 |
|---|---|
| 前缀写法以模型卡为准 | bge-zh 只给 query 加、bge-m3 不需要加、e5 两边都加,没有统一约定,照抄别的模型必错 |
| 前缀必须固化进代码常量 | 一旦线上入库用的是无前缀、查询用的是有前缀的不一致组合,排查起来极其隐蔽 |
| 换前缀 = 换空间 | 文档侧前缀变了就得全量重建,和换模型同等级别 |
6. 文本切分:决定检索质量的 50%
| 策略 | 做法 | 优缺点 |
|---|---|---|
| 固定长度 | 每 N 字符硬切 | 实现最简单;会把句子、表格、代码切烂 |
| 递归字符切分 | 按 \n\n → \n → 。 → 逐级降级,直到满足长度 | 工程默认选择,兼顾结构和长度 |
| 按结构切分 | 按 Markdown 标题 / HTML 标签 / PDF 章节 | 语义最完整,前提是文档结构规整 |
| 语义切分 | 相邻句子算 embedding 相似度,在「语义断点」处切 | 效果好,但要额外跑一遍 embedding,成本高 |
| 父子块(small-to-big) | 小块用于检索、命中后返回其所属大块给 LLM | 精度和上下文完整性兼得,强烈推荐 |
长度到底怎么定:
text
chunk_size 的选择不是拍脑袋,受三个约束同时夹住:
① Embedding 模型窗口:chunk 的 token 数必须 < max_seq_len(512 的模型别写 1000 字符以上)
② LLM 上下文预算(Day23 第 5 节):TopK × chunk_size 要装得进去还留足输出位置
③ 语义完整性:一个 chunk 最好只讲一件事
中文经验值:
FAQ / 短文档 → 200~400 字符,overlap 0~50
通用文档(默认) → 400~800 字符,overlap 10%~15%
法律 / 论文长推理 → 800~1500 字符 + 父子块
注意:这里说的是「字符」,而模型按 token 算,中文 1 字 ≈ 0.6~1.5 token(Day23 第 2 节),
务必用真实 tokenizer 复核,别只按字符数估。overlap(重叠)的作用与代价:
| 正面 | 负面 |
|---|---|
| 避免一句完整语义被切到两个 chunk,交界处信息不丢 | overlap 10% 就意味着存储、embedding 成本、检索去重压力都增加 10% |
| 提升边界处的召回率 | 过大(>30%)会导致 TopK 里出现大量内容重复的片段,挤占 LLM 的有效上下文 |
切分必须守住的四条:
| 规则 | 原因 |
|---|---|
| 表格、代码块、列表不能被切断 | 半张表、半个函数喂给 LLM,比不给更容易诱发幻觉 |
| 每个 chunk 带上标题路径 | 单独一段「它支持三种模式」没有主语,拼上 FastAPI > 依赖注入 > 作用域 才可检索、可引用 |
保留 doc_id、页码、版本、权限标签 | 答案要能溯源(Day23 安全小节的「可追溯」要求),也用于过滤和删除 |
| 切完要抽样人工看 20 条 | 这是性价比最高的质量动作,比调参有用得多 |
7. 向量数据库的核心概念(以 Milvus 为参照系)
| 概念 | 对应关系型数据库 | 要点 |
|---|---|---|
| Collection | 表 | 建表时就要定 schema、维度、度量类型 |
| Schema / Field | 列 | 一个主键 + 一个(或多个)向量字段 + 若干标量字段(metadata) |
| Partition | 分区 | 按租户/时间分区,检索时指定分区可大幅减少扫描量 |
| Index | 索引 | 向量索引是按字段建的,度量类型绑定在索引上 |
| Segment | 数据块 | 写入先进 growing segment,flush 后变 sealed,再建索引 |
load / release | 无对应 | 向量检索要求数据加载进内存才能查,这是向量库最反直觉的一点 |
| Entity | 行 | 一条 = 主键 + 向量 + metadata |
一次检索请求的参数:
| 参数 | 含义 | 坑 |
|---|---|---|
data | query 向量(可多条,批量检索) | 维度必须和 collection 一致,否则直接报错 |
limit / TopK | 返回条数 | 给 LLM 的最终条数建议 3~5;但召回阶段要多取(50),留给 rerank |
filter / expr | 标量过滤表达式 | 多租户场景必须强制注入 tenant_id ==,不能靠业务自觉 |
output_fields | 要带回的标量字段 | 不指定就只返回 id 和 distance,还得回查一次 |
search_params | nprobe / ef 等索引参数 | 这是召回率与延迟的调节旋钮,详见下节 |
consistency_level | 一致性级别 | 刚写完立刻查不到的根因,详见第 12 节 |
8. ANN 索引原理:Flat / IVF / HNSW 必须讲得出区别
暴力检索(Flat)的复杂度是 O(N×D):1000 万条 1024 维,一次查询要做 100 亿次乘加——单机几秒级,完全不可用。所以要用 ANN 索引,用一点召回率换几个数量级的速度。
text
【IVF:先聚类,再只搜几个桶】
┌──── 聚类中心 C1 ──── [v, v, v, v ...]
建索引:kmeans 把 ├──── 聚类中心 C2 ──── [v, v, v ...]
全部向量分成 ├──── 聚类中心 C3 ──── [v, v ...] ← nlist 个桶
nlist 个桶 └──── ... Cn ──── [...]
查询:① query 先和 n 个聚类中心比,挑最近的 nprobe 个桶
② 只在这 nprobe 个桶内部做暴力比对
扫描量 ≈ N × (nprobe / nlist) nprobe↑ → 更准更慢
致命边界:如果正确答案恰好落在没被选中的桶里 → 永远召回不到(边界丢失)
【HNSW:多层跳表式的邻居图】
第2层(稀疏) A ─────────────── F ← 远距离「高速公路」,快速逼近
│ │
第1层 A ─── C ───── E ── F
│ │ │ │
第0层(全量) A─B─C─D─E─F─G─H─I─J... ← 每点与最近的 M 个邻居相连
查询:从顶层入口点出发 → 贪心走向离 query 更近的邻居 → 到底层后在 ef 大小的
候选队列里精修 → 返回 TopK
复杂度 ≈ O(log N),召回率通常 >95%| 索引 | 召回率 | 查询延迟 | 内存 | 建索引耗时 | 适用 |
|---|---|---|---|---|---|
FLAT | 100% | 最慢 | 最小(只存向量) | 0 | <10 万条;做召回率评测基线 |
IVF_FLAT | 高(看 nprobe) | 中 | 中 | 中 | 百万级,内存有限 |
IVF_SQ8 | 略降 | 快 | 低(1/4) | 中 | 千万级省内存 |
IVF_PQ | 明显降 | 很快 | 极低 | 较长 | 亿级,必须配 rerank |
HNSW | 最高 | 最快 | 高(图结构额外占用) | 长 | 主流生产首选 |
DiskANN | 高 | 中 | 低(走 SSD) | 长 | 数据量远超内存时 |
关键调参对照表(面试必问「怎么权衡」):
| 参数 | 所属 | 调大的影响 |
|---|---|---|
nlist | IVF 建索引 | 桶更细、单桶更小 → 更快但更容易漏。经验值 ≈ 4 × √N |
nprobe | IVF 查询 | 扫更多桶 → 召回↑、延迟↑。在线可调,不用重建索引 |
M | HNSW 建索引 | 每个点的邻居数,召回↑、内存↑、建索引变慢。常用 16~32 |
efConstruction | HNSW 建索引 | 建图时的候选队列,图质量↑、建索引耗时↑。常用 100~500 |
ef | HNSW 查询 | 查询候选队列,召回↑、延迟↑。必须 ≥ TopK,在线可调 |
记牢这句:
nprobe和ef是查询期参数,可以线上灰度调;nlist、M、efConstruction是建索引期参数,改了必须重建索引。面试被问「线上召回不够怎么快速救」,答案是先调ef/nprobe和召回条数,而不是重建索引。
9. 主流向量库选型:比「哪个最强」更重要的是「什么时候不用上 Milvus」
| 方案 | 形态 | 优势 | 短板 | 适用 |
|---|---|---|---|---|
| Milvus / Zilliz | 分布式服务(存算分离,依赖 etcd + 对象存储 + 消息队列) | 工业级,亿级规模、多索引、分区、多租户、横向扩展 | 部署运维重,小项目性价比低 | 生产大规模首选 |
| Chroma | 嵌入式 / 轻量服务 | pip install 就能跑,API 极简 | 规模和并发有限,运维能力弱 | 本地开发、Demo、小知识库 |
| FAISS | 库,不是数据库 | Meta 出品,索引算法最全、单机性能极强 | 没有服务化、没有持久化语义、没有过滤和增删 | 离线评测、自建检索内核的底座 |
| Qdrant | 服务(Rust) | 过滤能力强(payload 索引做得好)、单机性能好、部署比 Milvus 轻 | 生态比 Milvus 小 | 中等规模、重过滤场景 |
| pgvector | PostgreSQL 扩展 | 向量和业务数据同库,能 join、有事务、运维零新增 | 千万级以上吃力,索引能力弱于专用库 | 百万级以内最务实的选择 |
| Elasticsearch / OpenSearch | 搜索引擎 | 天然同时有 BM25 和 向量(kNN),混合检索开箱即用 | 向量性能和内存效率不如专用库 | 已有 ES 的团队做混合检索 |
选型决策路径:
text
数据量 < 100 万,且已有 PostgreSQL ?
└─ 是 → pgvector(最省事,还能 join 业务表 + 事务保证)
数据量 < 10 万,只是本地开发/Demo ?
└─ 是 → Chroma
已经重度使用 Elasticsearch,且强需求关键词 + 语义混合 ?
└─ 是 → ES kNN
数据量千万~亿级,要分区、多租户、横向扩容、高并发 ?
└─ 是 → Milvus(有运维人力的前提下)
只是离线跑实验、算召回率基线 ?
└─ 是 → FAISS面试加分表述:「我们一期用 pgvector,因为知识库只有 30 万 chunk,和业务库同库能直接 join 权限表、省掉一致性问题;二期文档涨到千万级、要做多租户物理隔离才迁到 Milvus。先不上重组件,是因为向量库的真实瓶颈在召回策略,不在引擎性能。」
10. 元数据过滤:pre-filter 与 post-filter 的区别会直接决定对错
向量检索很少是「裸检索」,几乎总要带条件:只查这个租户、只查近一年、只查「公开」等级的文档。
text
【post-filter(先检索后过滤)】❌ 容易出错
① ANN 取 TopK=5
② 再按 tenant_id / 时间 过滤
→ 如果 5 条里只有 1 条满足条件,最终就只返回 1 条
→ 极端情况:全被过滤掉,返回空,用户看到「没有找到相关内容」
【pre-filter(过滤融入检索)】✅ 正确做法
① 先用标量索引算出满足条件的候选集(bitmap)
② ANN 遍历图/桶时,只在候选集内部挑邻居
→ 保证返回的 TopK 条数足够且都满足条件| 要点 | 说明 |
|---|---|
Milvus / Qdrant 的 filter / expr 是 pre-filter | 所以要用库原生过滤,不要「查完在 Python 里 filter」 |
| 过滤字段要建标量索引 | 否则过滤变成全表扫,延迟反而比不过滤更差 |
| 过滤条件选择性太高时反而会变慢 | 候选集极小时图连通性被破坏,HNSW 可能退化成近似暴力。此时考虑按该字段分区而不是过滤 |
| 多租户必须在封装层强制注入 | 不能靠调用方传。漏一个 tenant_id 就是跨租户数据泄露——这是向量库最高危的安全事故 |
三种多租户隔离方案:
| 方案 | 隔离强度 | 成本 | 适用 |
|---|---|---|---|
同 collection + tenant_id 过滤 | 弱(靠代码) | 最低 | 租户多、数据少(SaaS 长尾) |
| 同 collection + partition | 中 | 低 | 租户数量可控(几十~几千) |
| 每租户独立 collection | 强(物理) | 高(每个 collection 都要占内存) | 大客户、强合规要求 |
11. 检索质量:TopK、阈值、混合检索、Rerank
① TopK 不是一个数,是两个数:
text
召回阶段 recall_k = 50 ~ 100 ← 宁可多捞,别漏
↓ RRF 融合(多路召回)
重排阶段 rerank → 取 top_n = 3 ~ 5 ← 真正进 Prompt 的② score 阈值过滤要做,但别迷信绝对值:
| 做法 | 说明 |
|---|---|
| 设绝对阈值(如 cosine < 0.3 丢弃) | 必须按模型+业务数据实测标定,不同模型分数分布差异极大 |
| 用相对阈值 | 保留 score > top1_score × 0.8 的,对分布漂移更鲁棒 |
| 全部低于阈值时明确拒答 | 直接回「知识库中未找到相关内容」,比让 LLM 硬编一个答案好得多(Day23 幻觉治理) |
③ 混合检索(Hybrid Search):向量擅长语义、BM25 擅长精确词(产品型号 X-7200、报错码 ECONNRESET、人名),这两类查询的失败模式正好互补。
| 查询 | 纯向量 | 纯 BM25 |
|---|---|---|
| 「怎么处理连接被重置」 | ✅ 命中 | ❌ 文档里写的是 ECONNRESET |
| 「ECONNRESET 什么意思」 | ⚠️ 可能被语义相近内容带偏 | ✅ 精确命中 |
| 「X-7200 的保修期」 | ❌ 型号数字对向量几乎无意义 | ✅ 精确命中 |
融合用 RRF(Reciprocal Rank Fusion),只看排名不看分数,所以不需要对两路分数做归一化:
text
RRF_score(d) = Σ 1 / (k + rank_i(d)) k 一般取 60
各路召回
例:文档 D 在向量检索排第 2、在 BM25 排第 5
score = 1/(60+2) + 1/(60+5) = 0.01613 + 0.01538 = 0.03151④ Rerank(重排):召回用的是 bi-encoder(query 和文档分别编码,可预计算、快),精排用 cross-encoder(query 和文档拼在一起进模型算相关性,慢但准)。
| 阶段 | 模型类型 | 复杂度 | 典型耗时 |
|---|---|---|---|
| 召回 | bi-encoder(embedding) | 文档向量可离线算好 | 毫秒级 |
| 精排 | cross-encoder(reranker) | 每个候选都要跑一次模型 | 50 条约 50~200ms |
实践结论:加 rerank 通常是性价比最高的一次质量提升(召回 50 → 精排 5),比换更大的 embedding 模型收益明显。代价是多一跳延迟,要给它单独的超时和降级(rerank 挂了就退回向量原始排序)。
12. 更新、删除与一致性:向量库最容易被忽略的一面
| 问题 | 机制 | 工程动作 |
|---|---|---|
| 刚写入立刻查不到 | 写入先进 growing segment,默认一致性级别下对新数据可见有延迟 | 需要立刻可见时用强一致(Strong)查询或显式 flush,但会牺牲吞吐;通常业务上用「异步索引 + 状态位」更合理 |
| 删除不是立即物理删除 | 标记删除(tombstone),空间靠 compaction 回收 | 删除后短时间内仍可能被检索到,需要在应用层按 doc_id 二次过滤 |
| 文档更新 | 没有 UPDATE 语义,等价于「按 doc_id 删除所有旧 chunk + 插入新 chunk」 | chunk 主键必须可推导(如 {doc_id}_{chunk_index})或维护 doc_id → chunk_ids 映射,否则产生僵尸 chunk:文档改了,旧内容还在被检索 |
| 换 Embedding 模型 / 改前缀 / 改切分 | 向量空间变了,新旧向量不可比 | 必须全量重建,而且要双写双读灰度:新建一个 collection → 全量灌 → 评测对比 → 切流量 → 下线旧库 |
| 索引重建期间的查询 | 重建会占用大量 CPU/内存 | 走副本/影子 collection 重建,别在唯一的在线 collection 上原地重建 |
| 向量库数据丢失 | 它只是索引 | 原文 + metadata 必须有独立持久化,保证任何时候都能从源头全量重建(这是选型时的硬性要求) |
✨ 高级深挖知识点
1. 相似度 0.85 不代表「相似」:分数没有绝对意义
这是面试区分度最高的一个点。余弦相似度的绝对值受三件事干扰:
| 干扰因素 | 现象 |
|---|---|
| 各向异性(anisotropy) | 未经对比学习校准的模型,向量会挤在一个狭窄锥形区域里,任意两段无关文本的余弦都在 0.7 以上,看着都很"相似" |
| 文本长度不匹配 | 短 query 和长 chunk 的分数天然偏低,不能和「长句 vs 长句」的分数放一起比 |
| 高频通用词 | 「本文介绍了…」这类套话会把一批 chunk 的分数同时拉高 |
| ❌ 错误做法 | ✅ 正确做法 |
|---|---|
照搬网上的 0.75 当阈值 | 用自己的回归集跑分数分布,取「正样本 5 分位」与「负样本 95 分位」之间的点做阈值 |
| 只看分数判断相关 | 分数只用于相对排序和拒答兜底,相关性判断交给 rerank 模型 |
| 换了模型沿用旧阈值 | 阈值是模型的附属配置,和模型版本绑定在一起管理 |
2. 召回率怎么量化:用 Flat 做 ground truth
ANN 的「近似」有多近似,不能靠感觉,要能算出来:
text
① 用 FLAT(暴力)对一组真实 query 跑出精确 TopK,作为 ground truth
② 用 HNSW/IVF 以不同 ef / nprobe 跑同一组 query
③ recall@K = |ANN 结果 ∩ 精确结果| / K 取所有 query 平均
调参目标:在 recall@10 ≥ 0.95 的前提下,取延迟最低的那组参数再往上还有业务层指标,两者不要混为一谈:
| 指标 | 衡量什么 | 说明 |
|---|---|---|
recall@K(ANN 召回率) | 索引的近似损失 | 对比对象是暴力检索结果 |
| 命中率 / hit rate@K | 检索策略是否捞到了标注答案 | 对比对象是人工标注,受切分、模型、混合策略影响 |
| MRR | 正确答案的排名倒数均值 | 关心「第一条对不对」 |
| NDCG@K | 带位置折扣的相关性 | 关心整体排序质量 |
| 端到端答案正确率 | 最终 LLM 答得对不对 | 真正的北极星指标,接 Day23 的评测体系 |
排查口诀:先看标注答案是否在召回的 50 条里。在 → 是排序/rerank 问题;不在 → 是切分、embedding 或过滤条件问题。这一步能省掉 80% 的无效调参。
3. 量化与降维:怎么在召回率几乎不掉的前提下省一半内存
| 手段 | 做法 | 收益 / 代价 |
|---|---|---|
| float16 | 存储精度减半 | 省 50% 内存,召回几乎无损,首选 |
| SQ8(标量量化) | 每维压成 1 字节 | 省 75%,掉 1~3 个点,配 ef 调大可补回 |
| PQ(乘积量化) | 向量切段 + 码本编码 | 省 90%+,掉 5~15 个点,必须配 rerank 补偿 |
| MRL 降维 | Matryoshka 训练的模型(如 text-embedding-3)可直接截断维度后重新归一化 | 3072 → 1024 只掉几个点,省 2/3 内存,比换小模型更优 |
| 两段式检索 | binary/PQ 粗筛 1000 条 → 原始 float32 精排 | 内存和精度兼得,实现复杂度上升 |
python
# MRL 降维的正确姿势:截断之后必须【重新归一化】
v = get_embedding(text, dimensions=3072) # 或拿到全维再截断
v = v[:1024]
v = v / np.linalg.norm(v) # ← 漏了这步,内积度量直接失真4. Embedding 缓存与幂等:省钱也省重建时间
Embedding 是纯函数:同样的文本 + 同样的模型 = 同样的向量,天然可缓存。
| 设计点 | 做法 |
|---|---|
| 缓存 key | sha256(normalize(text)) + 模型名 + 模型版本 + 前缀策略 + 维度 全部进 key |
| 为什么模型版本必须进 key | 不进 key 就会出现「一半旧模型向量、一半新模型向量」混存,检索结果彻底失真,而且不会报错,极难发现 |
| 存哪 | 本地 SQLite / Redis / 对象存储都行,重建索引时命中率往往 >90% |
| 顺带收益 | 全量重建从「几小时 + 几百块钱」变成「几分钟 + 几乎零成本」 |
5. 批量灌库的吞吐与容错(接 Day23 第 16 节)
| 要点 | 说明 |
|---|---|
| 批大小 | Embedding 接口按 batch 收费和限流,一批 16~64 条通常最优;太大容易超时和触发 TPM |
| 并发 | asyncio.Semaphore 控并发,按 RPM/TPM 倒推,429 自动降档 |
| 超长截断 | 入库前用真实 tokenizer 校验,超过 max_seq_len 的 chunk 告警 + 二次切分,不能静默截断 |
| 部分失败 | 单条失败写进失败清单,不中断整批;跑完统一重试失败项 |
| 断点续跑 | 以 chunk_id 为幂等键,结果落盘;进程被 kill 后不重复花钱 |
| 写入顺序 | 先全部 embedding 落盘,再批量 insert 向量库,两个阶段解耦,任何一步失败都能单独重跑 |
6. 父子块 / small-to-big / 窗口扩展:精度与上下文的两难解法
text
问题:小块检索准但上下文不足;大块上下文足但检索被稀释
解法一:父子块
检索用「子块」(200 字,语义聚焦)
命中后向上取「父块」(1500 字,完整小节)喂给 LLM
解法二:句子窗口(sentence window)
检索粒度 = 单句
命中后把「前后各 N 句」一起带上
解法三:摘要索引
给每个大块生成一句摘要,用【摘要向量】检索,命中后返回大块原文
适合长文档章节级定位| 方案 | 检索精度 | 上下文完整性 | 额外成本 |
|---|---|---|---|
| 单一中等块 | 中 | 中 | 无 |
| 父子块 | 高 | 高 | 存两份映射关系 |
| 句子窗口 | 高 | 中高 | chunk 数量暴增 |
| 摘要索引 | 中高 | 高 | 每块多一次 LLM 调用 |
7. 查询侧的优化:问题本身往往才是瓶颈
| 手段 | 做法 | 解决什么 |
|---|---|---|
| 指代消解 / 查询改写 | 多轮对话里「它的价格呢」要先用 LLM 补全成「iPhone 17 的价格呢」 | 多轮场景检索全错的首要原因 |
| 多路 query 扩展(multi-query) | 让 LLM 把问题改写成 3 个不同角度的 query,并行检索后 RRF 融合 | 用户问法和文档措辞差异大 |
| HyDE(假设性答案) | 先让 LLM 编一段「假想答案」,用这段答案的向量去检索 | 短 query 与长文档的非对称问题 |
| 子问题拆解 | 「A 和 B 哪个更便宜」拆成两个独立检索 | 对比类、多跳类问题 |
| 元数据抽取 | 从「去年的财报」里抽出 year=2025 变成过滤条件 | 时间/类别语义不该靠向量表达 |
⚠️ 这些手段都要额外调一次 LLM,延迟和成本都上升。生产里的常见折中:只在「首轮检索 score 全部偏低」时才触发查询改写,而不是每次都做。
8. 向量也是敏感数据:embedding 可被反演
| 风险 | 说明 |
|---|---|
| Embedding inversion | 已有研究表明,在知道模型的情况下可以从向量近似重建原文。所以「只存向量不存原文」不等于脱敏 |
| 成员推断 | 可判断某条特定文本是否在你的库里,可能泄露业务信息 |
| 跨租户泄露 | 过滤条件漏注入 → 直接把别人的文档检索出来并喂给 LLM |
| 工程要求 | 说明 |
|---|---|
| 敏感级别对齐 | 向量库按与原文同等的敏感级别做权限控制和审计 |
| 先脱敏再切分 | PII 在切分前处理,不要指望向量会"忘记" |
| 过滤强制注入 | 多租户条件在封装层注入,并写跨租户越权的单测 |
| 纵深防御 | 检索结果进 Prompt 前再做一次权限校验(接 Day23 的注入防线) |
9. 生产运维清单
| 项 | 要点 |
|---|---|
| 容量规划 | 内存 ≈ 条数 × 维度 × 字节数 × 1.5~2(含图结构、副本、metadata) |
| 冷启动 | 向量库重启后需要 load 才能查,大 collection 加载要几分钟,必须纳入发布预案 |
| 分片与副本 | 分片提吞吐、副本提可用性和并发;副本也要占等量内存 |
| 监控指标 | QPS、P95/P99 延迟、召回条数分布、score 分布、过滤命中率、内存水位、索引构建进度 |
| 告警项 | 返回空结果比例突增(切分/过滤出问题)、score 均值漂移(模型或数据变了)、查询 P99 抬升 |
| 降级 | 向量库不可用时退回 BM25/ES 关键词检索或固定兜底话术,不要让整个问答功能挂掉 |
| 备份 | 向量库快照 + 原始文档与 metadata 的独立备份(后者才是真正的生命线) |
10. 向量检索解决不了的问题(别硬塞给它)
| 需求 | 为什么向量检索做不好 | 该用什么 |
|---|---|---|
| 「一共有多少份合同」 | 向量检索是 TopK 相似,没有聚合语义 | SQL / 数仓 |
| 「列出全部满足条件的记录」 | TopK 天生只返回少量,且是近似结果 | 标量数据库查询 |
| 「2025 年 3 月之后的文档」 | 时间是结构化属性,不该用语义近似表达 | metadata 过滤 |
| 精确型号 / 错误码 / ID 匹配 | 数字和编码的向量表达极弱 | BM25 / 精确索引(混合检索) |
| 需要强一致读写 | 向量库一致性模型弱、无事务 | 事实源放关系库,向量库只做索引 |
总结一句:向量库负责「语义近似召回」,结构化条件交给过滤、精确匹配交给关键词、统计聚合交给数据库。RAG 做不好,很多时候是把这四类问题全丢给了向量检索。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
Q1. 向量检索和关键词检索(BM25)有什么区别?生产上该选哪个?
精简口述答案(90 秒版)
两者匹配的对象完全不同。BM25 匹配的是字面 term,走倒排索引精确命中,再用词频、逆文档频率和文档长度归一化打分;向量检索匹配的是语义位置,把 query 和 chunk 编码到同一个向量空间,用余弦或内积找最近邻。所以它们的失效场景恰好互补:BM25 处理不了「换一种说法」——用户问「怎么退钱」,文档写的是「退款流程」,字面零交集;向量检索处理不了精确型号、错误码、人名、订单号——ERR_5021 和 ERR_5012 在向量空间里几乎是同一个点。
还有两个结构性差异一定要提:一是向量检索是近似的(ANN),默认就不保证召回全,BM25 是精确匹配;二是向量检索必须离线灌库,换 embedding 模型等于全量重算,BM25 加一条文档立刻可查。
生产上我的答案不是二选一,而是混合检索 + RRF 融合 + rerank 精排:两路各召回 50 条,用 RRF 按排名倒数融合(不依赖两边分数可比),再用 cross-encoder 精排取前 3~5 条进 prompt。如果只能选一路:开放域知识问答选向量,日志 / 工单 / 代码 / 商品型号检索选 BM25。
| 追问 | 答法要点 |
|---|---|
| 为什么两路分数不能直接加权相加? | BM25 分数无上界且随语料分布漂移,余弦在 [-1,1],量纲不可比;要么各自归一化(仍不稳),要么用 RRF 只取排名 |
| RRF 的公式和直觉? | score = Σ 1/(k + rank_i),k 常取 60。只看排名不看分数,所以天然跨系统可融合;排名靠前的边际收益大 |
| 向量检索为什么对数字和编码弱? | 分词后数字被切成无语义子词,且 embedding 训练目标是语义相似而非字符串相似,编辑距离近的编码会被映射到相邻点 |
| 只有向量库、没有 ES,怎么补关键词能力? | pgvector + PostgreSQL 全文索引;或 Milvus 的 BM25 稀疏向量(sparse vector)与稠密向量做同库混合检索 |
| 混合检索一定比单路好吗? | 不一定。两路都召回噪声时融合会放大噪声,必须有 rerank 兜底;且要用离线评测集验证 recall@50 和端到端正确率是否真的提升 |
✅ 项目关联话术:「我们一开始只做了向量检索,上线后发现一类固定的坏 case:用户带着错误码或合同编号来问,答案永远找不到。排查方式是先看正确答案有没有出现在召回的 50 条里——没有,说明是召回问题不是 prompt 问题。后来加了 BM25 这一路 + RRF 融合 + bge-reranker 精排,这类 case 的命中率从 30% 多到 90% 以上,整体 recall@50 也涨了 10 多个点。」
Q2. Embedding 是什么?怎么选模型?为什么不能直接用大模型两两判断相似度?
精简口述答案(90 秒版)
Embedding 是把一段文本映射成固定维度的稠密向量,训练目标(典型是对比学习 InfoNCE)让语义相近的文本在空间里距离近。它和 LLM 是两类模型:LLM 是 decoder-only、自回归生成 token;embedding 模型通常是 encoder(或 decoder 取末 token),一次前向输出一个向量,不生成文字。
不能用 LLM 两两判相似度的原因是复杂度和工程形态:向量检索是把 N 条文档离线各算一次向量,在线只对 query 算一次,然后在索引里做 ANN,是 O(log N) 量级;LLM 两两判断是 O(N) 次推理,10万条文档一次查询就是 10 万次调用,延迟和成本都不可能接受,而且结果不可索引、不可缓存。LLM 的位置在精排之后的少量候选上(比如 rerank 完的 3~5 条做答案生成),不是召回阶段。
选模型我看四件事:中英文支持(中文优先 bge-large-zh、bge-m3)、max_seq_len(决定 chunk 上限,超长会被静默截断,不报错)、维度和成本(1024 维通常够用,3072 维存储翻三倍)、是否需要对称/非对称检索(bge 系列 query 要加指令前缀,e5 系列 query/passage 两边都要加前缀,漏了召回会明显掉)。还有一条硬规则:线上换 embedding 模型必须全量重建索引,新旧向量不能混在一个 collection 里。
| 追问 | 答法要点 |
|---|---|
| 为什么同一句话用不同模型的向量不能比较? | 不同模型的向量空间互不对齐,连维度都可能不同;即便维度相同,坐标轴含义完全不同,余弦值没有意义 |
| 维度越高效果越好吗? | 不是。维度主要受训练目标和模型容量决定;高维带来存储、内存和距离计算成本上升,还有维度灾难。先用评测集验证 1024 vs 3072 的 recall 差多少 |
| CLS 池化和 mean 池化有什么区别? | CLS 取首 token 表示(需训练时有监督信号支撑),mean 对所有 token 平均更稳健。必须和模型发布时的池化方式一致,自己换池化等于换模型 |
| 为什么要 L2 归一化? | 归一化后内积恒等于余弦,可以用更快的 IP 索引;同时消除向量模长(受文本长度、高频词影响)对相似度的干扰 |
| 文本超过 max_seq_len 会怎样? | 大多数实现直接截断且不告警,尾部内容对向量零贡献。必须在入库前校验 token 数并切分,不能靠字符数估 |
| 需要微调 embedding 模型吗? | 先把 chunk 策略、指令前缀、混合检索、rerank 做到位;垂域术语多(医疗、法律、内部缩写)且有标注数据时再考虑微调,收益通常比换基模明显 |
✅ 项目关联话术:「我们的 embedding 走统一的 EmbeddingService,对外只暴露 encode(texts, is_query):内部负责加指令前缀、按 token 数校验超长、批量分批、带模型版本号的缓存(cache key 是 模型名:版本:文本哈希,不带版本号的话换模型会读到旧向量,这是个很隐蔽的事故)、失败重试和 L2 归一化。后来从 bge-large-zh 换到 bge-m3 时,业务代码一行没改,只是走了一遍影子 collection 全量重建 + 双写灰度。」
Q3. 余弦相似度、内积、欧氏距离怎么选?它们之间是什么关系?
精简口述答案(90 秒版)
先给结论:度量方式不是自己挑的,要和 embedding 模型训练时用的度量保持一致,模型卡写了用余弦就用余弦。很多人答「检索一律用余弦」,这是不严谨的。
三者的关系在向量已 L2 归一化的前提下是等价的:此时 cos(A,B) = A·B(内积就是余弦),而 ‖A-B‖² = 2 - 2·cos(A,B),欧氏距离是余弦的单调递减函数。所以归一化之后,COSINE / IP / L2 三种度量的 TopK 排序结果完全相同,差别只在数值和计算开销——IP 最省(省掉开方和归一化),所以工程上常见做法是入库前统一归一化 + 用 IP 索引。
不归一化就完全不同了:内积会偏向模长大的向量(长文本、高频词堆叠的 chunk 容易模长偏大,于是不管问什么它都排前面),欧氏距离同时受方向和模长影响。这也是「某个 chunk 莫名其妙总被召回」最常见的原因。
最后一个必须提的工程坑:库返回的是 distance 还是 similarity,方向是反的。Milvus 的 L2 越小越相似、IP/COSINE 越大越相似;Chroma 默认返回 distance(越小越相似)。阈值过滤的符号写反,会变成「把最不相关的留下来」,而且不报错,只是效果烂。
| 追问 | 答法要点 |
|---|---|
| 为什么归一化后 TopK 顺序一致? | ‖A-B‖² = ‖A‖² + ‖B‖² - 2A·B = 2 - 2cos,对固定 query 是 cos 的单调递减函数,排序必然一致 |
| 什么场景会刻意不归一化? | 推荐系统里希望用模长表达「热度 / 置信度」时;或模型本身训练目标就是未归一化内积(如部分双塔召回) |
| 余弦相似度 0.85 算高吗? | 单独看毫无意义。不同模型的分数分布差异极大(各向异性导致中文 bge 随机两句话也常在 0.6~0.8),必须用自己的正负样本集标定阈值 |
| 为什么 Milvus 的 COSINE 支持是后来才加的? | 因为归一化 + IP 在数学上就等价于 COSINE,底层实现其实是入库/查询时自动归一化再走 IP |
| 曼哈顿距离、汉明距离什么时候用? | 汉明用于二值化向量(极致压缩,如 BinaryFlat),曼哈顿极少用于文本 embedding |
| 混合检索里稀疏向量用什么度量? | 稀疏向量(BM25 / SPLADE)用内积,不要归一化,否则会破坏 term 权重语义 |
✅ 项目关联话术:「我们在 EmbeddingService 出口统一做 L2 归一化,并且在归一化里加了零向量防护(模长小于 1e-12 直接报错而不是除零产生 NaN)——曾经有一批空白页 OCR 出来是空字符串,算出全零向量,归一化后变成 NaN,插库成功但检索结果直接乱掉,这个坑很难查。collection 统一用 IP 度量,并在封装层把 Milvus 返回的 distance 统一转换成『越大越相似』的 score,业务侧只认一个方向。」
Q4. 文本切分(chunking)怎么设计?chunk_size 和 overlap 怎么定?
精简口述答案(90 秒版)
我的经验是:RAG 效果不好,一多半的根因在切分,而不是模型。chunk 的本质是「召回的最小单位」,它同时受三个约束:不能超过 embedding 模型的 max_seq_len(否则静默截断)、多条 chunk 拼起来不能挤爆 LLM 上下文预算、以及语义完整性——一个 chunk 要能独立回答一个问题。
参数上中文的经验值是 chunk_size 300~800 字、overlap 取 chunk_size 的 10%~20%。太大导致一个 chunk 里混了多个主题,向量被平均成「四不像」,相似度全都中不溜;太小导致上下文断裂,比如「它的保修期是 2 年」这一句被切出来,丢了主语,召回了也没用。overlap 的作用是防止答案正好横跨切点,代价是存储和召回重复,所以不能开太大(超过 50% 就是在浪费钱)。
比固定参数更重要的是四条硬规则:① 按结构切,优先用 Markdown 标题 / 段落 / 句子递归降级切,不要按固定字符硬切;② 表格和代码块绝不切断,宁可超长单独成块;③ 每个 chunk 带上标题路径(产品手册 > 售后 > 保修政策)写进文本前缀,补回上下文;④ metadata 必须留 doc_id、chunk_index、source,这是后续更新、删除、溯源引用的前提。
进一步的解法是检索单元和生成单元解耦:父子块(小块用于召回、命中后返回父块给 LLM)、句子窗口(召回单句、扩展前后 N 句)、摘要索引(为每个父块生成摘要做向量)。参数调不动的时候,换结构比调参有效得多。
| 追问 | 答法要点 |
|---|---|
| 怎么判断切分是不是出了问题? | 随机抽 30~50 个 chunk 人工读一遍,看能否独立成立;再对坏 case 检查答案是否出现在召回的 50 条里——在里面是精排问题,不在就是召回/切分问题 |
| 为什么不无脑把 chunk 切得很小? | 向量数量暴涨(成本、索引大小),且单块信息量不足,TopK 里塞满碎片;需要靠父子块把粒度还回来 |
| 语义切分(semantic chunking)值得上吗? | 原理是相邻句 embedding 相似度骤降处切开。质量更好但要额外一轮 embedding,成本和复杂度上升;先把结构化切分做好再考虑 |
| 表格怎么处理? | 表格整块保留 + 补上表头与标题路径;大表按行分组但每组重复表头;复杂表可额外生成一段自然语言摘要做向量 |
| PDF 怎么切? | 先解析出版面结构(标题层级、段落、表格),不要按页切——按页切会把一段话拦腰截断,还会带入页眉页脚噪声 |
| 多轮对话历史入库怎么切? | 按「一问一答」为最小单元,并把指代消解后的问题存进去,否则「那它多少钱」这种 chunk 完全不可检索 |
✅ 项目关联话术:「我们的文档入库链路是:解析出标题树 → 按 标题 / 段落 / 句子 递归降级切(表格、代码块整块保留)→ 每个 chunk 前缀拼上标题路径 → 用模型 tokenizer 校验 token 数,超长再降一级 → 写入时带 doc_id / chunk_index / 标题路径 / 更新时间 / tenant_id。最早我们按固定 2000 字符切,坏 case 查下来基本都是『一个 chunk 里混了三个小节』。改成结构化切分 + 父子块(召回小块、喂父块)之后,端到端正确率提升比换 embedding 模型明显得多。」
Q5. ANN 索引的原理是什么?HNSW 和 IVF 怎么选、怎么调参?
精简口述答案(90 秒版)
先说本质:ANN 是「近似」最近邻,用召回率换速度。暴力 Flat 是 O(N·d) 精确计算,百万级就撑不住在线延迟了,所以索引的思路都是「只算一小部分候选」。
IVF 是聚类分桶:先 k-means 训出 nlist 个聚类中心,查询时只进 nprobe 个最近的桶里算。优点是内存省、构建快;缺点是桶边界问题——如果答案落在没被探查的邻桶里就直接漏召回,所以 nprobe 调大能提召回但线性增加耗时。经验值 nlist ≈ 4√N,nprobe 从 nlist/20 起调。IVF 需要训练,数据量太小(低于几万)反而不如 Flat。
HNSW 是多层跳表式的邻近图:上层稀疏做长距离跳跃,底层稠密做精细搜索,查询沿图贪心下降。召回率和延迟表现是目前最好的,代价是内存大(要存图结构,通常是向量本身的 1.5~2 倍)、构建慢、不支持高效的大批量删除。参数上 M(每点邻居数,8~64)和 efConstruction(构建时候选集,100~500)是建索引期参数,改了必须重建;ef(查询时候选集,必须 ≥ TopK)是查询期参数,可以线上动态调——这个区分是面试加分点。
选型我一般这样说:数据量小于 100 万、内存够 → HNSW,默认首选;量大到内存吃不住 → IVF_PQ 或 HNSW + SQ8 量化;万级以下 → 直接 Flat,别加索引,还能当召回率的 ground truth 基线。
| 追问 | 答法要点 |
|---|---|
| 怎么量化召回率? | 用 Flat 暴力检索的结果作为 ground truth,recall@K = 交集大小(ANN TopK ∩ Flat TopK) / K,然后画 recall-QPS 曲线选工作点 |
| 建了索引比不建还慢,可能是什么原因? | 数据量太小(索引开销 > 收益)、collection 没 load 进内存、nprobe/ef 调得过大、或者每次查询都在重复建连接 |
| PQ 量化为什么能压这么狠? | 把向量切成 m 个子段,每段用码本的 1 字节 ID 代替,压缩比可达 10~30 倍,但引入量化误差;工程上配合两段式(PQ 粗召回 → 原始向量精算)找回精度 |
| HNSW 为什么不适合频繁删除? | 删除是标记删除,图结构不会立刻重建,大量 tombstone 会让图连通性变差、召回率下降,需要定期 compact 或重建 |
| 向量数量增长 10 倍,参数要动吗? | IVF 的 nlist 依赖数据规模,需要重新训练;HNSW 可以增量插入,但要重新评估 ef 与内存,并考虑分片 |
| 磁盘索引(DiskANN)什么时候用? | 向量量级到亿、内存成本不可接受时,用 SSD 换内存,延迟从毫秒级升到数十毫秒级 |
✅ 项目关联话术:「我们线上用的是 HNSW(M=16、efConstruction=200),ef 做成了配置项,因为它是查询期参数,可以不重建索引直接调。上线前我专门跑了一轮评测:拿 500 条真实 query,先用 Flat 暴力检索出 TopK 作为基线,然后扫 ef 从 32 到 256,画出 recall@10 和 P95 延迟的曲线,最后选了 recall 0.97 附近的拐点。这件事的价值是——后面任何人说『检索不准』,我都能先用 recall 这个数字判断是不是索引的问题,而不是靠猜。」
Q6. 检索召回不准怎么系统性优化?
精简口述答案(90 秒版)
我会先定位是哪一段出了问题,而不是直接调参。方法很简单:把正确答案所在的 chunk 找出来,看它有没有出现在召回的 50 条里。
- 不在召回里 → 是召回阶段问题:切分不合理、query 和文档表述差异大、漏了指令前缀、metadata 过滤把它滤掉了、或
ef/nprobe太小。 - 在召回里但没进 TopK 3 → 是精排阶段问题:该上 rerank。
- 进了 TopK 但模型答错 → 是生成阶段问题:prompt 没约束「只依据给定材料」、或多个片段互相矛盾。
然后按收益排序动手:① 两段式检索——召回放宽到 50,用 cross-encoder reranker 精排到 3~5 条,这是单项收益最大的改动;② 混合检索 + RRF,补上精确型号、错误码这类向量弱项;③ 查询改写——多轮对话先做指代消解(「它多少钱」要还原成「iPhone 17 多少钱」),再做 multi-query(一个问题生成 3 个变体并行召回后去重融合);④ 切分重构(父子块 / 句子窗口);⑤ metadata 过滤,把时间、租户、文档类型这类结构化条件从语义里剥出来;⑥ 最后才是换 embedding 模型或微调。
一个关键前提:必须先有离线评测集(50~200 条 query + 标注的正确 chunk id),否则每次改动都是盲调,谁的直觉大声听谁的。
| 追问 | 答法要点 |
|---|---|
| rerank 为什么比向量检索准? | bi-encoder 把 query 和文档独立编码,交互信息在打分时已经丢失;cross-encoder 把两者拼在一起过一遍模型,能做 token 级交互,代价是无法预计算,只能用于少量候选 |
| 召回 50 条是怎么定的? | 由 rerank 的延迟预算反推。rerank 50 条通常几十毫秒级;再往上收益递减而延迟线性上升。可以用 recall@K 曲线找 K 的饱和点 |
| multi-query 的代价? | 多一次 LLM 调用(增加 TTFT)+ N 倍检索开销。只在复杂问题上开,简单问题走快路径;改写结果可缓存 |
| HyDE 是什么?什么时候有用? | 让 LLM 先虚构一段答案,用这段答案的向量去检索。适合 query 很短、和文档表述差距大的场景;缺点是幻觉内容可能把检索带偏 |
| 相似度阈值该怎么设? | 不要照抄 0.75 这类数字。用自己的正负样本集标定分布,或改用相对阈值(低于 top1 分数 ×0.8的丢掉)、以及交给 reranker 分数来裁 |
| 什么都调了还是不准? | 检查是不是向量检索解决不了的问题:聚合统计、全量列举、时间范围、精确 ID——这类要走 SQL / 过滤 / 关键词,不是检索优化能救的 |
✅ 项目关联话术:「我们建了一个 120 条 query 的离线评测集,每条标注了正确答案所在的 chunk id,跑一次脚本输出 recall@50、MRR@10、rerank 后的 hit@3 三个指标。任何检索侧改动都先过这个脚本再上线。最有效的一次改动是加 reranker:recall@50 几乎没变,但 hit@3 从 0.61 涨到 0.84——这组数据也说明了召回和精排是两件事,不能混在一起调。」
Q7. 带条件的向量检索怎么做?pre-filter 和 post-filter 有什么区别?多租户怎么隔离?
精简口述答案(90 秒版)
带条件检索就是向量相似度 + 结构化过滤一起用,比如「只在 2025 年之后、租户 A 的产品手册里找」。实现上有两条路:
- post-filter:先 ANN 取 TopK,再对结果做条件过滤。问题很致命——TopK 会缩水甚至为空。比如 TopK=10,但这 10 条里只有 1 条属于租户 A,用户就只拿到 1 条;如果一条都不属于,直接返回空,而库里其实有很多符合条件的数据。
- pre-filter:把过滤条件下推到索引层,ANN 只在满足条件的子集里搜,保证返回 K 条。Milvus 的
expr、Qdrant 的filter、pgvector 的WHERE都是这个思路。
所以生产上必须用 pre-filter,并且过滤字段要建标量索引。要注意一个性能陷阱:过滤后的子集如果非常小(高选择性),图索引的连通性会被破坏,引擎可能退化成暴力扫描——这时候更好的做法是用 partition / 分表做物理隔离,而不是靠 filter。
多租户我的做法是三层:① 数据模型上每条记录必须有 tenant_id;② 在封装层强制注入 tenant_id 条件,不允许业务自己拼 expr——这是安全边界,靠约定一定会出事;③ 大租户单独 partition 或单独 collection。隔离方案的权衡是:共享 collection + filter 最省资源但隔离最弱;独立 collection 隔离最强但数量多了元数据和内存开销大。
| 追问 | 答法要点 |
|---|---|
| 为什么 post-filter 还有人用? | 部分库(早期 Chroma、纯 FAISS)不支持条件下推,只能在应用层过滤。缓解手段是放大召回(取 K×10 再过滤),但依然不能保证 |
| 过滤条件选择性很高时怎么优化? | 改用 partition 物理分区、或先用标量条件取出 id 集合再做小规模精确计算;也可以为高频过滤维度建独立 collection |
tenant_id 怎么保证不被绕过? | 不暴露裸 expr 参数,search(tenant_id, ...) 作为必填入参由框架拼接;并在单测里加「不传 tenant_id 必须抛异常」的用例 |
| partition 和 collection 怎么选? | partition 共享 schema 和索引配置、切换成本低,适合租户多且 schema 一致;collection 适合租户少但隔离/配额/备份要求强 |
| 时间范围过滤有坑吗? | 时间必须存成可比较的数值类型(时间戳 int64),不要存字符串;也绝不要指望语义检索能理解「最近三个月」 |
| 删除某个租户的全部数据怎么做? | 按 partition drop 最快;共享 collection 要按 tenant_id 批量删除,注意是标记删除,需要 compact 才真正释放空间 |
✅ 项目关联话术:「我们的 VectorStore 封装里 tenant_id 是位置参数,不是可选项,expr 由内部统一拼:tenant_id == "{t}" and doc_type in {types} and update_ts >= {ts}。这样做的起因是一次 code review——有人为了调试方便直接把 expr 透传了出去,虽然当时没出事,但那就是个越权读取的口子。另外我们把时间统一存成 int64 时间戳并建了标量索引,因为早期用字符串存日期,>= 比较在跨年时直接出错。」
Q8. 向量数据库怎么选型?FAISS、Chroma、Milvus、pgvector、ES 分别适合什么场景?
精简口述答案(90 秒版)
我先把它们分成三类,这样答比逐个背参数清楚:
- 库(library):FAISS。只有索引算法,没有持久化、没有 metadata 过滤、没有服务端、没有权限。它是索引引擎,不是数据库。适合离线实验、做召回率基线、嵌在单机服务里。把 FAISS 当数据库用是经典错误——重启丢失、没法增删改、没法按条件过滤。
- 专用向量数据库:Chroma(嵌入式、几行代码起步,适合 Demo 和十万级以内)、Milvus(分布式、多索引、强过滤、partition、一致性级别可调,适合千万级以上和多租户生产)、Qdrant(Rust 写的,filter 能力强、单机性能好,运维比 Milvus 轻)。
- 已有数据库的向量扩展:pgvector(数据和业务同库,能用 SQL JOIN、事务、现成的备份和权限体系,百万级完全够用)、Elasticsearch(原生 BM25 + kNN,天然做混合检索,已有 ES 集群时性价比极高)。
我的选型原则是先不上重组件:如果已经有 PostgreSQL,百万级以内直接 pgvector,省掉一整套运维;如果已经有 ES 且需要混合检索,直接用 ES;只有当数据量到千万级、需要分布式扩容、多租户 partition、或者要换着试多种索引时,才上 Milvus。选型的成本大头从来不是「哪个 QPS 高」,而是谁来运维、怎么备份、怎么扩容。
| 追问 | 答法要点 |
|---|---|
| pgvector 的瓶颈在哪? | 单机扩展上限、HNSW 索引构建吃内存、并发高时和业务 SQL 抢资源。到千万级或需要水平扩容时就要换 |
| 为什么说 FAISS 不是数据库? | 无持久化保证、无 CRUD 语义(删除要重建或维护 id 映射)、无 metadata 过滤、无服务化与鉴权、无副本与高可用 |
Milvus 为什么要显式 load? | 它是存算分离架构,数据在对象存储,检索要先加载到 query node 内存。没 load 直接查会报错或返回空,这是新手第一坑 |
| ES 做向量检索的短板? | 大规模 kNN 的内存和延迟表现不如专用库,索引参数可调性较弱;但混合检索和运维生态是强项 |
| 怎么做到换库不改业务代码? | 定义统一的 VectorStore 接口(upsert / search / delete_by_doc / count),各实现适配;分数方向、归一化、tenant 注入都收在接口内部 |
| 评估一个向量库要看哪些指标? | pre-filter 能力、索引类型、一致性级别、增删改语义、水平扩容、备份恢复、监控与可观测、客户端成熟度 |
✅ 项目关联话术:「我们一期是 pgvector——文档量几十万,数据和业务表在同一个 PG 里,检索可以直接 JOIN 权限表,备份用现成的 PG 流程,基本零额外运维。二期文档量涨到千万级、又要做多租户 partition 和分片扩容,才迁到 Milvus。因为一开始就抽了 VectorStore 接口,迁移时业务代码没动,主要工作量在数据重灌和 load、分片副本这些运维侧配置上。我面试时会强调这个顺序:不要一上来就 Milvus,先问清楚数据量级和运维成本。」
Q9. 文档更新和删除怎么处理?换 embedding 模型要做什么?
精简口述答案(90 秒版)
向量库基本没有 UPDATE 语义,所有「更新」都要实现成「按 doc_id 先删后插」。这件事的前提是入库时就把 doc_id 写进 metadata,否则一篇文档改了之后你根本找不到它对应的那十几条 chunk。
最典型的事故是僵尸 chunk:文档从 20 个 chunk 改成 15 个,如果按 chunk_index 逐条覆盖,第 16~20 条老 chunk 会永远留在库里,内容已经过期但照样被召回,导致模型基于旧内容回答。所以正确做法一定是 delete(doc_id == X) 然后整篇重插,而不是逐条 upsert。
另外三件事也要说清楚:① 删除是标记删除,空间和召回影响不会立刻消失,需要 compact;② 刚写入可能查不到,向量库普遍是最终一致,Milvus 要靠 consistency_level(Strong 会等同步、延迟高;Bounded 是默认折中),写完立刻验证的场景要显式用 Strong 或主动 flush;③ 原文必须独立持久化(对象存储或 MySQL),向量库只当索引,这样换模型、重建索引时不需要重新爬一遍数据源。
换 embedding 模型是全量重建,不是增量:新旧向量空间不对齐,混在一个 collection 里相似度完全没有意义。我的流程是——新建影子 collection → 用新模型全量重算灌入 → 跑离线评测集对比新旧指标 → 流量按比例灰度切(或双写双查对比)→ 确认后切别名、下线旧 collection。索引参数重建(比如 HNSW 的 M 改了)也走同一套影子流程,不要在线上 collection 上直接 drop index。
| 追问 | 答法要点 |
|---|---|
| 为什么不能逐条 upsert? | chunk 数量会变化,多出来的老 chunk 无法被覆盖,形成僵尸数据;且切分策略一改,chunk 边界全变,index 对不上 |
| 文档只改了一个错别字,也要全篇重算吗? | 可以做 chunk 级内容哈希比对,只重算变化的 chunk(省 embedding 成本),但删除逻辑仍按 doc_id 维度兜底 |
| 刚写入查不到,怎么排查? | 依次确认:是否 flush、是否 load、consistency_level 是什么、是否写到了另一个 partition、过滤条件是否把它排除了 |
| 大批量删除后性能变差? | tombstone 堆积破坏 HNSW 图连通性,需要 compact 或重建索引;设计上尽量用 partition drop 替代海量按条件删除 |
| 双写期间两套向量怎么保证一致? | 以原文表为事实源,灌库任务幂等(主键是 doc_id + chunk_index + 模型版本),失败可重放;切流前跑一致性校验(条数 + 抽样比对) |
| 用别名(alias)有什么好处? | 业务只认别名,切换 collection 是一次原子的别名重指向,可以秒级回滚,不需要改配置重启服务 |
✅ 项目关联话术:「我们踩过一次很典型的坑:产品手册改版,一篇文档的 chunk 从 20 条减到 15 条,当时是按 chunk_index 做 upsert,结果老的第 16~20 条留在库里,用户问保修政策时召回的是已经作废的旧条款,而且因为内容本身很通顺,没人能第一时间发现。之后我们把入库改成『按 doc_id 整篇删除 + 整篇重插』,并加了一个对账任务,每天比对原文表的 chunk 数和向量库里的实际条数,不一致就告警。」
Q10. 检索效果怎么评测?线上要监控哪些指标?
精简口述答案(90 秒版)
分离线评测和线上监控两块,面试时把这两块分开讲会显得体系化。
离线评测的前提是有一个标注集:50~200 条真实 query,每条标注正确答案所在的 chunk id(可以用 LLM 批量生成候选再人工校验,成本可控)。然后分三层看指标:
- 索引层:
recall@K——用 Flat 暴力检索结果作为 ground truth,衡量 ANN 近似带来的损失。这个指标只反映索引好不好,和「答案对不对」无关,很多人混为一谈。 - 检索层:
hit@K(正确 chunk 是否在前 K 条)、MRR(正确结果排名倒数的均值,看排序质量)、NDCG(多条相关文档且有分级相关性时用)。 - 端到端层:答案正确率 / 忠实度(faithfulness,回答是否只基于给定材料)/ 引用准确率。这一层通常用 LLM-as-a-Judge 批量打分 + 人工抽检校准,注意位置偏差和长度偏差。
线上监控我会盯:检索 P95/P99 延迟、召回条数为 0 的占比(通常是 post-filter 或阈值设置的问题)、平均 top1 分数的漂移(突然下降说明用户问法变了或数据出了问题)、rerank 超时降级率、答案「未找到相关信息」的占比、以及用户点赞点踩与追问率这类业务反馈。再配上定时的「金丝雀 query」巡检——固定 20 条 query 每小时跑一次,结果和基线对比,变化超阈值告警。
| 追问 | 答法要点 |
|---|---|
| recall@K 和 hit rate 的区别? | recall@K 衡量 ANN 相对精确检索的损失(索引质量);hit@K 衡量标注的正确答案是否被召回(业务质量)。前者 1.0 也完全可能答错 |
| 没有标注集怎么起步? | 用 LLM 为每个 chunk 反向生成 1~2 个问题构成弱标注集,先把相对趋势跑起来;同时从线上真实日志里挑坏 case 逐步人工标注 |
| LLM-as-a-Judge 可信吗? | 可用于相对比较(A/B 哪个更好),不适合当绝对分数。要固定评分 rubric、交换候选顺序消除位置偏差、并用人工抽检校准 |
| 怎么发现「检索召回了但模型没用」? | 要求模型输出引用的 chunk 编号,比对引用集合与召回集合;引用为空却给出了具体答案,基本就是幻觉 |
| 线上 top1 分数突然下跌怎么查? | 先看是不是模型/版本变更(cache key 带版本号就能区分)、再看是否有新文档污染、最后看 query 分布是否变化(新活动带来新问法) |
| 评测集需要定期更新吗? | 需要。业务变化会让老集合失效;但更新时要保留一部分冻结集,否则指标不可跨时间比较 |
✅ 项目关联话术:「我们有两套东西:一个是 eval.py 离线脚本,跑 120 条标注 query,输出 recall@50 / hit@3 / MRR@10 和一份 bad case 明细,任何检索侧改动(切分、模型、ef、rerank 开关)都要先过它;另一个是线上大盘,盯 P95 延迟、零召回率、rerank 降级率和『未找到相关信息』占比。有一次零召回率从 0.5% 涨到 8%,查下来是新上的文档类型过滤条件写错了 —— 如果只看平均相似度分数,这个问题是看不出来的。」
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:余弦相似度 / L2 归一化 / 批量矩阵化 TopK 检索
最常考的基础题。但面试官真正想看的不是公式,而是零向量防护、维度校验、以及会不会用矩阵运算代替 for 循环。
python
import numpy as np
EPS = 1e-12
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""单对向量余弦相似度,返回 [-1, 1]。"""
a = np.asarray(a, dtype=np.float32).ravel()
b = np.asarray(b, dtype=np.float32).ravel()
if a.shape != b.shape:
raise ValueError(f"维度不一致: {a.shape} vs {b.shape}")
na, nb = np.linalg.norm(a), np.linalg.norm(b)
# 零向量没有方向,相似度无定义。绝不能静默返回 0 或让它除出 nan
if na < EPS or nb < EPS:
raise ValueError("零向量无法计算余弦相似度(常见于空文本 / OCR 失败)")
return float(np.dot(a, b) / (na * nb))
def l2_normalize(x: np.ndarray, strict: bool = True) -> np.ndarray:
"""按最后一维归一化,支持 (d,) 和 (n, d) 两种形状。"""
x = np.asarray(x, dtype=np.float32)
if x.ndim == 1:
x = x[None, :]
squeeze = True
else:
squeeze = False
norms = np.linalg.norm(x, axis=1, keepdims=True)
bad = (norms < EPS).ravel()
if bad.any():
if strict:
raise ValueError(f"第 {np.where(bad)[0].tolist()} 行是零向量")
norms = np.where(norms < EPS, 1.0, norms) # 宽松模式:保持为零向量
out = x / norms
return out[0] if squeeze else out
def topk_search(query: np.ndarray, matrix: np.ndarray, k: int = 5,
normalized: bool = False):
"""暴力精确检索(Flat),可直接当 ANN 召回率的 ground truth 基线。
matrix: (n, d)。normalized=True 表示两边都已 L2 归一化,
此时内积恒等于余弦,可省掉一次归一化开销。
"""
q = np.asarray(query, dtype=np.float32).ravel()
m = np.asarray(matrix, dtype=np.float32)
if m.ndim != 2 or q.shape[0] != m.shape[1]:
raise ValueError(f"维度不匹配: query {q.shape} vs matrix {m.shape}")
if m.shape[0] == 0:
return []
if normalized:
scores = m @ q # (n,) 一次矩阵乘,别写 for
else:
scores = (m @ q) / (np.linalg.norm(m, axis=1) * np.linalg.norm(q) + EPS)
k = min(k, m.shape[0])
# argpartition 是 O(n),比全排序 O(n log n) 快;只对 k 个结果再排序
idx = np.argpartition(-scores, k - 1)[:k]
idx = idx[np.argsort(-scores[idx])]
return [(int(i), float(scores[i])) for i in idx]三个必须讲出来的点
| 点 | 为什么重要 |
|---|---|
argpartition 而非 argsort | 百万条向量只要 TopK 时,部分排序把排序开销从 O(n log n) 降到 O(n) |
| 预归一化 + 纯内积 | 把每次查询的 n 次开方省掉;也是向量库统一用 IP 度量的原因 |
float32 而非 float64 | 向量库存的就是 float32,用 float64 白白翻倍内存,且精度对检索无意义 |
边界 Case 自测清单
- [ ] 零向量(空字符串 embedding)→ 抛异常,而不是返回 NaN 悄悄污染结果
- [ ] 两个向量维度不同 → 抛异常(换模型后最容易出现)
- [ ]
k大于库里向量总数 → 返回全部而不是报错 - [ ] 空矩阵
(0, d)→ 返回空列表 - [ ] 一维输入
(d,)和二维(1, d)→l2_normalize输出形状分别对应 - [ ] 归一化后再算
np.linalg.norm→ 应为 1.0(误差 1e-6 内) - [ ]
normalized=True与False两条路径 → TopK 顺序必须一致(验证等价性) - [ ] 分数存在并列时 → 结果稳定可复现(必要时用 id 做二级排序)
变种拓展题
- 实现
topk_search_batch(queries, matrix, k):(m, d) @ (d, n)一次算完 m 个 query,注意内存峰值是m×n,要分块(chunking)处理。 - 实现
recall_at_k(ann_result, flat_result, k),用本题的 Flat 结果作为 ground truth 量化 ANN 索引的召回率。 - 加 MRL 维度截断:
v[:256]之后必须重新 L2 归一化,写出带断言的实现。 - 实现
MMR(最大边际相关)重排,在相似度和多样性之间按 λ 权衡,解决 TopK 全是重复内容的问题。 - 用
np.memmap支持向量矩阵大于内存的场景,并对比分块暴力检索与 HNSW 的延迟差异。
题 2:递归文本切分器(分隔符逐级降级 + overlap + 标题路径 metadata)
不要手写「每 500 字切一刀」。面试要体现的是按语义边界逐级降级、token 而非字符计数、以及chunk 自带上下文。
python
import re
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class Chunk:
text: str
index: int
metadata: dict = field(default_factory=dict)
# 中文优先的分隔符,从粗到细逐级降级
SEPARATORS = ["\n## ", "\n### ", "\n\n", "\n", "。", ";", "!", "?", ",", " ", ""]
def default_len(s: str) -> int:
"""粗略 token 估算:中文 1 字≈1 token,英文 4 字符≈1 token。
生产上必须换成模型自带 tokenizer,字符数估算会低估 30% 以上。"""
zh = len(re.findall(r"[\u4e00-\u9fff]", s))
return zh + (len(s) - zh) // 4
class RecursiveSplitter:
def __init__(self, chunk_size: int = 500, overlap: int = 80,
separators: list[str] | None = None,
len_fn: Callable[[str], int] = default_len):
if overlap >= chunk_size:
raise ValueError("overlap 必须小于 chunk_size,否则会死循环")
self.chunk_size, self.overlap = chunk_size, overlap
self.separators = separators or SEPARATORS
self.len_fn = len_fn
def _split_recursive(self, text: str, seps: list[str]) -> list[str]:
if self.len_fn(text) <= self.chunk_size:
return [text] if text.strip() else []
if not seps:
# 所有分隔符都用尽,只能硬切(代码块 / 无标点长文本会走到这里)
step = max(self.chunk_size - self.overlap, 1)
return [text[i:i + self.chunk_size] for i in range(0, len(text), step)]
sep, rest = seps[0], seps[1:]
parts = text.split(sep) if sep else list(text)
pieces, buf = [], ""
for p in parts:
cand = (buf + sep + p) if buf else p
if self.len_fn(cand) <= self.chunk_size:
buf = cand
else:
if buf:
pieces.append(buf)
# 单个 part 还是太长 → 用更细的分隔符继续降级
if self.len_fn(p) > self.chunk_size:
pieces.extend(self._split_recursive(p, rest))
buf = ""
else:
buf = p
if buf:
pieces.append(buf)
return [x for x in pieces if x.strip()]
def _add_overlap(self, pieces: list[str]) -> list[str]:
if self.overlap <= 0 or len(pieces) <= 1:
return pieces
out = [pieces[0]]
for i in range(1, len(pieces)):
tail = pieces[i - 1][-self.overlap:]
out.append(tail + pieces[i])
return out
def split(self, text: str, doc_id: str, title_path: str = "") -> list[Chunk]:
pieces = self._add_overlap(self._split_recursive(text, self.separators))
chunks = []
for i, p in enumerate(pieces):
# 标题路径写进正文前缀:补回「这段话在讲什么」的上下文
body = f"{title_path}\n{p}" if title_path else p
chunks.append(Chunk(
text=body, index=i,
metadata={"doc_id": doc_id, "chunk_index": i,
"title_path": title_path,
"n_tokens": self.len_fn(body)},
))
return chunks配套:保护表格和代码块不被切断
python
BLOCK_RE = re.compile(r"(```.*?```|(?:^\|.*\|$\n?)+)", re.S | re.M)
def split_protect_blocks(text: str, splitter: RecursiveSplitter,
doc_id: str, title_path: str = "") -> list[Chunk]:
"""代码块 / Markdown 表格整块保留(宁可超长),其余部分正常递归切分。"""
out, idx = [], 0
for seg in BLOCK_RE.split(text):
if not seg or not seg.strip():
continue
is_block = seg.lstrip().startswith("```") or seg.lstrip().startswith("|")
parts = [seg] if is_block else [c.text for c in
splitter.split(seg, doc_id, title_path="")]
for p in parts:
body = f"{title_path}\n{p}" if title_path else p
out.append(Chunk(text=body, index=idx, metadata={
"doc_id": doc_id, "chunk_index": idx,
"title_path": title_path, "is_block": is_block,
}))
idx += 1
return out边界 Case 自测清单
- [ ]
overlap >= chunk_size→ 构造时直接抛异常(否则无限循环) - [ ] 一整段 2000 字无任何标点 → 降级到最后的
""分隔符硬切,不会丢内容也不会死循环 - [ ] 文本长度本来就小于 chunk_size → 返回 1 个 chunk,不做任何切分
- [ ] 空字符串 / 纯空白 → 返回空列表,不产生空 chunk(空 chunk 会算出零向量)
- [ ] Markdown 表格横跨 chunk_size → 整块保留,表头不丢
- [ ] 代码块中间有空行 → 不会在空行处被切开
- [ ] 每个 chunk 的
n_tokens→ 全部 ≤ 模型max_seq_len(加了title_path前缀后重新校验) - [ ]
doc_id/chunk_index→ 全部非空且连续,这是后续按 doc 删除重插的前提 - [ ] 相邻 chunk 拼接后 → 覆盖原文全部内容(只多 overlap,不少字)
变种拓展题
- 把
len_fn换成真正的 tokenizer(transformers.AutoTokenizer),对比字符估算低估了多少。 - 实现父子块:子块用于召回(200 token),父块用于喂给 LLM(1200 token),
metadata里存parent_id,命中子块后按parent_id去重取父块。 - 实现句子窗口检索:按单句建向量,命中后把前后各 2 句拼成上下文返回。
- 实现语义切分:相邻句 embedding 相似度低于分位数阈值处切开,和本题结果做召回率对比。
- 解析 Markdown 标题树,自动生成
产品手册 > 售后 > 保修政策这样的title_path,而不是手动传入。 - 加 chunk 级内容哈希,文档更新时只重算变化的 chunk,省 embedding 成本。
题 3:生产级 Embedding 服务(批量 + 带模型版本的缓存 + 超长校验 + 限流重试 + 归一化)
这题是 Day23 「生产级 LLM 客户端」的向量版。核心考点:cache key 必须带模型版本、query/passage 指令前缀不对称、超长必须主动拦截。
python
import asyncio, hashlib, logging
import numpy as np
from dataclasses import dataclass
log = logging.getLogger("embed")
@dataclass
class EmbedConfig:
model: str = "bge-large-zh-v1.5"
version: str = "v1.5" # 换模型/换版本必须改,否则缓存会串味
dim: int = 1024
max_seq_len: int = 512
batch_size: int = 32
max_concurrency: int = 4
max_retries: int = 3
# bge 系列:只给 query 加前缀;e5 系列:query/passage 两边都要加
query_prefix: str = "为这个句子生成表示以用于检索相关文章:"
passage_prefix: str = ""
class EmbeddingService:
def __init__(self, cfg: EmbedConfig, client, tokenizer=None, cache=None):
self.cfg, self.client, self.tok = cfg, client, tokenizer
self.cache = cache # 任意实现了 get/set 的后端
self._sem = asyncio.Semaphore(cfg.max_concurrency)
# ---------- key / 校验 ----------
def _cache_key(self, text: str, is_query: bool) -> str:
h = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
# 模型名 + 版本 + 角色 全部进 key,缺一个就会读到错误空间的向量
role = "q" if is_query else "p"
return f"emb:{self.cfg.model}:{self.cfg.version}:{role}:{h}"
def _n_tokens(self, text: str) -> int:
if self.tok is None:
return len(text) # 退化估算,生产必须传 tokenizer
return len(self.tok.encode(text, add_special_tokens=True))
def _prepare(self, text: str, is_query: bool) -> str:
if not text or not text.strip():
raise ValueError("空文本不能做 embedding(会产生零向量)")
prefix = self.cfg.query_prefix if is_query else self.cfg.passage_prefix
out = prefix + text
n = self._n_tokens(out)
if n > self.cfg.max_seq_len:
# 关键:绝不静默截断。超长要么上游重新切分,要么显式降级
raise ValueError(
f"文本 {n} tokens 超过 max_seq_len={self.cfg.max_seq_len},"
f"请在切分阶段处理,不要让模型静默截断"
)
return out
# ---------- 核心 ----------
async def _call_batch(self, batch: list[str]) -> list[list[float]]:
delay = 0.5
for attempt in range(self.cfg.max_retries + 1):
try:
async with self._sem:
resp = await self.client.embeddings.create(
model=self.cfg.model, input=batch)
# 必须按 index 回排,不能依赖返回顺序
items = sorted(resp.data, key=lambda d: d.index)
return [d.embedding for d in items]
except Exception as e:
if attempt >= self.cfg.max_retries:
raise
log.warning("embed 失败 %s,第 %d 次重试", e, attempt + 1)
await asyncio.sleep(delay)
delay *= 2
async def encode(self, texts: list[str], is_query: bool = False) -> np.ndarray:
prepared = [self._prepare(t, is_query) for t in texts]
vectors: list[np.ndarray | None] = [None] * len(prepared)
todo: list[int] = []
# 1) 查缓存
for i, t in enumerate(prepared):
v = self.cache.get(self._cache_key(t, is_query)) if self.cache else None
if v is not None:
vectors[i] = np.asarray(v, dtype=np.float32)
else:
todo.append(i)
# 2) 未命中的分批并发
batches = [todo[i:i + self.cfg.batch_size]
for i in range(0, len(todo), self.cfg.batch_size)]
results = await asyncio.gather(
*[self._call_batch([prepared[i] for i in b]) for b in batches])
for b, embs in zip(batches, results):
for i, e in zip(b, embs):
v = np.asarray(e, dtype=np.float32)
if v.shape[0] != self.cfg.dim:
raise ValueError(f"维度异常: {v.shape[0]} != {self.cfg.dim}")
vectors[i] = v
if self.cache:
self.cache.set(self._cache_key(prepared[i], is_query), e)
m = np.vstack(vectors)
# 3) 统一 L2 归一化:出口只有一个方向,下游一律按 IP 用
norms = np.linalg.norm(m, axis=1, keepdims=True)
if (norms < 1e-12).any():
raise ValueError("出现零向量,检查上游是否有空白文本")
return m / norms边界 Case 自测清单
- [ ] 同一文本,
is_query=True与False→ cache key 不同(bge 的 query 带前缀,向量本来就不同) - [ ] 把
version从v1.5改成v2→ 全部缓存失效,不会读到旧模型的向量 - [ ] 空字符串 / 纯空格 → 在
_prepare阶段抛异常,不会进到向量库 - [ ] 文本 token 数超
max_seq_len→ 显式抛异常,而不是让服务端静默截断 - [ ] 返回维度与
cfg.dim不符 → 抛异常(换模型忘改配置时第一时间暴露) - [ ] 一次传 1000 条 → 自动分 32 条一批、并发上限 4,不打爆上游
- [ ] 上游返回乱序 → 按
index回排后与输入顺序严格对应 - [ ] 部分命中缓存 → 只对未命中的发请求,且最终结果顺序与入参一致
- [ ] 输出向量 → 每行
norm == 1.0(误差 1e-6 内) - [ ] 连续失败超过
max_retries→ 向上抛出,不返回半成品
变种拓展题
- 加令牌桶限流(按 TPM 而非 QPS),把预估 token 数作为消耗量,复用 Day23 的限流器。
- 支持多后端路由:本地 vLLM / 云端 API 按模型注册表切换,失败时跨后端故障转移。
- 把缓存换成 Redis,并用 pipeline 做批量
mget,减少网络往返。 - 加可观测性:上报 batch 大小分布、缓存命中率、P95 延迟、超长拒绝次数。
- 实现
encode_sync,内部用asyncio.run适配同步调用方,注意不能在已有事件循环里调用。 - 支持 MRL 维度截断:按
cfg.truncate_dim截断后重新归一化,并在 cache key 里带上截断维度。
题 4:向量库端到端(建 schema + 索引 + 批量插入 + pre-filter 检索 + 按 doc_id 更新删除)
这题考的不是 API 熟不熟,而是有没有踩过 load、pre-filter、tenant 注入、僵尸 chunk 这四个坑。
python
from pymilvus import (MilvusClient, DataType)
import numpy as np
COLL = "kb_chunks"
DIM = 1024
def create_collection(client: MilvusClient):
if client.has_collection(COLL):
return
schema = client.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("pk", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=DIM)
schema.add_field("text", DataType.VARCHAR, max_length=4096)
# 下面这些标量字段是 pre-filter 和「按 doc 删除」的前提,不能图省事不建
schema.add_field("doc_id", DataType.VARCHAR, max_length=64)
schema.add_field("chunk_index", DataType.INT64)
schema.add_field("tenant_id", DataType.VARCHAR, max_length=64)
schema.add_field("doc_type", DataType.VARCHAR, max_length=32)
schema.add_field("update_ts", DataType.INT64) # 存时间戳,不要存字符串
index = client.prepare_index_params()
# 入库前已 L2 归一化 → 用 IP,等价于 COSINE 且更快
index.add_index("vector", index_type="HNSW", metric_type="IP",
params={"M": 16, "efConstruction": 200})
# 标量索引:没有它,pre-filter 会退化成全表扫描
for f in ("doc_id", "tenant_id", "doc_type", "update_ts"):
index.add_index(f, index_type="INVERTED")
client.create_collection(COLL, schema=schema, index_params=index)
client.load_collection(COLL) # 关键:不 load 查询直接报错 / 返回空python
def upsert_doc(client: MilvusClient, tenant_id: str, doc_id: str,
chunks: list[dict], vectors: np.ndarray, doc_type: str, ts: int):
"""「更新」= 按 doc_id 整篇删除 + 整篇重插。
绝不能按 chunk_index 逐条覆盖:文档改短了,多出来的老 chunk
会永远留在库里被召回,就是所谓的僵尸 chunk。
"""
assert len(chunks) == vectors.shape[0] == len(chunks)
assert vectors.shape[1] == DIM, f"维度不符: {vectors.shape[1]}"
client.delete(COLL, filter=f'tenant_id == "{tenant_id}" and doc_id == "{doc_id}"')
rows = [{
"vector": vectors[i].tolist(),
"text": c["text"],
"doc_id": doc_id,
"chunk_index": i,
"tenant_id": tenant_id,
"doc_type": doc_type,
"update_ts": ts,
} for i, c in enumerate(chunks)]
# 分批插入,单批过大容易超 gRPC 消息上限
for i in range(0, len(rows), 500):
client.insert(COLL, rows[i:i + 500])
def search(client: MilvusClient, tenant_id: str, query_vec: np.ndarray,
top_k: int = 50, doc_types: list[str] | None = None,
since_ts: int | None = None, ef: int = 128):
"""tenant_id 是必填位置参数,expr 由内部拼接 —— 不把 expr 透传给业务,
否则就是一个越权读取的口子。"""
conds = [f'tenant_id == "{tenant_id}"'] # 强制注入,不可绕过
if doc_types:
types = ", ".join(f'"{t}"' for t in doc_types)
conds.append(f"doc_type in [{types}]")
if since_ts is not None:
conds.append(f"update_ts >= {since_ts}")
expr = " and ".join(conds)
res = client.search(
COLL,
data=[query_vec.tolist()],
filter=expr, # pre-filter:条件下推到索引层,保证返回 K 条
limit=top_k,
search_params={"metric_type": "IP", "params": {"ef": max(ef, top_k)}},
output_fields=["text", "doc_id", "chunk_index", "doc_type", "update_ts"],
consistency_level="Bounded", # 写完立刻要查到就用 "Strong"
)
hits = res[0]
# IP 下 distance 越大越相似;封装层统一转成「越大越好」的 score,
# 业务侧永远只认一个方向,避免 L2 / IP 混用时把符号写反
return [{
"score": float(h["distance"]),
**{k: h["entity"][k] for k in
("text", "doc_id", "chunk_index", "doc_type", "update_ts")},
} for h in hits]
def delete_doc(client: MilvusClient, tenant_id: str, doc_id: str):
client.delete(COLL, filter=f'tenant_id == "{tenant_id}" and doc_id == "{doc_id}"')
# 注意是标记删除:空间不会立刻释放,tombstone 堆积会拖累 HNSW 召回,
# 需要定期 compact 或走影子 collection 重建边界 Case 自测清单
- [ ] 新建 collection 后不
load直接查 → 复现报错,确认自己知道这个坑 - [ ] 插入维度不符的向量 → 被 assert 拦住,而不是插进去污染索引
- [ ] 文档从 20 chunk 改成 15 chunk 后重新 upsert → 库里只剩 15 条(无僵尸 chunk)
- [ ] 用租户 A 的身份检索 → 结果里绝不出现租户 B 的数据
- [ ]
search不传tenant_id→ 调用处直接TypeError(位置参数而非可选项) - [ ] 过滤条件很严格(只剩 3 条符合)→ 仍能返回这 3 条,不会因 post-filter 返回空
- [ ]
ef < top_k→ 被max(ef, top_k)纠正,否则部分引擎直接报错 - [ ] 写入后立刻用
Bounded查 → 可能查不到;改Strong后必定可见 - [ ] 删除不存在的
doc_id→ 幂等,不抛异常 - [ ]
update_ts传字符串 → schema 拦住(早期用字符串存日期,跨年比较会出错)
变种拓展题
- 抽出统一的
VectorStore接口(upsert_doc / search / delete_doc / count_by_doc),再写 Chroma、pgvector 两个实现,验证业务代码零改动。 - 实现 collection 别名切换:影子 collection 全量重建完成后原子切别名,支持秒级回滚。
- 加对账任务:比对原文表的 chunk 数与向量库实际条数,不一致则告警(真实事故的防线)。
- 用 partition 按
tenant_id做物理隔离,对比 filter 方案在高选择性下的延迟差异。 - 实现灌库任务:并发 + 部分失败容错 + 逐条落盘 + 断点续跑(复用 Day23 第 16 节的批量编排)。
- 实现
recall@K评测:同一批 query 分别走HNSW和FLAT,量化ef从 32 到 256 的召回率与 P95 延迟曲线。
题 5:混合检索管线(向量 + BM25 → RRF 融合 → rerank 精排 → 降级兜底)
RAG 检索侧的「最终形态」。考点:RRF 为什么只用排名、两段式 TopK、rerank 超时必须能降级。
python
import asyncio, logging
from dataclasses import dataclass
log = logging.getLogger("retrieve")
@dataclass
class Doc:
id: str # 唯一标识,通常是 f"{doc_id}:{chunk_index}"
text: str
meta: dict
def rrf_fuse(rank_lists: list[list[Doc]], k: int = 60,
weights: list[float] | None = None) -> list[tuple[Doc, float]]:
"""Reciprocal Rank Fusion:score = Σ w_i / (k + rank_i),rank 从 1 开始。
只用排名不用分数,所以 BM25(无上界)和余弦([-1,1])可以直接融合,
不需要做那种永远调不准的分数归一化。
"""
weights = weights or [1.0] * len(rank_lists)
scores: dict[str, float] = {}
pool: dict[str, Doc] = {}
for w, lst in zip(weights, rank_lists):
for rank, d in enumerate(lst, start=1):
scores[d.id] = scores.get(d.id, 0.0) + w / (k + rank)
pool.setdefault(d.id, d) # 同一 id 去重,保留首次出现的内容
ranked = sorted(scores.items(), key=lambda kv: -kv[1])
return [(pool[i], s) for i, s in ranked]
class HybridRetriever:
def __init__(self, vector_store, bm25_store, embedder, reranker,
recall_k: int = 50, final_k: int = 5,
rerank_timeout: float = 1.5):
self.vs, self.bm25 = vector_store, bm25_store
self.embedder, self.reranker = embedder, reranker
self.recall_k, self.final_k = recall_k, final_k
self.rerank_timeout = rerank_timeout
async def retrieve(self, tenant_id: str, query: str, **filters):
# 1) 两路并行召回:宽召回(50)交给精排去收窄,不要一上来就只取 5 条
qv_task = self.embedder.encode([query], is_query=True)
bm_task = self.bm25.search(tenant_id, query, top_k=self.recall_k, **filters)
qv, bm_hits = await asyncio.gather(qv_task, bm_task)
vec_hits = await self.vs.search(tenant_id, qv[0],
top_k=self.recall_k, **filters)
if not vec_hits and not bm_hits:
return [] # 零召回要能被监控统计到
# 2) RRF 融合(向量路权重略高,可按离线评测调)
fused = rrf_fuse([vec_hits, bm_hits], k=60, weights=[1.0, 0.7])
candidates = [d for d, _ in fused][:self.recall_k]
# 3) cross-encoder 精排;超时/异常必须降级到融合顺序,不能让整条链路挂掉
try:
scored = await asyncio.wait_for(
self.reranker.rank(query, candidates), timeout=self.rerank_timeout)
scored.sort(key=lambda x: -x[1])
top = [d for d, _ in scored[:self.final_k]]
source = "rerank"
except (asyncio.TimeoutError, Exception) as e:
log.warning("rerank 降级: %s", e)
top = candidates[:self.final_k]
source = "rrf_fallback" # 打点上报,便于统计降级率
# 4) 同一文档的多个 chunk 去重,避免 TopK 被一篇文档占满
seen, out = set(), []
for d in top:
key = d.meta.get("doc_id")
if key in seen:
continue
seen.add(key)
out.append(d)
log.info("retrieve source=%s n=%d", source, len(out))
return out边界 Case 自测清单
- [ ] 两路都返回空 → 返回空列表且被「零召回率」指标统计到,不抛异常
- [ ] 只有一路有结果 → RRF 退化为该路原顺序,结果正常
- [ ] 两路返回同一 id → 去重且分数累加(命中两路的文档应该排更前)
- [ ] BM25 分数量级远大于余弦 → 融合结果不受影响(验证 RRF 与分数无关)
- [ ]
k取 1 和 60 → 对比排名靠前文档的边际权重差异 - [ ] reranker 超时 1.5s → 走
rrf_fallback,链路仍返回结果且降级被打点 - [ ] reranker 抛异常 → 同样降级,不把异常抛给上层
- [ ] 一篇文档贡献 10 个 chunk → 去重后 TopK 里该文档只占 1 条
- [ ]
final_k > recall_k→ 返回数量不超过实际候选数 - [ ] 带
tenant_id过滤 → 两路都注入了租户条件(BM25 这一路最容易漏)
变种拓展题
- 加 multi-query:LLM 把问题改写成 3 个变体并行召回,三路一起进 RRF;改写结果做缓存。
- 加指代消解:多轮对话中先把「它多少钱」还原成「iPhone 17 多少钱」再检索。
- 实现 HyDE:先让 LLM 虚构一段答案,用它的向量检索,和原始 query 两路融合。
- 用 MMR 替换「按 doc_id 去重」,在相关性与多样性之间按 λ 连续权衡。
- 加父子块还原:召回小块,返回前按
parent_id查出父块并去重合并。 - 给整条链路加 trace:每一跳的候选 id 列表、耗时、降级标记落日志,用于复盘「答案到底在第几步被丢掉」。
- 把 RRF 换成加权分数归一化融合,用离线评测集对比两者的
hit@3,用数据说明为什么选 RRF。
⚠️ 当日踩坑实录
一、Embedding 模型与向量本身
| ❌ 坑 | ✅ 方案 |
|---|---|
入库用 bge-large-zh,检索时用 text-embedding-3 | 两套向量空间不对齐,相似度毫无意义。模型名 + 版本写进 collection 的说明和配置,客户端启动时校验维度一致 |
| 向量维度和 collection schema 不符,插入直接失败 | 维度在配置里只写一处(EmbedConfig.dim),建表和插入都从它取;插入前加 assert |
| query 忘了加 bge 的指令前缀 | 非对称检索下召回明显下降。前缀收进 EmbeddingService,encode(texts, is_query=True) 自动处理,不让调用方拼 |
| e5 系列只给 query 加前缀、passage 没加 | e5 是两边都要加(query: / passage:)。换模型时必须重读模型卡的使用示例 |
文本超过 max_seq_len,模型静默截断不报错 | 尾部内容对向量零贡献,还很难发现。入库前用模型 tokenizer 校验 token 数,超长直接抛异常让上游重新切分 |
| 用字符数估 token,结果低估 30% 以上 | 中文 1 字 ≈ 1 token、英文 4 字符 ≈ 1 token 只能做粗估,生产一律用 AutoTokenizer |
| 空白页 OCR 出空字符串 → 零向量 → 归一化后变 NaN | 插库能成功但检索结果整体乱掉,极难排查。l2_normalize 里对模长 < 1e-12 直接抛异常,不要除零也不要静默返回 0 |
| 自己换了池化方式(CLS 改 mean)效果变差 | 池化方式必须和模型发布时一致,换池化等价于换了一个模型 |
| Embedding 缓存 key 只用文本哈希 | 换模型后读到旧空间的向量,而且完全不报错。key 必须是 模型名:版本:角色:文本哈希 |
二、相似度度量与分数
| ❌ 坑 | ✅ 方案 |
|---|---|
| 「检索一律用余弦」 | 不严谨。度量要和模型训练时一致;归一化后 COSINE / IP / L2 的 TopK 顺序本来就相同,差别只在开销 |
| 没归一化就用内积 | 内积偏向模长大的向量,长文本 chunk 会「不管问什么都排前面」。出口统一 L2 归一化 |
把 Milvus 的 distance 当相似度用 | L2 越小越相似,IP/COSINE 越大越相似。封装层统一转成「越大越好」的 score,符号写反不会报错,只是效果烂 |
照搬网上的 0.75 相似度阈值 | 不同模型分数分布差异极大,中文 bge 随机两句话也常在 0.6~0.8。用自己的正负样本集标定,或改用相对阈值 / 交给 reranker 分数裁 |
| 稀疏向量(BM25)也做了归一化 | 破坏 term 权重语义。稀疏向量用内积且不归一化 |
| MRL 截断维度后直接用 | v[:256] 之后必须重新 L2 归一化,否则模长不再为 1,内积不等于余弦 |
三、文本切分
| ❌ 坑 | ✅ 方案 |
|---|---|
| chunk 切得太大(2000+ 字符),一块混了多个主题 | 向量被平均成「四不像」,所有分数都中不溜。中文 300~800 字,按标题 / 段落 / 句子递归降级切 |
| chunk 切得太小,「它的保修期是 2 年」丢了主语 | 召回了也没用。overlap 取 10%~20%,并给每个 chunk 加标题路径前缀 |
| overlap 设成 50% 以上 | 存储、embedding 成本和召回重复度都翻倍,收益极小 |
overlap >= chunk_size | 切分循环永远不前进。构造器里直接校验抛异常 |
| Markdown 表格被腰斩,表头和数据分到两个 chunk | 表格 / 代码块整块保留,宁可超长;大表按行分组但每组重复表头 |
| PDF 按页切 | 一段话被拦腰截断,还混入页眉页脚。先解析版面结构再按语义切 |
入库没留 doc_id / chunk_index | 文档更新时找不到对应 chunk,也做不了溯源引用。metadata 是必填项 |
| 多轮对话历史直接入库 | 「那它多少钱」这种 chunk 不可检索。入库前先做指代消解 |
四、索引、过滤与多租户
| ❌ 坑 | ✅ 方案 |
|---|---|
| 几十万条向量没建索引,走暴力扫描 | 延迟直接上秒级。超过万级就该建 HNSW / IVF;万级以下用 Flat 反而更合适 |
建了索引但没 load_collection | Milvus 存算分离,不 load 查询会报错或返回空。建表、重建索引、服务启动后都要确认 load 完成 |
| 以为数据量小也要上索引 | 几千条时索引开销大于收益,Flat 更快,还能当召回率的 ground truth 基线 |
改了 HNSW 的 M / efConstruction 以为能热生效 | 这是建索引期参数,必须重建;只有 ef(查询期)能线上动态调 |
ef < top_k | 部分引擎直接报错或召回不足。ef = max(ef, top_k) 兜底 |
| 用 post-filter:先 ANN 取 TopK 再过滤 | TopK 缩水甚至返回空,而库里其实有大量符合条件的数据。必须用 pre-filter 把条件下推到索引层 |
| pre-filter 用了但标量字段没建索引 | 过滤退化成全表扫描。doc_id / tenant_id / doc_type / update_ts 都要建 INVERTED 索引 |
| 过滤条件选择性极高(只剩几十条) | 图索引连通性被破坏,可能退化成暴力扫描。改用 partition 物理隔离更合适 |
把 expr 参数直接透传给业务层 | 这是越权读取的口子。tenant_id 做必填位置参数,expr 由封装层拼接,并加「不传就抛异常」的单测 |
| 时间字段存成字符串 | >= 比较跨年时直接出错。统一存 int64 时间戳 |
| 指望语义检索理解「最近三个月」 | 时间是结构化属性,必须走 metadata 过滤 |
| 把 FAISS 当数据库用 | 重启丢数据、无法增删改、无 metadata 过滤、无鉴权。FAISS 是索引库,生产要用 Milvus / Qdrant / pgvector |
五、更新、删除与一致性
| ❌ 坑 | ✅ 方案 |
|---|---|
文档从 20 chunk 改成 15 chunk,按 chunk_index 逐条 upsert | 老的第 16~20 条成为僵尸 chunk,内容作废却照样被召回,而且答案很通顺没人察觉。一律 delete(doc_id) + 整篇重插 |
| 以为向量库有 UPDATE 语义 | 基本没有。所有更新都是「先删后插」,前提是入库时写了 doc_id |
| 大批量删除后检索变慢、召回下降 | 标记删除留下 tombstone,破坏 HNSW 图连通性。定期 compact,或用 partition drop 代替海量条件删除 |
| 刚写入立刻查,查不到就以为写失败 | 向量库普遍最终一致。确认 flush / load 状态,或把 consistency_level 改成 Strong(代价是延迟) |
| 换 embedding 模型后增量灌库 | 新旧向量混在一个 collection 里,相似度彻底失效。必须全量重建:影子 collection + 评测对比 + 灰度切流 + 别名重指向 |
| 在线上 collection 直接 drop index 重建 | 重建期间检索不可用。走影子 collection + alias 原子切换,可秒级回滚 |
| 原文只存在向量库里 | 换模型、重建索引时要重新爬一遍数据源。原文必须独立持久化(对象存储 / MySQL),向量库只当索引 |
| 没有对账 | chunk 数与向量条数长期不一致也没人知道。加每日对账任务,不一致就告警 |
六、检索质量与工程化
| ❌ 坑 | ✅ 方案 |
|---|---|
| 直接取 TopK=3 进 prompt | 召回和精排混成一步。召回放宽到 50 + cross-encoder 精排到 3~5,这是单项收益最大的改动 |
| 两路分数直接加权相加做混合检索 | BM25 无上界、余弦在 [-1,1],量纲不可比。用 RRF(只看排名)融合 |
| 用 LLM 两两判断相似度做召回 | O(N) 次推理,延迟和成本都不可接受,还不可索引。LLM 只用在精排后的少量候选上 |
| rerank 服务超时导致整条链路 500 | rerank 必须可降级到融合顺序,并统计降级率 |
| TopK 被同一篇文档的多个 chunk 占满 | 按 doc_id 去重,或用 MMR 在相关性与多样性之间权衡 |
| 没有评测集就开始调参 | 每次改动全靠直觉。先建 50~200 条标注 query,输出 recall@50 / hit@3 / MRR@10 |
把 recall@K 和「答对率」当同一件事 | recall@K 只衡量 ANN 相对精确检索的损失,它等于 1.0 也完全可能答错。要分索引层 / 检索层 / 端到端三层看 |
| 排查「检索不准」时直接改 prompt | 先看正确答案有没有出现在召回的 50 条里:不在是召回问题,在但没进前 3 是精排问题,进了还答错才是 prompt 问题 |
| 线上只监控平均相似度分数 | 发现不了问题。要盯 P95 延迟、零召回占比、rerank 降级率、「未找到相关信息」占比 |
| 向量当成脱敏数据到处传 | 向量可被反演出近似原文,属于敏感数据。按原文同级做权限、加密与审计,禁止跨租户复用 |
📌 当日复盘任务
一、原理自测(不看文档口述)
- [ ] 画出 RAG 的离线灌库链路和在线检索链路,并说出每一步可能出错的地方
- [ ] 解释 Embedding 模型和 LLM 的区别,以及为什么召回阶段不能用 LLM 两两判相似度
- [ ] 推导
‖A-B‖² = 2 - 2cos(A,B),并解释为什么归一化后 COSINE / IP / L2 的 TopK 顺序相同 - [ ] 说清 ANN「用召回率换速度」的本质,以及 IVF 的桶边界问题和 HNSW 的分层跳跃原理
- [ ] 讲清 pre-filter 与 post-filter 的差别,以及 post-filter 为什么会返回空
- [ ] 讲清 bi-encoder 与 cross-encoder 的本质差别,以及为什么 rerank 只能用于少量候选
- [ ] 手写 RRF 公式并给出一个两路融合的算例(能算出具体分数和最终排名)
- [ ] 列出向量检索解决不了的四类问题,以及各自应该交给谁
二、概念与参数辨析
- [ ] 区分
recall@K、hit@K、MRR、NDCG、端到端正确率各自衡量什么 - [ ] 区分 HNSW 的建索引期参数(
M/efConstruction)与查询期参数(ef) - [ ] 说出
nlist ≈ 4√N、nprobe起调值的来历,以及nprobe调大的代价 - [ ] 对比 Milvus / Chroma / FAISS / Qdrant / pgvector / ES 的适用边界,并解释「为什么先不上重组件」
- [ ] 说出 float32 / float16 / SQ8 / PQ 的压缩比与精度代价,以及 MRL 截断后为何必须重新归一化
- [ ] 说清
consistency_level的几个级别,以及「刚写入查不到」的完整排查顺序 - [ ] 解释为什么 Embedding 缓存 key 必须带模型版本号
三、动手实现(跑通为准)
- [ ] 实现余弦相似度 +
l2_normalize(含零向量防护)+ 矩阵化topk_search,并验证归一化前后 TopK 顺序一致 - [ ] 实现递归切分器,用一篇真实长文档跑通,人工抽读 20 个 chunk 判断是否语义完整
- [ ] 实现
EmbeddingService:批量 + 带版本的缓存 + 超长拦截 + 重试 + 统一归一化 - [ ] 用 Milvus 或 pgvector 跑通端到端:建表 → 建索引 → load → 插入 → pre-filter 检索 → 按
doc_id更新删除 - [ ] 复现僵尸 chunk 事故:先按
chunk_indexupsert 一篇变短的文档,确认旧 chunk 还能被召回,再改成整篇删除重插 - [ ] 复现不 load 直接查、post-filter 返回空、零向量变 NaN 三个坑,亲手看一次现象
- [ ] 实现混合检索 + RRF + rerank 管线,并验证 rerank 超时能正确降级
四、工程化与度量
- [ ] 建一个 30~50 条 query 的小评测集(可用 LLM 反向生成问题 + 人工校验)
- [ ] 用 Flat 结果作为 ground truth,扫
ef从 32 到 256,画出recall@10与 P95 延迟曲线,选出工作点 - [ ] 对比「只向量」「只 BM25」「混合 + RRF」「混合 + RRF + rerank」四种配置的
hit@3,用数据说话 - [ ] 用自己的正负样本集标定相似度阈值,确认网上流传的 0.75 在你的模型上是什么含义
- [ ] 抽出
VectorStore统一接口,至少写两个实现(如 Chroma + pgvector),验证换库业务代码零改动 - [ ] 设计一次换 embedding 模型的完整方案:影子 collection → 全量重建 → 评测对比 → 灰度 → 别名切换 → 回滚预案
- [ ] 列出要上线的监控项:P95 延迟、零召回占比、top1 分数漂移、rerank 降级率、「未找到相关信息」占比
- [ ] 检查多租户隔离:
tenant_id是否为必填参数、是否有「不传就抛异常」的单测、BM25 那一路是否也注入了
五、面试输出与串联
- [ ] 把 Q1~Q10 的「90 秒口述答案」各讲一遍并录音,重点听结论先行和有没有说出数字
- [ ] 准备一个属于自己的检索优化故事:问题现象 → 如何定位是召回还是精排 → 改了什么 → 指标提升多少
- [ ] 准备一个属于自己的事故故事(僵尸 chunk / 零召回 / 越权 expr 任选),说清根因与之后加的防线
- [ ] 回看 Day23:把
EmbeddingService与LLMClient的封装思路对齐(限流、重试、缓存、可观测、降级是同一套方法论) - [ ] 预习 Day25:RAG 全链路落地——把今天的检索管线接上 prompt 组装、引用溯源、忠实度评测与拒答策略