阶段3(下):RAG、向量数据库专题;接入 BOSS直聘调研(research/、岗位对照和延伸技术栈);新增 cs 领域和 12 个技术栈骨架;面试题全部补齐追问并加入校验

This commit is contained in:
Shikui Wang committed 2026-10-23 15:56:03 +08:00
1 parent 7f52513ffc
commit 6650f7eb6d
52 files changed
+2777 -38

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
---
id: ai.agent-design.index
title: Agent 设计:规划、上下文工程与多 Agent
type: index
domain: ai
stack: [agent-design]
tags: []
week: [10, 11, 13]
---
# Agent 设计:规划、上下文工程与多 Agent
ReAct、Planning、Context Engineering、Skills、多 Agent 与 A2A、Agentic RAG。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
+15
View File
@@ -0,0 +1,15 @@
---
id: ai.ai-coding.index
title: AI 编程工具与产出验证
type: index
domain: ai
stack: [ai-coding]
tags: []
week: [1, 8, 10, 15]
---
# AI 编程工具与产出验证
Claude Code / Cursor / Codex 工作流、Skills 与规则、评审和测试验证、会话记录作为作品证据。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
+15
View File
@@ -0,0 +1,15 @@
---
id: ai.llm-serving.index
title: 模型接入与部署(了解级)
type: index
domain: ai
stack: [llm-serving]
tags: []
week: [10, 13]
---
# 模型接入与部署(了解级)
多模型 API 路由与降级、vLLM 部署基础、LoRA / QLoRA 概念。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
+114
View File
@@ -0,0 +1,114 @@
---
id: ai.rag.pipeline-chunking
title: RAG 流水线和文档切分:加载、清洗、切分策略、元数据
type: lesson
domain: ai
stack: [rag]
tags: [RAG, 文档加载, 切分, chunk, 元数据, 嵌入]
level: basic
prerequisites: [python.python.syntax-for-js]
est_hours: 3
related: [ai.rag.hybrid-rerank, ai.vector-db.ann-metrics, ai.rag.interview]
week: [5]
order: 1
---
# RAG 流水线和文档切分:加载、清洗、切分策略、元数据
## 解释说明
**为什么需要 RAG**:大模型不知道公司内部资料,也不知道训练截止之后的信息;直接问会“编”(幻觉)。RAG 把相关资料放进提示词,让模型“开卷答题”,并且能给出引用来源。和微调相比:知识更新只需要更新索引,成本低、可追溯。
**两条流水线**
```mermaid
flowchart LR
subgraph 离线:建索引
A[加载文档 PDF/Word/网页] --> B[清洗] --> C[切分 chunk] --> D[嵌入向量] --> E[(向量库 + 元数据)]
end
subgraph 在线:问答
Q[用户问题] --> R[检索 top-k] --> F[权限过滤 / 重排] --> P[拼提示词] --> L[大模型生成] --> O[答案 + 引用]
end
E --> R
```
**加载和清洗**:PDF 的页眉页脚、目录、页码、表格是最常见的噪音;扫描件需要 OCR。清洗质量直接决定检索质量。
**切分(chunking)**——RAG 效果最关键的环节之一:
| 策略 | 做法 | 适合 |
| --- | --- | --- |
| 固定长度 + 重叠 | 每 N 个字符 / token 一块,相邻块重叠一部分 | 没有结构的长文本 |
| 按结构切分 | 按标题、段落、列表切,节太长再细分 | 制度、手册、Markdown、网页(**首选**) |
| 按句子 / 语义切分 | 句子为单位,按语义相似度合并 | 长篇叙述 |
| 父子块 | 用小块检索(精准),返回所在的大块给模型(上下文完整) | 问答需要上下文的场景 |
块的大小:太小(一句话)上下文不完整,“满十年 10 天”不知道说的是年假还是病假;太大则包含多个主题,检索不精准、浪费 token。中文制度类文档常见几百字一块,需要用评测来调。
**元数据**:每个块带上文档 ID、标题、小节路径、页码、更新时间、权限标签。用途:引用来源、按权限过滤、按时间过滤、增量更新时删除旧块。**把标题路径拼进块的正文**(“员工手册 / 年假:……”)能明显改善孤立块的检索。
**嵌入(embedding)**:把文本变成向量,语义相近的文本向量距离近。选择嵌入模型要看:中文效果、向量维度、最大输入长度、价格 / 是否能私有部署。**建索引和查询必须用同一个模型**,换模型要全量重建索引。具体模型和参数以各服务商官方文档为准。
## 案例
公司制度问答第一版:把 PDF 直接按 500 字切分,结果“病假”一节被切成两半,问“病假工资怎么发”检索到的块只有前半段;页眉“内部资料 第 3 页”混进每个块。改进:先转 Markdown 去掉页眉页脚,按标题切分,每块带“文档 / 小节”前缀,节太长再按句子切并保留重叠。
## 代码示例
按标题切分 + 句子级细分 + 重叠 + 标题前缀(摘自 [examples/mini-rag/mini_rag.py](examples/mini-rag/mini_rag.py)):
```python
import re
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
doc_id: str
text: str
acl: set[str]
def split(doc_id: str, title: str, text: str, acl: set[str], max_len: int = 80, overlap: int = 15) -> list[Chunk]:
out: list[Chunk] = []
for sec in re.split(r"\n(?=# )", text.strip()): # 按一级标题分节
heading, _, body = sec.partition("\n")
heading = heading.lstrip("# ").strip()
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
buf, pieces = "", []
for s in sentences: # 节内按句子累积,超长就切
if buf and len(buf) + len(s) > max_len:
pieces.append(buf)
buf = buf[-overlap:] # 保留重叠,避免句子间信息断开
buf += s
if buf:
pieces.append(buf)
for p in pieces:
out.append(Chunk(f"{doc_id}#{len(out)}", doc_id, f"【{title} / {heading}】{p}", acl))
return out
doc = """# 年假
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
# 病假
病假需提供医院证明,每年累计 10 天以内按全额工资发放。"""
for c in split("hr-leave", "员工手册", doc, {"all"}, max_len=40, overlap=8):
print(c.id, c.text)
```
输出中每一块都带有“【员工手册 / 年假】”这样的前缀,即使块本身只有“满十年 10 天”,也能被“年假”相关的问题检索到。
## 易错点
- **不看切分结果就建索引**:一定要抽样打印切分后的块,检查是否切断了表格、步骤、条款。
- **按字符数重叠会截断词语**:上面的输出里第二块以“十年 15 天”开头(原文是“满二十年 15 天”)。实际项目更常用“重叠最后一两句”而不是固定字符数。
- **只用固定长度切分**:结构化文档的标题信息丢失。
- **块没有元数据**:无法引用来源、无法做权限过滤、更新文档时删不掉旧块。
- **建索引和查询用了不同的嵌入模型**(或同一模型不同版本):向量空间不一致,检索结果混乱。
- **文档更新后只追加不删除**:新旧版本同时被检索到,答案自相矛盾。用文档 ID 先删后加。
- **把整篇文档塞进提示词**:成本高、模型容易忽略中间内容,而且受上下文长度限制。
## 面试常问
见 [RAG 面试题](interview.md):RAG 和微调的区别、切分策略怎么选、chunk 大小怎么定、元数据有什么用。
+94
View File
@@ -0,0 +1,94 @@
---
id: ai.rag.hybrid-rerank
title: 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
type: lesson
domain: ai
stack: [rag]
tags: [混合检索, BM25, RRF, 重排, rerank, 查询改写]
level: intermediate
prerequisites: [ai.rag.pipeline-chunking]
est_hours: 3
related: [ai.rag.generation-eval, ai.vector-db.ann-metrics, ai.rag.interview]
week: [6]
order: 2
---
# 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
## 解释说明
**向量检索和关键词检索各有短板**
| | 向量检索 | 关键词检索(BM25) |
| --- | --- | --- |
| 擅长 | 语义相近、换种说法(“带薪假” ↔ “年假”) | 精确词:产品型号、错误码、人名、条款编号 |
| 不擅长 | 精确的编号、罕见专有名词 | 同义词、口语化的问题 |
所以生产环境常用**混合检索**:两路各取 top-N,再融合。
**融合:RRF(Reciprocal Rank Fusion)**:`score(d) = Σ 1 / (k + rank_i(d))`,`k` 常取 60。只用排名、不用原始分数,不需要处理两种分数的量纲差异,简单稳健。也可以按权重加权归一化后的分数。
**中文关键词检索要先分词**(jieba 等),否则 BM25 按单字或整句计算,效果很差。Elasticsearch / OpenSearch 需要安装中文分词插件;PostgreSQL 可以用 `pg_trgm` 或中文分词扩展。
**重排(rerank)**:第一阶段召回 20~50 个候选,用交叉编码器(cross-encoder)重排模型对“问题 + 候选块”逐对打分,取前 3~5 个给大模型。重排更准但更慢,只对少量候选使用。重排模型可以用云服务或私有部署的开源模型,以对应官方文档为准。
**查询改写**
- 多轮对话中,“那病假呢?”要结合历史改写成独立问题“病假怎么规定的?”再检索;
- 把一个复杂问题拆成多个子问题分别检索;
- 生成假设答案再用它去检索(HyDE)。
改写会多一次模型调用,增加延迟和成本,按需使用。
**元数据过滤**:按权限、文档类型、时间等过滤,**在检索阶段**完成(向量库的 filter 或预过滤),见[权限场景](scenarios/permission-aware-kb.md)。
## 案例
IT 知识库:用户问“报错 0x80070005 怎么办”,纯向量检索返回的都是“权限问题”的泛泛文章,没有命中包含这个错误码的那篇;加入 BM25 后,精确匹配错误码的文档排第一。反过来,“在家怎么连公司网络”在 BM25 下命中很差(文档里写的是“VPN 远程办公”),向量检索能找到。混合之后两类问题都能覆盖。
## 代码示例
RRF 融合(摘自 [examples/mini-rag](examples/mini-rag/),示例中还包含向量检索和 BM25 检索的实现):
```python
def rrf(result_lists: list[list[str]], k: int = 60, top: int = 5) -> list[tuple[str, float]]:
"""result_lists:每一路检索按相关度排好序的文档 ID 列表。"""
scores: dict[str, float] = {}
for results in result_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda kv: -kv[1])[:top]
vector_hits = ["vpn-guide", "permission-faq", "printer"] # 语义检索结果
bm25_hits = ["error-0x80070005", "vpn-guide", "office-install"] # 关键词检索结果
for doc_id, score in rrf([vector_hits, bm25_hits]):
print(f"{doc_id:18s} {score:.4f}")
# vpn-guide 两路都排得靠前,融合后排第一;只出现在一路的文档按名次排在后面
```
中文 BM25(`rank_bm25` + `jieba`):
```python
import logging
import jieba
from rank_bm25 import BM25Okapi
jieba.setLogLevel(logging.WARNING)
docs = ["Windows 更新报错 0x80070005 的处理方法", "VPN 远程办公连接指南", "打印机共享设置"]
bm25 = BM25Okapi([jieba.lcut(d) for d in docs])
print(bm25.get_scores(jieba.lcut("报错 0x80070005 怎么办")).round(2)) # 第一篇得分最高
```
## 易错点
- **只做向量检索**:错误码、型号、人名这类精确查询效果差。
- **中文 BM25 不分词**:分数几乎没有意义。
- **直接把两路的原始分数相加**:余弦相似度在 0~1,BM25 可能是 0~20,量纲不同。用 RRF 或先归一化。
- **重排所有候选**:重排模型慢,只对召回的前几十个使用。
- **top-k 设得很大以为更保险**:无关内容增多,模型更容易被干扰,成本也更高。
- **多轮对话直接用最后一句检索**:“那病假呢”检索不到东西,需要改写。
## 面试常问
见 [RAG 面试题](interview.md):为什么要混合检索、RRF 原理、重排的作用、多轮对话怎么检索。
+109
View File
@@ -0,0 +1,109 @@
---
id: ai.rag.generation-eval
title: 生成与评测:带引用的提示词、拒答、检索评测和答案评测
type: lesson
domain: ai
stack: [rag]
tags: [提示词, 引用, 拒答, 幻觉, Recall@k, MRR, 评测集]
level: intermediate
prerequisites: [ai.rag.hybrid-rerank]
est_hours: 3
related: [ai.rag.scenario.permission-aware-kb, ai.prompt-engineering.index, ai.agent-ops.index, ai.rag.interview]
week: [6]
order: 3
---
# 生成与评测:带引用的提示词、拒答、检索评测和答案评测
## 解释说明
**提示词的关键要素**
1. 角色和任务:“你是公司制度问答助手”;
2. **只依据资料回答**,资料中没有就明确说没有(减少幻觉);
3. 资料编号 + 要求用 `[编号]` 标注依据(可追溯、可校验);
4. 输出格式和长度要求;
5. 资料放在问题前面,并用清晰的分隔符包起来。
**拒答**:检索结果的最高相似度低于阈值、或者重排分数太低时,不调用大模型,直接回答“知识库中没有找到相关内容”,并引导到人工渠道。宁可不答,不要编。阈值需要用评测数据调。
**引用校验**:检查答案中的 `[编号]` 是否都在给出的资料范围内;可以进一步检查被引用的资料里是否包含答案中的关键数字。
**评测**——没有评测就无法判断改动是变好还是变坏:
| 层面 | 指标 | 怎么做 |
| --- | --- | --- |
| 检索 | Recall@k(正确资料是否在前 k 个里)、MRR(正确资料的排名倒数的平均值) | 人工标注“问题 → 应命中的文档 / 块”,几十到几百条 |
| 生成 | 忠实度(答案是否都有资料支撑)、相关性、正确性 | 人工抽查 + 用大模型按评分标准打分(LLM-as-judge) |
| 线上 | 点赞 / 点踩、转人工率、无答案率 | 埋点、定期分析差评样本 |
评测集要覆盖:常见问题、需要精确匹配的问题(编号、数字)、多轮追问、**知识库里没有答案的问题**(测试拒答)、无权限的问题。
工具:Ragas 等框架提供了忠实度、上下文召回等指标的实现,接口变化较快,以官方文档为准(https://docs.ragas.io/)。
## 案例
制度问答助手上线前建立了 9 条以上的检索评测集([examples/mini-rag](examples/mini-rag/) 中的 `EVAL`,真实项目要几十到上百条),比较纯向量、纯 BM25、混合三种检索的 Recall@3 和 MRR;之后每次调整切分或检索参数都跑一遍,指标下降就不合并。
## 代码示例
检索评测(Recall@k、MRR):
```python
def recall_and_mrr(results: dict[str, list[str]], expected: dict[str, str], k: int = 3) -> tuple[float, float]:
"""results:问题 → 检索出的文档 ID(已排序);expected:问题 → 应命中的文档 ID。"""
hit, rr = 0, 0.0
for q, gold in expected.items():
top = results[q][:k]
if gold in top:
hit += 1
rr += 1 / (top.index(gold) + 1)
n = len(expected)
return hit / n, rr / n
results = {"年假几天": ["hr-leave", "fin"], "VPN 连不上": ["hr-leave", "it-vpn"], "报销时限": ["it-vpn", "x", "y"]}
expected = {"年假几天": "hr-leave", "VPN 连不上": "it-vpn", "报销时限": "fin"}
print(recall_and_mrr(results, expected)) # (0.666..., 0.5):第 1 题排第 1,第 2 题排第 2,第 3 题没命中
```
带引用的提示词和拒答、引用校验:
```python
import re
def build_prompt(question: str, chunks: list[str]) -> str:
context = "\n".join(f"[{i}] {c}" for i, c in enumerate(chunks, start=1))
return ("你是公司制度问答助手。只根据<资料>回答;资料中没有的内容,回答“资料中没有相关规定”。"
"每个结论后用 [编号] 标注依据。\n"
f"<资料>\n{context}\n</资料>\n问题:{question}")
def answer_or_refuse(question: str, hits: list[tuple[str, float]], min_score: float = 0.3) -> str:
if not hits or hits[0][1] < min_score:
return "知识库中没有找到相关内容,建议咨询 HR 或提交工单。"
return build_prompt(question, [text for text, _ in hits]) # 交给大模型
def citations_valid(answer: str, n_chunks: int) -> bool:
cited = {int(x) for x in re.findall(r"\[(\d+)\]", answer)}
return bool(cited) and cited <= set(range(1, n_chunks + 1))
print(answer_or_refuse("股票期权怎么行权", [("差旅住宿标准……", 0.12)]))
print(citations_valid("满十年年假 10 天 [1]。", 3), citations_valid("答案 [5]", 3)) # True False
```
## 易错点
- **没有评测集,靠几个问题“感觉效果不错”**:改了参数也不知道变好还是变坏。
- **评测集里没有“无答案”的问题**:拒答能力从未被测试。
- **拒答阈值拍脑袋**:要用评测数据画出“阈值 vs 准确率 / 拒答率”来选。
- **完全依赖 LLM 打分**:评分模型本身有偏差,要和人工标注对比校准。
- **提示词里资料和指令混在一起**:资料里的内容可能被模型当成指令(提示词注入),用分隔符包裹并在指令中说明。
- **引用编号不校验**:模型可能引用不存在的编号,或者引用了但内容对不上。
## 面试常问
见 [RAG 面试题](interview.md):怎么减少幻觉、怎么评测 RAG、拒答怎么做、线上效果怎么监控。
+205
View File
@@ -0,0 +1,205 @@
# requires: jieba, rank_bm25, numpy
"""最小 RAG 流水线:切分 → 嵌入 → 混合检索(向量 + BM25,RRF 融合)→ 权限过滤 → 拒答阈值 → 带引用的提示词 → 检索评测。
为了不依赖外部服务,这里的“嵌入”用字符 n-gram 哈希向量代替真实的嵌入模型(只能捕捉字面相似,不懂语义),
目的是把流程跑通、把评测方法讲清楚。换成真实嵌入模型时,只需要替换 embed() 函数。
运行:python mini_rag.py
"""
from __future__ import annotations
import hashlib
import logging
import re
from dataclasses import dataclass, field
import jieba
import numpy as np
from rank_bm25 import BM25Okapi
jieba.setLogLevel(logging.WARNING)
DIM = 512
@dataclass
class Doc:
id: str
title: str
text: str
acl: set[str] # 允许访问的部门;"all" 表示全员
@dataclass
class Chunk:
id: str
doc_id: str
title: str
text: str
acl: set[str]
vec: np.ndarray = field(repr=False, default=None)
DOCS = [
Doc("hr-leave", "员工手册-休假制度", """# 年假
入职满一年的正式员工享有带薪年假 5 天,满十年 10 天,满二十年 15 天。年假需提前 3 个工作日在 OA 中申请。
# 病假
病假需提供医院证明,每年累计 10 天以内按全额工资发放。
# 调休
加班可在 3 个月内申请调休,过期作废。""", {"all"}),
Doc("it-vpn", "IT 指南-远程办公", """# VPN
在家办公需先安装公司 VPN 客户端,使用工号和动态口令登录。连接失败请检查系统时间是否准确。
# 设备
笔记本电脑丢失需在 24 小时内报告 IT 部门并修改所有密码。""", {"all"}),
Doc("fin-reimburse", "财务制度-报销", """# 差旅报销
出差住宿标准:一线城市每晚 500 元以内,其他城市 350 元以内。发票须在出差结束后 30 天内提交。
# 餐费
出差餐补每天 100 元,无需发票。""", {"all"}),
Doc("hr-salary", "薪酬制度(仅 HR)", """# 调薪
年度调薪在每年 3 月进行,幅度由绩效等级决定,A 级 10% 至 15%。
# 保密
薪酬信息属于保密信息,不得在公开渠道讨论。""", {"hr"}),
]
def split(doc: Doc, max_len: int = 80, overlap: int = 15) -> list[Chunk]:
"""按 Markdown 标题分节;节内超过 max_len 再按句子切,相邻块保留 overlap 个字符的重叠。
每块前面加上“文档标题 / 小节标题”,让孤立的块也有上下文(例如“满十年 10 天”属于“年假”)。"""
out: list[Chunk] = []
for sec in re.split(r"\n(?=# )", doc.text.strip()):
heading, _, body = sec.partition("\n")
heading = heading.lstrip("# ").strip()
sentences = [s for s in re.split(r"(?<=[。!?])", body.strip()) if s]
buf = ""
pieces: list[str] = []
for s in sentences:
if buf and len(buf) + len(s) > max_len:
pieces.append(buf)
buf = buf[-overlap:]
buf += s
if buf:
pieces.append(buf)
for p in pieces:
out.append(Chunk(f"{doc.id}#{len(out)}", doc.id, doc.title, f"【{doc.title} / {heading}】{p}", doc.acl))
return out
def embed(text: str) -> np.ndarray:
"""字符 1~2 gram 哈希到 DIM 维并 L2 归一化(真实项目替换为嵌入模型 API)。"""
v = np.zeros(DIM, dtype=np.float32)
t = re.sub(r"\s+", "", text)
for n in (1, 2):
for i in range(len(t) - n + 1):
h = int.from_bytes(hashlib.md5(t[i:i + n].encode()).digest()[:4], "little")
v[h % DIM] += 1.0
norm = np.linalg.norm(v)
return v / norm if norm else v
def tokenize(text: str) -> list[str]:
return [w for w in jieba.lcut(text) if w.strip() and not re.fullmatch(r"[,。、:;!?【】/\s]+", w)]
class Index:
def __init__(self, docs: list[Doc]) -> None:
self.chunks = [c for d in docs for c in split(d)]
for c in self.chunks:
c.vec = embed(c.text)
self.matrix = np.stack([c.vec for c in self.chunks])
self.bm25 = BM25Okapi([tokenize(c.text) for c in self.chunks])
def allowed(self, user_depts: set[str]) -> np.ndarray:
return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks])
def vector_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
scores = self.matrix @ embed(q) # 已归一化,点积 = 余弦相似度
scores[~mask] = -np.inf # 权限过滤:检索阶段就排除,而不是生成后再删
order = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i])]
def bm25_search(self, q: str, mask: np.ndarray, k: int) -> list[tuple[int, float]]:
scores = np.array(self.bm25.get_scores(tokenize(q)))
scores[~mask] = -np.inf
order = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in order if np.isfinite(scores[i]) and scores[i] > 0]
def hybrid_search(self, q: str, mask: np.ndarray, k: int, rrf_k: int = 60) -> list[tuple[int, float]]:
"""RRF:score = Σ 1 / (rrf_k + 排名)。只用排名,不用担心两种分数量纲不同。"""
fused: dict[int, float] = {}
for results in (self.vector_search(q, mask, 20), self.bm25_search(q, mask, 20)):
for rank, (i, _) in enumerate(results, start=1):
fused[i] = fused.get(i, 0.0) + 1.0 / (rrf_k + rank)
return sorted(fused.items(), key=lambda kv: -kv[1])[:k]
def answer_context(index: Index, q: str, user_depts: set[str], k: int = 3, min_sim: float = 0.25) -> dict:
mask = index.allowed(user_depts)
hits = index.hybrid_search(q, mask, k)
# 拒答:最相关块的向量相似度太低,说明知识库里很可能没有答案
best_sim = max((float(index.matrix[i] @ embed(q)) for i, _ in hits), default=0.0)
if not hits or best_sim < min_sim:
return {"refuse": True, "prompt": None, "sources": []}
sources = [index.chunks[i] for i, _ in hits]
context = "\n".join(f"[{n}] {c.text}" for n, c in enumerate(sources, start=1))
prompt = (
"你是公司制度问答助手。只根据下面的资料回答,资料里没有的内容回答“资料中没有相关规定”。"
"回答时用 [编号] 标注依据。\n\n"
f"资料:\n{context}\n\n问题:{q}"
)
return {"refuse": False, "prompt": prompt, "sources": [c.id for c in sources], "best_sim": round(best_sim, 3)}
# ---------------- 检索评测 ----------------
EVAL = [ # (问题, 期望命中的文档)
("年假有几天", "hr-leave"),
("工作满十年有多少天带薪假", "hr-leave"),
("病假要什么材料", "hr-leave"),
("加班多久内可以调休", "hr-leave"),
("VPN 连不上怎么办", "it-vpn"),
("电脑丢了怎么处理", "it-vpn"),
("出差住酒店能报多少钱", "fin-reimburse"),
("发票什么时候交", "fin-reimburse"),
("出差吃饭有补贴吗", "fin-reimburse"),
]
def evaluate(index: Index, method: str, k: int = 3) -> tuple[float, float]:
mask = index.allowed({"all"})
hit, rr = 0, 0.0
for q, expected in EVAL:
results = getattr(index, f"{method}_search")(q, mask, k)
docs = [index.chunks[i].doc_id for i, _ in results]
if expected in docs:
hit += 1
rr += 1 / (docs.index(expected) + 1)
return hit / len(EVAL), rr / len(EVAL)
def main() -> None:
index = Index(DOCS)
print(f"共 {len(DOCS)} 篇文档,切成 {len(index.chunks)} 块")
print("\n检索评测(Recall@3 / MRR):")
scores = {}
for m in ("vector", "bm25", "hybrid"):
recall, mrr = evaluate(index, m)
scores[m] = recall
print(f" {m:7s} recall@3={recall:.2f} MRR={mrr:.2f}")
r = answer_context(index, "满十年的年假几天?", {"dev"})
print("\n问:满十年的年假几天? 引用:", r["sources"])
print(r["prompt"][:120], "……")
r_hr = answer_context(index, "A 级调薪幅度是多少", {"hr"})
r_dev = answer_context(index, "A 级调薪幅度是多少", {"dev"})
print("\nHR 提问调薪:", r_hr["sources"][:1], "| 研发提问调薪:", r_dev["sources"])
assert r_hr["sources"][0].startswith("hr-salary")
assert not any(s.startswith("hr-salary") for s in r_dev["sources"]) # 无权限的内容不会进入上下文
r_none = answer_context(index, "公司股票期权怎么行权", {"dev"})
print("问:公司股票期权怎么行权 → 拒答" if r_none["refuse"] else f"未拒答:{r_none}")
assert r_none["refuse"]
assert scores["hybrid"] >= max(scores["vector"], scores["bm25"]) - 1e-9
if __name__ == "__main__":
main()
+33 -3
View File
@@ -4,12 +4,42 @@ title: RAG 检索增强生成
type: index
domain: ai
stack: [rag]
tags: []
tags: [RAG, 检索, 知识库, 评测]
week: [5, 6]
---
# RAG 检索增强生成
加载切分、嵌入、检索、混合检索与重排、引用与拒答、评测。
RAG(Retrieval-Augmented Generation):先从知识库检索相关资料,再让大模型基于资料回答。这是企业 AI 应用里落地最多的形态(制度问答、客服知识库、文档助手),也是 AI 应用岗位面试的必考题。重点不在“调通一个 demo”,而在**切分、混合检索、权限、引用和拒答、评测**这些决定效果和能否上线的环节。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [RAG 流水线和文档切分:加载、清洗、切分策略、元数据](01-pipeline-chunking.md) | 基础 | 3 小时 |
| 2 | [检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写](02-hybrid-rerank.md) | 进阶 | 3 小时 |
| 3 | [生成与评测:带引用的提示词、拒答、检索评测和答案评测](03-generation-eval.md) | 进阶 | 3 小时 |
## 企业场景
- [带权限的企业知识库问答:不同部门看到不同内容](scenarios/permission-aware-kb.md)
## 面试题
- [RAG 面试题](interview.md)
## 示例代码
- [examples/mini-rag](examples/mini-rag/):不依赖外部服务的最小 RAG 流水线——按标题切分、混合检索(向量 + BM25 + RRF)、权限过滤、拒答阈值、带引用的提示词、Recall@3 / MRR 评测(需要 `jieba`、`rank_bm25`、`numpy`)
## 在 16 周计划中
- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md)
## 官方资料
RAG 相关的框架和模型服务更新很快,接口和参数以官方文档为准:
- LangChain 检索相关文档:https://docs.langchain.com/
- LlamaIndex:https://developers.llamaindex.ai/
- Ragas(RAG 评测):https://docs.ragas.io/
+85
View File
@@ -0,0 +1,85 @@
---
id: ai.rag.interview
title: RAG 面试题
type: interview
domain: ai
stack: [rag]
tags: [RAG, 检索, 评测, 面试]
level: intermediate
related: [ai.rag.pipeline-chunking, ai.rag.hybrid-rerank, ai.rag.generation-eval]
week: [5, 6, 16]
---
# RAG 面试题
## 基础
### 【基础】什么是 RAG?和微调有什么区别?
**参考答案**:RAG 先从外部知识库检索相关资料,再把资料和问题一起交给大模型生成答案。和微调相比:知识更新只需要更新索引,成本低;答案可以附引用、可追溯;能做权限控制。微调改变的是模型的行为和风格(格式、领域语气、特定任务能力),不适合频繁变化的事实性知识。很多场景两者结合,但先做 RAG。
**追问**:什么情况下需要 RAG 和微调结合?→ 既要最新的企业知识,又要固定的输出格式或领域表达时:RAG 提供知识,微调(或更常见的:提示词 + 少量示例)约束格式。先用 RAG 和提示词解决,确实不够再考虑微调。
### 【基础】RAG 的完整流程是怎样的?
**参考答案**:离线:加载文档 → 解析清洗 → 切分 → 嵌入 → 写入向量库(带元数据),关键词检索还要写入倒排索引。在线:问题(必要时改写)→ 检索(向量 / 关键词 / 混合)→ 权限和元数据过滤 → 重排 → 拼接提示词 → 大模型生成 → 返回答案和引用;全程记录日志用于评测和分析。
**追问**:文档解析这一步常见的问题有哪些?→ PDF 表格被拆散、多栏排版顺序错乱、扫描件需要 OCR、页眉页脚和水印混入正文。表格转成 Markdown 表格整体入库,解析结果要抽样人工检查。
### 【基础】chunk 大小怎么定?切分有哪些策略?
**参考答案**:没有固定答案,要用评测集对比。太小上下文不完整,太大主题混杂、浪费 token。策略:固定长度加重叠、按文档结构(标题 / 段落)切分、按句子 / 语义切分、父子块(小块检索、返回大块)。结构化文档优先按结构切,并把标题路径加到块内容里。
**追问**:父子块(小块检索、返回大块)解决什么问题?→ 小块语义集中、检索准,但上下文不完整;命中小块后把它所在的大块(父块)交给模型,兼顾检索精度和上下文完整。
### 【基础】为什么要混合检索?
**参考答案**:向量检索擅长语义相似,但对错误码、型号、人名等精确词效果差;BM25 擅长精确匹配,但不理解同义表达。两者互补,用 RRF 等方法融合后效果通常更稳定。中文关键词检索要先分词。
**追问**:中文 BM25 要注意什么?Elasticsearch 怎么做混合检索?→ 中文要先分词(jieba、ES 的 IK 等分词插件),否则按单字匹配效果差;ES 可以同时做 BM25 和 kNN 向量检索,再用 RRF 等方式融合,具体语法随版本变化,以官方文档为准。
## 进阶
### 【进阶】怎么减少 RAG 的幻觉?
**参考答案**:检索层面:提高召回质量(切分、混合检索、重排),召回不到时拒答;提示词层面:要求只依据资料回答、资料中没有就说没有、每个结论标注引用编号;生成后:校验引用编号是否存在、关键数字是否出现在被引用的资料中;评测:用忠实度指标和人工抽查持续监控。
**追问**:模型给出的引用编号是对的,内容却不在资料里,怎么发现?→ 生成后做校验:引用编号必须存在;抽取答案中的关键数字 / 实体,检查是否出现在被引用的资料里;不通过时降级为“只返回相关资料”或提示人工核对,并记录为 Bad Case。
### 【进阶】怎么评测一个 RAG 系统?
**参考答案**:分层评测。检索层:人工标注问题和应命中的文档,计算 Recall@k、MRR;生成层:忠实度(答案是否有资料支撑)、答案正确性和相关性,用人工标注 + LLM 评分并互相校准;端到端:拒答率、无权限问题的处理;线上:用户反馈、转人工率、差评样本分析。评测集要覆盖精确词、多轮、无答案、无权限等类型,改动前后都跑。
**追问**:评测集从哪里来?→ 初期人工根据文档编写(每类问题若干条),上线后从真实日志和差评中补充;可以用大模型辅助生成候选问题,但必须人工审核;评测集要版本化管理。
### 【进阶】重排(rerank)是什么?为什么不直接用重排模型检索全部文档?
**参考答案**:重排模型(通常是交叉编码器)把问题和候选文本拼在一起计算相关度,比向量的双塔模型更准,但每一对都要单独计算,速度慢、成本高。所以先用向量 / BM25 快速召回几十个候选,再用重排模型精排取前几个。
**追问**:重排之后取几条给模型?→ 用评测集测试:太少可能漏掉答案,太多增加成本和干扰,常见 3~8 条;同时设置重排分数阈值,低于阈值的不放进提示词。
### 【进阶】什么是 Agentic RAG?和普通 RAG 有什么区别?
**参考答案**:普通 RAG 是固定流程:检索一次、生成一次。Agentic RAG 让模型作为 Agent 决定是否检索、检索什么、检索结果不够时改写问题再检索、从多个知识源(文档、数据库、搜索)中选择,并在生成后自我检查。适合复杂多跳问题,代价是延迟和成本更高、流程更难控制,需要设置最大步数和评测。详见 [Agent 设计专题](../agent-design/index.md)。
**追问**:Agentic RAG 怎么防止无限检索?→ 设置最大步数 / 最大检索次数和总超时;每一步记录状态,达到上限后基于已有资料回答或拒答;用评测集对比它和普通 RAG 的效果、延迟和成本,确认值得。
## 场景题
### 【场景题】知识库里有不同部门的保密文档,怎么保证员工只能问到有权限的内容?
**参考答案**:每个块在入库时带权限元数据(可见部门 / 角色);问答时先鉴权得到用户的部门和角色,在检索阶段作为过滤条件(先过滤再取 top-k),无权限内容不进入提示词;缓存要区分权限;日志脱敏;评测集包含越权问题做回归。不能只靠提示词约束模型。详见[带权限的知识库场景](scenarios/permission-aware-kb.md)。
**追问**:用户的部门变了,之前的缓存答案怎么办?→ 缓存 key 包含权限维度(部门 / 角色的哈希),或者敏感知识库不缓存答案;权限变更事件触发相关缓存失效。
### 【场景题】用户反馈“答案不对”,你怎么排查?
**参考答案**:用请求 ID 找到这次的完整链路记录:改写后的问题、检索到的块和分数、重排结果、最终提示词、模型输出。判断问题在哪一层:正确资料没被召回(切分、嵌入、检索参数问题)→ 召回了但排序靠后(重排)→ 资料在提示词里但模型答错(提示词、模型能力)→ 资料本身过期或错误(数据问题)。把它加入评测集(Bad Case),修复后回归。
**追问**:怎么把这类问题变成可持续改进的流程?→ 建立 Bad Case 记录:每个问题标注失败环节(解析 / 切分 / 检索 / 排序 / 生成 / 数据),定期汇总找出占比最高的环节优先优化,修复后加入评测集防止回归。
### 【场景题】文档每天都在更新,怎么保持索引最新?
**参考答案**:监听文档源的变更(事件、定时比较更新时间或内容哈希),只处理变化的文档;每个块带 `doc_id` 和版本号,更新时先删除该文档的旧块再写入新块(或写入新版本后原子切换);删除的文档同步删除;入库是异步任务,失败重试、幂等;监控入库延迟和失败数。跨栈的数据同步场景见 [scenarios/](../../../scenarios/index.md)。
**追问**:嵌入成本很高,大量文档频繁小改动怎么办?→ 按块计算内容哈希,只有内容变化的块才重新嵌入;元数据(如权限)变化只更新元数据,不重新嵌入。
@@ -0,0 +1,108 @@
---
id: ai.rag.scenario.permission-aware-kb
title: 带权限的企业知识库问答:不同部门看到不同内容
type: scenario
domain: ai
stack: [rag, vector-db, elasticsearch, fastapi]
tags: [企业知识库, 权限过滤, 文档解析, 混合检索, 重排, 拒答, 增量更新]
level: advanced
prerequisites: [ai.rag.generation-eval]
est_hours: 3
related: [ai.rag.interview, ai.vector-db.scenario.multi-tenant-index, backend.elasticsearch.index]
week: [5, 6]
---
# 带权限的企业知识库问答:不同部门看到不同内容
## 业务背景与问题
公司把员工手册、IT 指南、财务制度、各部门内部文档都导入知识库,做一个“制度问答助手”。试运行时发现:
1. 研发同事问“A 级调薪幅度多少”,助手根据 HR 内部的薪酬文档给出了答案——**越权**;
2. PDF 里的表格(差旅标准)被解析成一串数字,问“一线城市住宿标准”答错;
3. 问“报错 0x80070005 怎么办”,检索不到包含这个错误码的文档;
4. 制度更新后,新旧两个版本都被检索到,答案自相矛盾;
5. 知识库里没有的问题(股票期权),助手也一本正经地回答。
## 约束
- 权限以现有的组织架构(部门、角色)为准,文档有“全员 / 指定部门 / 指定角色”三种可见范围。
- 文档格式:PDF(含表格、扫描件)、Word、Confluence 类网页。
- 答案必须给出引用来源,方便员工核对原文。
- 回答延迟目标:首字 2 秒以内(流式)。
## 方案设计
```mermaid
flowchart LR
subgraph 入库(异步任务)
S[文档源] --> P[解析:PDF/Word/HTML → Markdown<br/>表格转 Markdown 表格,扫描件 OCR] --> C[按标题切分 + 标题路径前缀]
C --> E[嵌入] --> V[(向量库<br/>块 + acl + doc_id + version)]
C --> K[(Elasticsearch<br/>中文分词 BM25)]
end
subgraph 问答
U[用户] --> A[鉴权:取部门 / 角色] --> R1[向量检索 + acl 过滤]
A --> R2[ES 检索 + acl 过滤]
R1 & R2 --> F[RRF 融合 → 重排 top 5] --> G{最高分 ≥ 阈值?}
G -->|否| N[拒答 + 转人工入口]
G -->|是| L[带编号资料的提示词 → 流式生成] --> O[答案 + 引用]
end
```
1. **权限在检索阶段过滤**:每个块带 `acl`(可见的部门 / 角色列表),检索时作为过滤条件,**无权限的内容根本不进入提示词**。不要“先检索再让模型别说”。
2. **文档解析**:PDF 先转结构化文本,表格转成 Markdown 表格整体作为一个块;扫描件走 OCR;去掉页眉页脚。解析结果抽样人工检查。
3. **混合检索**:向量检索负责语义,Elasticsearch(IK 等中文分词)负责错误码、编号等精确词;RRF 融合后用重排模型取前 5。
4. **版本和增量更新**:块带 `doc_id` 和 `version`;文档更新时按 `doc_id` 删除旧块再写入新块(或写入新版本后切换),避免新旧共存。
5. **拒答**:重排后最高分低于阈值直接拒答,给出“提交工单 / 联系 HR”入口。
6. **评测和回归**:评测集包含有权限 / 无权限 / 无答案 / 精确词 / 表格类问题,每次改动跑一遍。
## 关键代码
权限过滤和拒答的最小实现(完整代码 [examples/mini-rag/mini_rag.py](../examples/mini-rag/mini_rag.py)):
```python
def allowed(self, user_depts: set[str]) -> np.ndarray:
return np.array([("all" in c.acl) or bool(c.acl & user_depts) for c in self.chunks])
def vector_search(self, q: str, mask: np.ndarray, k: int):
scores = self.matrix @ embed(q)
scores[~mask] = -np.inf # 无权限的块直接排除,不会进入提示词
...
```
示例里验证了:HR 部门问调薪,命中薪酬制度;研发部门问同一个问题,薪酬制度不会出现在候选中(结果为空 → 拒答);问知识库中没有的“股票期权”→ 拒答。
真实向量库的过滤写法(以 Chroma 为例,`where` 条件;其他向量库语法不同,以官方文档为准):
```python
results = collection.query(
query_embeddings=[q_vec],
n_results=20,
where={"$or": [{"acl_all": True}, {"dept": {"$in": list(user_depts)}}]},
)
```
## 取舍
| 选择 | 优点 | 缺点 |
| --- | --- | --- |
| **检索时按 acl 过滤(本方案)** | 安全,无权限内容不会进入模型 | 权限变化时要更新块的 acl |
| 按部门分别建索引 | 隔离彻底 | 跨部门文档要重复存储;部门多时管理困难 |
| 检索后再过滤 | 实现简单 | top-k 被无权限内容占满后,有权限的内容反而召回不到 |
| 只靠提示词让模型不说 | —— | **不可接受**,模型可能泄露 |
| 向量库 + ES 两套存储 | 混合检索效果好 | 两边数据同步、一致性要处理 |
| 只用支持混合检索的单一引擎 | 运维简单 | 依赖具体产品的能力(ES、部分向量库已支持向量 + 关键词),以官方文档为准 |
## 易错点
- 权限只在前端或网关判断,检索层不过滤。
- 先取 top-5 再过滤权限:结果可能被过滤成空,实际上还有相关的有权限内容。先过滤再取 top-k。
- 用户换部门后缓存的答案仍可见:缓存 key 要包含权限维度,或不缓存敏感问答。
- 表格被拆成多块:一行数据和表头分离,数字失去含义。
- 文档删除 / 下线后向量库里还有残留。
- 日志里记录了完整的提示词(包含敏感文档内容),日志本身成为泄露渠道。
## 面试怎么讲
> “企业知识库最大的问题是权限。我在每个块上带了 acl 元数据,检索阶段就过滤,无权限的内容不会进入提示词,而不是靠提示词让模型别说;检索用向量加 ES 的混合检索再重排,解决错误码这类精确查询;文档解析时把表格转成 Markdown 整体入库;文档按 doc_id 和版本增量更新;最高分低于阈值就拒答。评测集专门包含无权限和无答案的问题,每次改动都跑回归。”
+100
View File
@@ -0,0 +1,100 @@
---
id: ai.vector-db.ann-metrics
title: 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
type: lesson
domain: ai
stack: [vector-db]
tags: [向量检索, 余弦相似度, 内积, L2, HNSW, IVF, PQ, 召回率]
level: basic
prerequisites: [ai.rag.pipeline-chunking]
est_hours: 2.5
related: [ai.vector-db.selection-pgvector, ai.rag.hybrid-rerank, ai.vector-db.interview]
week: [5]
order: 1
---
# 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
## 解释说明
**嵌入(embedding)** 把一段文本变成一个固定长度的浮点数组(例如 768、1024、1536 维),语义相近的文本向量也相近。向量数据库要解决的问题就是:给定一个查询向量,从几百万个向量中**快速**找出最相近的 k 个(k 近邻,kNN)。
### 距离度量
| 度量 | 含义 | 什么时候用 |
| --- | --- | --- |
| 余弦相似度(cosine) | 两个向量夹角的余弦,只看方向不看长度 | 文本嵌入最常用 |
| 内积(inner product, IP / dot) | 对应元素相乘求和 | 向量已归一化时**和余弦等价且更快** |
| 欧氏距离(L2) | 两点之间的直线距离 | 部分模型推荐;归一化向量的 L2 排序和余弦排序一致 |
规则:**用嵌入模型文档推荐的度量**;如果向量已归一化(长度为 1),三种度量的排序结果一致。注意“距离”和“相似度”方向相反:余弦距离 = 1 − 余弦相似度,越小越相近。
### 暴力检索和近似检索
- **暴力检索(精确)**:查询向量和所有向量逐一计算,复杂度 O(N·d)。几万条以内完全够用(numpy 一次矩阵乘法),而且结果是精确的。
- **近似最近邻(ANN)**:用索引结构牺牲一点准确度,换取数量级的速度提升。衡量指标是**召回率**:近似结果和精确结果的重合比例。
### 常见索引
| 索引 | 原理 | 关键参数 | 特点 |
| --- | --- | --- | --- |
| **HNSW** | 多层“小世界”图,从稀疏的上层快速定位,逐层向下精细搜索 | `M`(每个点的连接数)、`ef_construction`(建索引时的候选数)、`ef_search`(查询时的候选数) | 查询快、召回高,**最常用**;内存占用大,建索引慢 |
| **IVF**(倒排文件) | 先用聚类把向量分成 `nlist` 个桶,查询时只搜最近的 `nprobe` 个桶 | `nlist` / `lists`、`nprobe` / `probes` | 建索引快、省内存;要先有数据才能训练聚类中心,数据分布变化后要重建 |
| **PQ**(乘积量化) | 把向量切段并压缩成编码 | 段数、码本大小 | 大幅省内存,精度下降,常和 IVF 组合(IVF_PQ) |
| DiskANN 等磁盘索引 | 索引放在 SSD 上 | —— | 数据量超出内存时使用 |
参数的规律:`ef_search` / `nprobe` 调大 → 召回率上升、延迟上升。**用自己的数据和评测集测出合适的值**,不要照抄。
### 过滤和近似索引的冲突
带条件的查询(只搜某租户 / 某部门)在近似索引上有一个经典问题:索引先返回 top-N 个近邻,再按条件过滤,如果满足条件的数据很少,过滤后可能不足 k 个甚至为空。解决办法:数据库内置的过滤遍历(例如 pgvector 0.8 的迭代索引扫描、Milvus 的过滤搜索)、增大候选数、按租户分区 / 分集合、过滤条件非常严格时直接走精确检索。
## 案例
制度问答知识库有 3 万个块:直接用 numpy 暴力检索,单次查询几毫秒,没有召回损失。业务扩展到 50 个租户、800 万个块后,暴力检索需要几百毫秒,改用 HNSW 索引;上线前用 200 个评测问题对比 HNSW 和暴力检索的结果,调整 `ef_search` 使召回率达到 0.95 以上,同时 P99 延迟满足要求。
## 代码示例
用 numpy 实现暴力检索,验证“归一化后余弦 = 内积,L2 排序一致”:
```python
import numpy as np
rng = np.random.default_rng(0)
docs = rng.normal(size=(1000, 64)).astype(np.float32)
q = rng.normal(size=64).astype(np.float32)
docs_n = docs / np.linalg.norm(docs, axis=1, keepdims=True)
q_n = q / np.linalg.norm(q)
cos = docs_n @ q_n # 余弦相似度(越大越近)
ip = docs_n @ q_n # 归一化后内积就是余弦
l2 = np.linalg.norm(docs_n - q_n, axis=1) # 欧氏距离(越小越近)
top_cos = np.argsort(-cos)[:5]
top_l2 = np.argsort(l2)[:5]
assert list(top_cos) == list(top_l2) # 归一化后排序一致
print(top_cos, np.round(cos[top_cos], 3))
# 未归一化时内积会偏向长度大的向量,排序可能不同
top_ip_raw = np.argsort(-(docs @ q))[:5]
print("未归一化内积 top5:", top_ip_raw)
```
近似索引和精确检索的召回率对比(Chroma HNSW、pgvector HNSW)见 [examples/vector-stores](examples/vector-stores/)。
## 易错点
- 嵌入模型和度量不匹配:模型输出未归一化却用内积,结果偏向长向量。
- 入库和查询使用了**不同的嵌入模型**(或同一模型的不同版本):向量空间不一致,检索结果完全不对。换模型必须全量重新嵌入。
- 把“距离”当“相似度”用:阈值方向写反。
- 只看近似检索的结果,不和暴力检索对比,不知道召回率是多少。
- 数据量很小也上复杂的向量数据库:几万条以内 numpy / pgvector 精确检索就够了。
- IVF 索引在空表上建立:聚类中心没有意义,召回率很低。应在导入有代表性的数据之后再建。
## 面试常问
- 余弦相似度、内积、L2 的区别?什么时候结果一样?
- HNSW 的原理和关键参数?`ef_search` 调大会怎样?
- HNSW 和 IVF 怎么选?
- 带过滤条件的向量检索为什么可能召回不足?怎么解决?
@@ -0,0 +1,87 @@
---
id: ai.vector-db.selection-pgvector
title: 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch
type: lesson
domain: ai
stack: [vector-db, postgresql]
tags: [向量库选型, pgvector, Chroma, Milvus, Elasticsearch, 元数据过滤]
level: intermediate
prerequisites: [ai.vector-db.ann-metrics]
est_hours: 3
related: [ai.vector-db.scenario.multi-tenant-index, backend.postgresql.index, backend.elasticsearch.index]
week: [5, 6]
order: 2
---
# 向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch
## 解释说明
### 选型对比
| 方案 | 适合 | 优点 | 注意 |
| --- | --- | --- | --- |
| **Chroma** | 原型、学习、小规模 | 安装即用,Python 内嵌模式 | 大规模、高可用要评估;版本间 API 有变化 |
| **pgvector**(PostgreSQL 扩展) | 已有 PostgreSQL、百万级以内、需要和业务数据联查 | 一套数据库;SQL 过滤、事务、备份都复用 | 超大规模和高 QPS 时不如专用向量库;向量索引的内存占用要规划 |
| **Milvus** | 千万到亿级、需要分布式 | 多种索引、分区、标量过滤、水平扩展 | 组件多,运维成本高(也有托管版) |
| **Elasticsearch / OpenSearch** | 已经用 ES 做全文检索,想要关键词 + 向量混合检索 | 一个引擎做 BM25 + kNN | 向量功能的版本差异较大,以官方文档为准 |
| FAISS | 嵌入到程序里的向量检索库 | 快,索引类型全 | 是库不是数据库:没有持久化服务、过滤、权限,需要自己做 |
**本计划的选择**:学习阶段用 Chroma 或 numpy 快速理解;项目使用 **pgvector**,因为第 3~4 周已经学了 PostgreSQL,业务数据、权限和向量可以放在同一个库里联查;面试时能讲清楚“为什么不用 Milvus”和“什么规模需要换”。
### pgvector 要点(以官方文档为准:https://github.com/pgvector/pgvector)
- 类型:`vector(n)`;还有 `halfvec`(半精度,省一半空间)、`sparsevec`。
- 运算符:`<->` L2 距离,`<#>` **负**内积,`<=>` 余弦距离(都是越小越近)。
- 索引:`USING hnsw (embedding vector_cosine_ops)`,运算符类必须和查询用的运算符一致(`vector_l2_ops` / `vector_ip_ops` / `vector_cosine_ops`),否则不走索引。
- 查询参数:`SET hnsw.ef_search = 100;`(默认 40);IVFFlat 用 `ivfflat.probes`。
- 过滤:`WHERE tenant_id = $1 ORDER BY embedding <=> $2 LIMIT 10`。过滤条件很严格时,0.8 版本可以开启 `hnsw.iterative_scan` 让索引继续扫描直到凑够结果;或者按租户建部分索引 / 分区。
## 案例
一个 SaaS 客服系统,50 个租户共 200 万个知识块,平均每个租户 4 万,最大租户 60 万。业务表已经在 PostgreSQL 中。选择 pgvector:块表带 `tenant_id`,HNSW 索引 + `tenant_id` 普通索引;大租户检索结果不足时开启迭代扫描。评估结论写进文档:当总量超过数千万或 QPS 超过单机能力时,再评估 Milvus。
## 代码示例
pgvector 的表结构和查询(完整可运行版本 [examples/vector-stores/pgvector_demo.py](examples/vector-stores/pgvector_demo.py),已在 PostgreSQL 17 + pgvector 0.8.0 上运行):
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
version INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1024) NOT NULL
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON chunks (tenant_id);
CREATE INDEX ON chunks (doc_id);
-- 查询:$1 租户,$2 查询向量
SET hnsw.ef_search = 100;
SELECT id, doc_id, content, 1 - (embedding <=> $2) AS similarity
FROM chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
```
Chroma 的写入、元数据过滤、更新删除见 [examples/vector-stores/chroma_demo.py](examples/vector-stores/chroma_demo.py)。
## 易错点
- 建索引用了 `vector_l2_ops`,查询用 `<=>`:不走索引,变成全表扫描。
- `<#>` 返回的是负内积,排序时直接 `ORDER BY embedding <#> q` 就是内积从大到小;自己再加负号就反了。
- 小表的执行计划显示顺序扫描:数据少时规划器认为顺序扫描更便宜,这是正常的,不是索引失效。
- HNSW 索引默认不支持太高的维度(`vector` 类型的索引上限 2000 维,`halfvec` 4000 维,以官方文档为准),高维模型要选 `halfvec` 或降维。
- 大批量导入时边写边维护 HNSW 索引很慢:先导入再建索引,并适当调大 `maintenance_work_mem`。
- Chroma 不同版本的客户端 / 持久化格式有变化,升级前看迁移说明。
## 面试常问
- 为什么选 pgvector 而不是 Milvus?什么时候要换?
- pgvector 的三种距离运算符分别是什么?索引怎么建才能生效?
- 向量和业务数据放一个库有什么好处和风险?
@@ -0,0 +1,40 @@
# requires: chromadb, numpy
"""Chroma(内存模式):写入自带向量、按元数据过滤、和暴力检索对比召回率。
运行:python chroma_demo.py(不需要任何外部服务,也不会下载嵌入模型,因为我们自己提供向量)
Chroma 的 API 在版本间有变化,以官方文档为准:https://docs.trychroma.com/
"""
import chromadb
import numpy as np
from common import brute_force, make_corpus, query_vec
ids, vecs, metas, centers = make_corpus()
client = chromadb.EphemeralClient()
col = client.create_collection("docs_demo", metadata={"hnsw:space": "cosine"}) # 距离度量要和嵌入模型匹配
col.add(ids=ids, embeddings=vecs.tolist(), metadatas=metas)
q = query_vec(centers, "it")
k = 10
# 1. 无过滤:近似检索结果 vs 精确结果
res = col.query(query_embeddings=[q.tolist()], n_results=k)
approx = res["ids"][0]
exact = [ids[i] for i in brute_force(vecs, q, k)]
recall = len(set(approx) & set(exact)) / k
print(f"HNSW 召回率(相对暴力检索)@{k} = {recall:.2f}")
print("距离(cosine distance = 1 - 相似度)前 3:", [round(d, 3) for d in res["distances"][0][:3]])
assert all(i.startswith("it-") for i in approx[:5])
# 2. 元数据过滤:多租户 / 权限
res = col.query(query_embeddings=[q.tolist()], n_results=k,
where={"$and": [{"tenant": "tenant-1"}, {"topic": {"$in": ["it", "hr"]}}]})
got = res["ids"][0]
print("tenant-1 过滤后:", got[:5])
assert got and all(metas[ids.index(i)]["tenant"] == "tenant-1" for i in got)
# 3. 更新和删除:按 id 覆盖、删除
col.upsert(ids=["it-0"], embeddings=[vecs[0].tolist()], metadatas=[{"topic": "hr", "tenant": "tenant-0"}])
col.delete(where={"topic": "finance"})
print("删除 finance 后剩余:", col.count())
assert col.count() == 400
@@ -0,0 +1,33 @@
"""示例共用:确定性的“假嵌入”(随机向量 + 主题偏移),让示例不依赖嵌入模型 API。"""
import numpy as np
DIM = 64
TOPICS = ["hr", "it", "finance"]
def make_corpus(n_per_topic: int = 200, seed: int = 7):
"""每个主题一个中心向量,文档向量 = 中心 + 噪声。返回 (ids, vectors, metadatas)。"""
rng = np.random.default_rng(seed)
centers = {t: rng.normal(size=DIM) for t in TOPICS}
ids, vecs, metas = [], [], []
for t in TOPICS:
for i in range(n_per_topic):
v = centers[t] + rng.normal(scale=0.8, size=DIM)
ids.append(f"{t}-{i}")
vecs.append(v / np.linalg.norm(v))
metas.append({"topic": t, "tenant": f"tenant-{i % 3}"})
return ids, np.array(vecs, dtype=np.float32), metas, centers
def query_vec(centers, topic: str, seed: int = 1) -> np.ndarray:
rng = np.random.default_rng(seed)
v = centers[topic] + rng.normal(scale=0.8, size=DIM)
return (v / np.linalg.norm(v)).astype(np.float32)
def brute_force(vecs: np.ndarray, q: np.ndarray, k: int, mask=None) -> list[int]:
"""精确的最近邻(余弦 = 归一化后的点积),作为近似检索的对照。"""
scores = vecs @ q
if mask is not None:
scores = np.where(mask, scores, -np.inf)
return list(np.argsort(-scores)[:k])
@@ -0,0 +1,77 @@
# requires: psycopg, numpy
"""pgvector:建表、HNSW 索引、带过滤的相似度检索、和暴力检索对比。
运行:TEST_DATABASE_URL=postgresql://user:pass@localhost:5432/db python pgvector_demo.py
需要数据库已安装 pgvector 扩展(CREATE EXTENSION vector)。没有设置环境变量时跳过。
会在一个临时 schema 里建表,结束后删除。
"""
import os
import sys
import uuid
import numpy as np
import psycopg
from common import DIM, brute_force, make_corpus, query_vec
def to_pg(v: np.ndarray) -> str:
return "[" + ",".join(f"{x:.6f}" for x in v) + "]"
def main() -> int:
url = os.environ.get("TEST_DATABASE_URL")
if not url:
print("未设置 TEST_DATABASE_URL,跳过")
return 0
schema = f"vec_demo_{uuid.uuid4().hex[:8]}"
ids, vecs, metas, centers = make_corpus()
with psycopg.connect(url, autocommit=True) as conn:
try:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute(f"CREATE SCHEMA {schema}")
conn.execute(f"SET search_path TO {schema}, public")
conn.execute(f"""CREATE TABLE chunks (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
topic TEXT NOT NULL,
embedding vector({DIM}) NOT NULL)""")
with conn.cursor().copy("COPY chunks (id, tenant, topic, embedding) FROM STDIN") as cp:
for i, v, m in zip(ids, vecs, metas):
cp.write_row((i, m["tenant"], m["topic"], to_pg(v)))
# HNSW 索引;vector_cosine_ops 对应 <=> 余弦距离运算符
conn.execute("CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)")
conn.execute("CREATE INDEX ON chunks (tenant)")
conn.execute("ANALYZE chunks")
q = query_vec(centers, "finance")
k = 10
rows = conn.execute("SELECT id, 1 - (embedding <=> %s::vector) AS sim FROM chunks "
"ORDER BY embedding <=> %s::vector LIMIT %s", (to_pg(q), to_pg(q), k)).fetchall()
exact = [ids[i] for i in brute_force(vecs, q, k)]
recall = len({r[0] for r in rows} & set(exact)) / k
print(f"pgvector HNSW 召回率@{k} = {recall:.2f},最相似:{rows[0][0]} {rows[0][1]:.3f}")
assert all(r[0].startswith("finance-") for r in rows[:5])
# 只有 600 行时规划器可能选择顺序扫描(更便宜),演示时临时关闭顺序扫描看索引计划
conn.execute("SET enable_seqscan = off")
plan = "\n".join(r[0] for r in conn.execute(
"EXPLAIN SELECT id FROM chunks ORDER BY embedding <=> %s::vector LIMIT 10", (to_pg(q),)).fetchall())
conn.execute("RESET enable_seqscan")
print("执行计划使用 HNSW 索引:", "chunks_embedding_idx" in plan)
assert "chunks_embedding_idx" in plan
# 带过滤:SQL 的 WHERE 和向量排序写在一起,这是 pgvector 的便利之处
rows = conn.execute("SELECT id, tenant FROM chunks WHERE tenant = %s "
"ORDER BY embedding <=> %s::vector LIMIT %s", ("tenant-2", to_pg(q), k)).fetchall()
assert rows and all(t == "tenant-2" for _, t in rows)
mask = np.array([m["tenant"] == "tenant-2" for m in metas])
exact_f = {ids[i] for i in brute_force(vecs, q, k, mask)}
print(f"过滤后召回率@{k} = {len({r[0] for r in rows} & exact_f) / k:.2f}(过滤 + 近似索引可能降低召回,见课程)")
finally:
conn.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
return 0
if __name__ == "__main__":
sys.exit(main())
+33 -3
View File
@@ -4,12 +4,42 @@ title: 向量数据库
type: index
domain: ai
stack: [vector-db]
tags: []
tags: [向量数据库, HNSW, pgvector, Chroma, Milvus]
week: [5, 6]
---
# 向量数据库
近似最近邻索引、距离度量、Chroma / Milvus / pgvector 选型。
RAG 的检索基础设施。重点是讲清楚**距离度量、近似索引的召回和延迟取舍、带过滤的检索、选型理由**,而不是背某个产品的 API。项目里使用 pgvector(复用第 3~4 周学的 PostgreSQL)。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引](01-ann-metrics.md) | 基础 | 2.5 小时 |
| 2 | [向量库选型和 pgvector 实战:Chroma / pgvector / Milvus / Elasticsearch](02-selection-pgvector.md) | 进阶 | 3 小时 |
## 企业场景
- [多租户知识库的向量索引:隔离、过滤召回和增量更新](scenarios/multi-tenant-index.md)
## 面试题
- [向量数据库面试题](interview.md)
## 示例代码
- [examples/vector-stores](examples/vector-stores/):`chroma_demo.py`(内存模式,自带向量,元数据过滤、更新删除,和暴力检索对比召回率);`pgvector_demo.py`(HNSW 索引、过滤查询、执行计划,需要 `TEST_DATABASE_URL` 和 pgvector 扩展,否则跳过)
## 在 16 周计划中
- [第 5 周:RAG(一)](../../../plan/week05-rag-1.md)、[第 6 周:RAG(二)](../../../plan/week06-rag-2.md)
## 官方资料
产品更新很快,接口和参数以官方文档为准:
- pgvector:https://github.com/pgvector/pgvector
- Chroma:https://docs.trychroma.com/
- Milvus:https://milvus.io/docs
- Elasticsearch 向量检索:https://www.elastic.co/docs/solutions/search/vector
+67
View File
@@ -0,0 +1,67 @@
---
id: ai.vector-db.interview
title: 向量数据库面试题
type: interview
domain: ai
stack: [vector-db]
tags: [向量数据库, HNSW, pgvector, 面试]
level: intermediate
related: [ai.vector-db.ann-metrics, ai.vector-db.selection-pgvector, ai.rag.interview]
week: [5, 6, 16]
---
# 向量数据库面试题
## 基础
### 【基础】向量数据库解决什么问题?和传统数据库有什么区别?
**参考答案**:解决“找出和查询向量最相似的 k 个向量”的问题。传统数据库按精确值或范围查询(B+ 树等),向量检索按相似度排序,需要专门的近似最近邻索引(HNSW、IVF)在速度和召回率之间取舍。现在很多传统数据库也通过扩展支持向量(pgvector、ES 的 kNN)。
**追问**:数据量只有几万条,需要向量数据库吗?——不需要专门的向量数据库,内存暴力检索或 pgvector 精确检索就够快,而且结果精确。
### 【基础】余弦相似度、内积、欧氏距离有什么区别?
**参考答案**:余弦只看方向;内积同时受长度影响;欧氏距离是空间中两点的距离。向量归一化后三者的排序结果一致,此时内积计算最快。应该使用嵌入模型推荐的度量。
**追问**:pgvector 中 `<#>` 返回什么?——负内积,所以 `ORDER BY embedding <#> q` 是内积从大到小。
### 【基础】什么是 ANN?召回率怎么算?
**参考答案**:近似最近邻,用索引结构换取速度,结果不保证精确。召回率 = 近似检索的 top-k 和精确检索的 top-k 的重合数 / k。上线前要用自己的数据测试。
**追问**:召回率 0.95 够不够?→ 取决于业务:RAG 后面还有重排和生成,0.95 通常足够;关键是要在自己的数据上测出召回率和延迟的关系,再按延迟预算选参数。
## 进阶
### 【进阶】HNSW 的原理和关键参数?
**参考答案**:多层图结构,上层稀疏、下层稠密。查询从最上层的入口点开始,贪心地走向更近的邻居,逐层下降,在最底层用大小为 `ef_search` 的候选队列搜索。参数:`M` 每个节点的连接数(大 → 召回高、内存大),`ef_construction` 建索引时的候选数(大 → 索引质量高、建索引慢),`ef_search` 查询时的候选数(大 → 召回高、延迟高)。
**追问**:HNSW 和 IVF 怎么选?——HNSW 查询性能和召回通常更好、支持增量插入,但内存大;IVF 建索引快、省内存(配合 PQ 更省),需要先有数据训练,数据分布变化后要重建。
### 【进阶】带过滤条件的向量检索有什么问题?
**参考答案**:近似索引先找全局近邻再过滤,满足条件的数据占比很小时,结果可能不足 k 个。解决:数据库的过滤遍历能力(pgvector 0.8 的迭代扫描、Milvus 的标量过滤)、增大候选数、按租户分区、小数据量直接精确检索。
**追问**:pgvector 0.8 的迭代扫描是什么?→ 开启 `hnsw.iterative_scan` 后,如果过滤后结果不足,索引会继续往下扫描更多候选,直到凑够结果或达到扫描上限(`hnsw.max_scan_tuples`),参数以官方文档为准。
### 【进阶】更换嵌入模型要怎么做?
**参考答案**:新旧模型的向量空间不兼容,必须全量重新嵌入。做法:新增列 / 新集合存新向量,后台回填,用评测集对比效果,切换读路径,保留回滚能力,最后清理旧数据。记录每个块使用的模型版本。
**追问**:切换期间新文档写入怎么办?→ 双写:切换期间新文档同时生成新旧两种向量,回填只处理存量;切换读路径后再停止写旧向量。
## 场景题
### 【场景题】SaaS 产品多租户共用向量表,怎么防止数据串租户?
**参考答案**:数据访问层统一封装检索函数,`tenant_id` 必填;数据库层启用行级安全(RLS)兜底,应用使用非表所有者的角色连接,每个事务设置租户变量(连接池下必须用事务级);测试用例覆盖跨租户访问;日志审计。详见[多租户向量索引场景](scenarios/multi-tenant-index.md)。
**追问**:大租户和小租户检索性能差别很大怎么办?——分层:大租户单独分区 / 索引,小租户精确检索或开启迭代扫描。
### 【场景题】线上向量检索延迟从 50ms 涨到 500ms,怎么排查?
**参考答案**:先看是检索本身慢还是嵌入接口慢(分段计时);检索慢:执行计划是否走了向量索引(运算符类是否匹配)、索引是否能放进内存(`shared_buffers` / 内存不足导致大量磁盘读)、`ef_search` 是否被调大、过滤条件是否导致大量扫描、数据量是否增长、是否有大批量写入在重建索引。对照监控的时间点找变化。
**追问**:怎么提前发现这种问题?→ 监控检索 P95/P99 延迟、每次查询扫描的行数、索引大小和内存命中率;给慢查询设置日志阈值;数据量增长到一定程度前做容量评估。
@@ -0,0 +1,89 @@
---
id: ai.vector-db.scenario.multi-tenant-index
title: 多租户知识库的向量索引:隔离、过滤召回和增量更新
type: scenario
domain: ai
stack: [vector-db, postgresql]
tags: [多租户, 元数据过滤, 增量更新, 召回率, SaaS]
level: advanced
prerequisites: [ai.vector-db.selection-pgvector]
est_hours: 2.5
related: [ai.rag.scenario.permission-aware-kb, ai.vector-db.interview]
week: [6]
---
# 多租户知识库的向量索引:隔离、过滤召回和增量更新
## 业务背景与问题
一个面向中小企业的 SaaS 客服产品,每个企业(租户)上传自己的知识库。上线三个月后出现:
1. 一个小租户(2000 个块)提问,经常返回“没有找到相关资料”,但资料明明存在;
2. 一次代码改动漏了租户条件,A 公司的客服看到了 B 公司的产品价格;
3. 大租户每天更新上千篇文档,删除的文档还会被检索到;
4. 产品想换一个效果更好的嵌入模型,不知道怎么切换。
## 约束
- 租户数千个,大小差异很大(几千到几百万块)。
- 数据存储在 PostgreSQL + pgvector,不想引入新的组件。
- 租户之间的数据绝对不能串。
## 方案设计
1. **隔离方式按租户大小分层**:
- 普通租户共用一张表,每行带 `tenant_id`;
- 超大租户单独分区(PostgreSQL 声明式分区或单独的表),有自己的 HNSW 索引。
2. **问题 1(小租户召回不足)的原因**:全表 HNSW 索引先找全局近邻,再过滤 `tenant_id`,小租户的数据很少出现在全局候选中。解决:开启 pgvector 0.8 的迭代索引扫描 `SET hnsw.iterative_scan = relaxed_order`;或者对小租户直接走精确检索(`tenant_id` 索引过滤后计算距离,几千条很快)。
3. **问题 2(越权)**:租户条件不能依赖每个查询“记得加”。数据访问层封装统一的检索函数,`tenant_id` 是必填参数;数据库层用 **行级安全(RLS)** 兜底,连接时 `SET app.tenant_id`。
4. **问题 3(增量更新)**:每个块带 `doc_id`、`version`、`content_hash`;文档更新在一个事务里删除旧块、插入新块;文档删除同步删除块;内容哈希不变的块跳过重新嵌入,节省成本。
5. **问题 4(换嵌入模型)**:新增一列(或新表)存新模型的向量,后台批量回填,回填完成后对比评测集,再切换读路径,最后删除旧列。块表记录 `embed_model` 字段。
## 关键代码
```sql
-- 行级安全:即使查询漏了 WHERE tenant_id,也只能看到当前租户的数据
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON chunks
USING (tenant_id = current_setting('app.tenant_id'));
-- 注意:表的所有者和超级用户默认绕过 RLS,应用要用单独的普通角色连接,
-- 或者对表执行 ALTER TABLE chunks FORCE ROW LEVEL SECURITY。
-- 每个请求(事务)开始时设置租户
BEGIN;
SELECT set_config('app.tenant_id', 'tenant-42', true); -- true 表示只在当前事务有效
SET LOCAL hnsw.iterative_scan = relaxed_order; -- pgvector 0.8+,过滤后结果不足时继续扫描
SELECT id, content FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;
COMMIT;
-- 文档更新:在一个事务里替换
BEGIN;
DELETE FROM chunks WHERE tenant_id = $1 AND doc_id = $2;
INSERT INTO chunks (tenant_id, doc_id, version, content, embedding) VALUES (...);
COMMIT;
```
过滤查询和召回率对比的可运行示例:[examples/vector-stores/pgvector_demo.py](../examples/vector-stores/pgvector_demo.py)。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 共享表 + `tenant_id` 过滤 | 简单,租户多时管理成本低 | 过滤召回问题;越权风险靠 RLS 兜底 |
| 每租户一张表 / 一个集合 | 隔离彻底,索引小 | 租户上千时表和索引数量爆炸 |
| **分层(本方案)** | 兼顾 | 有两套路径,代码复杂一点 |
| 独立数据库(大客户私有化) | 合规要求最高时 | 成本最高 |
## 易错点
- 用表所有者账号连接,RLS 不生效却以为生效了。
- `set_config(..., false)` 设置的是会话级变量,连接池复用连接时会串到下一个请求;应该用事务级(`true`)或每次请求都重新设置。
- 删除文档只删了数据库里的记录,没删向量库 / ES 里的块(多存储时)。
- 换嵌入模型时新旧向量混在一起检索。
- 只用大租户做测试,没发现小租户的召回问题。评测集要按租户规模分组。
## 面试怎么讲
> “多租户向量检索我遇到过两个问题:一是小租户召回不足,原因是全局 HNSW 先找近邻再过滤,小租户的数据很少进入候选,我用 pgvector 0.8 的迭代扫描和小租户走精确检索解决;二是租户隔离,我在数据访问层强制要求 tenant_id,数据库层用行级安全兜底,并且注意连接池下要用事务级变量。增量更新按 doc_id 在事务里替换,内容哈希没变的块不重新嵌入。”