Skip to content

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 系)或专门微调的 encoderdecoder-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取最后一个 tokendecoder 结构的 embedding 模型(如部分 Qwen-Embedding)用

记住结论:池化方式由模型决定,不能自己换。自己改池化会让向量空间失配,检索效果断崖下跌。

3. 向量的三个工程属性:维度、归一化、精度 ​

① 维度决定表达能力和成本,不是越大越好:

维度代表模型单条存储(float32)说明
384bge-small-zh1.5 KB轻量,适合边缘/海量低成本场景
768bge-base-zh、m3e-base3 KB性价比主力
1024bge-large-zh、bge-m34 KB中文检索常用上限
1536 / 3072text-embedding-3-small / large6 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 不是必须的。

精度体积召回损失适用
float32100%0基线、小数据量
float1650%可忽略通用优化
int8(SQ8)25%1~3%大规模主流选择
PQ 乘积量化5%~10%5%~15%超大规模,必须配 rerank 补偿
binary(1bit)3%较大粗筛 + 原向量精排的两段式

4. 相似度度量:先纠正一个常见的错误说法 ​

草稿式说法「向量检索优先用余弦相似度」不够准确,准确结论是:度量方式必须和 Embedding 模型训练时用的那个一致,而主流检索模型训练时就是按余弦/内积优化的,所以才「看起来总是余弦」。

度量公式范围方向
余弦相似度 COSINEA·B / (‖A‖‖B‖)-1~1(实际常在 0~1)越大越相似
内积 IPA·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.51024中文检索老牌强基线,开源可私有化纯中文知识库
bge-m31024多语言 + 同时产出 dense / sparse / colbert 三种表示,天然支持混合检索中英混排、要做混合检索
text-embedding-3-small/large1536 / 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

一次检索请求的参数:

参数含义坑
dataquery 向量(可多条,批量检索)维度必须和 collection 一致,否则直接报错
limit / TopK返回条数给 LLM 的最终条数建议 3~5;但召回阶段要多取(50),留给 rerank
filter / expr标量过滤表达式多租户场景必须强制注入 tenant_id ==,不能靠业务自觉
output_fields要带回的标量字段不指定就只返回 id 和 distance,还得回查一次
search_paramsnprobe / 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%
索引召回率查询延迟内存建索引耗时适用
FLAT100%最慢最小(只存向量)0<10 万条;做召回率评测基线
IVF_FLAT高(看 nprobe)中中中百万级,内存有限
IVF_SQ8略降快低(1/4)中千万级省内存
IVF_PQ明显降很快极低较长亿级,必须配 rerank
HNSW最高最快高(图结构额外占用)长主流生产首选
DiskANN高中低(走 SSD)长数据量远超内存时

关键调参对照表(面试必问「怎么权衡」):

参数所属调大的影响
nlistIVF 建索引桶更细、单桶更小 → 更快但更容易漏。经验值 ≈ 4 × √N
nprobeIVF 查询扫更多桶 → 召回↑、延迟↑。在线可调,不用重建索引
MHNSW 建索引每个点的邻居数,召回↑、内存↑、建索引变慢。常用 16~32
efConstructionHNSW 建索引建图时的候选队列,图质量↑、建索引耗时↑。常用 100~500
efHNSW 查询查询候选队列,召回↑、延迟↑。必须 ≥ 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 小中等规模、重过滤场景
pgvectorPostgreSQL 扩展向量和业务数据同库,能 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 是纯函数:同样的文本 + 同样的模型 = 同样的向量,天然可缓存。

设计点做法
缓存 keysha256(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 做二级排序)

变种拓展题

  1. 实现 topk_search_batch(queries, matrix, k):(m, d) @ (d, n) 一次算完 m 个 query,注意内存峰值是 m×n,要分块(chunking)处理。
  2. 实现 recall_at_k(ann_result, flat_result, k),用本题的 Flat 结果作为 ground truth 量化 ANN 索引的召回率。
  3. 加 MRL 维度截断:v[:256] 之后必须重新 L2 归一化,写出带断言的实现。
  4. 实现 MMR(最大边际相关)重排,在相似度和多样性之间按 λ 权衡,解决 TopK 全是重复内容的问题。
  5. 用 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,不少字)

变种拓展题

  1. 把 len_fn 换成真正的 tokenizer(transformers.AutoTokenizer),对比字符估算低估了多少。
  2. 实现父子块:子块用于召回(200 token),父块用于喂给 LLM(1200 token),metadata 里存 parent_id,命中子块后按 parent_id 去重取父块。
  3. 实现句子窗口检索:按单句建向量,命中后把前后各 2 句拼成上下文返回。
  4. 实现语义切分:相邻句 embedding 相似度低于分位数阈值处切开,和本题结果做召回率对比。
  5. 解析 Markdown 标题树,自动生成 产品手册 > 售后 > 保修政策 这样的 title_path,而不是手动传入。
  6. 加 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 → 向上抛出,不返回半成品

变种拓展题

  1. 加令牌桶限流(按 TPM 而非 QPS),把预估 token 数作为消耗量,复用 Day23 的限流器。
  2. 支持多后端路由:本地 vLLM / 云端 API 按模型注册表切换,失败时跨后端故障转移。
  3. 把缓存换成 Redis,并用 pipeline 做批量 mget,减少网络往返。
  4. 加可观测性:上报 batch 大小分布、缓存命中率、P95 延迟、超长拒绝次数。
  5. 实现 encode_sync,内部用 asyncio.run 适配同步调用方,注意不能在已有事件循环里调用。
  6. 支持 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 拦住(早期用字符串存日期,跨年比较会出错)

变种拓展题

  1. 抽出统一的 VectorStore 接口(upsert_doc / search / delete_doc / count_by_doc),再写 Chroma、pgvector 两个实现,验证业务代码零改动。
  2. 实现 collection 别名切换:影子 collection 全量重建完成后原子切别名,支持秒级回滚。
  3. 加对账任务:比对原文表的 chunk 数与向量库实际条数,不一致则告警(真实事故的防线)。
  4. 用 partition 按 tenant_id 做物理隔离,对比 filter 方案在高选择性下的延迟差异。
  5. 实现灌库任务:并发 + 部分失败容错 + 逐条落盘 + 断点续跑(复用 Day23 第 16 节的批量编排)。
  6. 实现 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 这一路最容易漏)

变种拓展题

  1. 加 multi-query:LLM 把问题改写成 3 个变体并行召回,三路一起进 RRF;改写结果做缓存。
  2. 加指代消解:多轮对话中先把「它多少钱」还原成「iPhone 17 多少钱」再检索。
  3. 实现 HyDE:先让 LLM 虚构一段答案,用它的向量检索,和原始 query 两路融合。
  4. 用 MMR 替换「按 doc_id 去重」,在相关性与多样性之间按 λ 连续权衡。
  5. 加父子块还原:召回小块,返回前按 parent_id 查出父块并去重合并。
  6. 给整条链路加 trace:每一跳的候选 id 列表、耗时、降级标记落日志,用于复盘「答案到底在第几步被丢掉」。
  7. 把 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_collectionMilvus 存算分离,不 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 服务超时导致整条链路 500rerank 必须可降级到融合顺序,并统计降级率
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_index upsert 一篇变短的文档,确认旧 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 组装、引用溯源、忠实度评测与拒答策略