面试 Q&A — RAG 检索管线#
覆盖:混合检索(Vector + BM25 + RRF)、Rerank、Parent-Child 分块、pgvector 选型、知识库同步。
一、整体架构#
Q1: 举个具体例子,一个运维问题是怎么从知识库里找到答案的?#
场景:oncall 收到告警,在诊断平台输入 “RedisOOM 怎么排查”。知识库里有一篇 SOP 文档《Redis 故障排查手册》,其中”OOM 处理”章节正好覆盖这个问题。系统怎么从 4113 个 chunk 里精确找到它?
四步漏斗,逐层收敛:
-
粗召回(双路并行,各取 top-30)
- 向量检索(pgvector cosine):把 query 编码成向量,和所有 chunk 向量算距离。“RedisOOM” 在向量空间里离 “Redis 内存溢出” 很近 → 命中。但 “MySQL OOM” 也语义相似,被拉了进来——答非所问。
- BM25 关键词检索(ParadeDB pg_search):精确匹配 “RedisOOM” 这个词。文档原文就是 “RedisOOM”,BM25 直接命中,不会错配到 MySQL。
-
RRF 融合:两路结果只用排名不用分数(BM25 分数无界 vs cosine 在 [-1,1],不能直接相加)。公式
score(d) = Σ 1/(60+rank)。去重后保留 top-30。“MySQL OOM” 只在向量路出现、BM25 路没有,融合分低 → 被挤掉。 -
Cross-Encoder 精排(Rerank):粗召回的 Bi-Encoder 是 query 和 doc 分别编码再比距离,不看交叉信息。Rerank 用 gte-rerank-v2(Cross-Encoder),把 query 和候选 chunk 拼接后一起编码,精度高但慢——只能对 top-30 做。排完取 top-3。
-
Parent 展开:命中的 child chunk 只有 300 字(检索精度高),但 LLM 需要完整上下文。用 child 的
parent_id找到 parent chunk(完整段落,最多 2400 字),喂给 LLM。
| 阶段 | 输入 | 输出 | 关键作用 |
|---|---|---|---|
| 向量 + BM25 | 4113 chunks | 各 30 条(有重叠) | 互补盲区:语义 + 精确词 |
| RRF 融合 | ~60 条 | 30 条去重排序 | 挤掉只在单路出现的噪音 |
| Rerank | 30 条 | 3 条精排 | Cross-Encoder 交叉注意力提精度 |
| Parent 展开 | 3 个 child | 3 个 parent 段落 | 小 chunk 检索、大段落理解 |
效果:混合检索 + Rerank 把 R@1 从纯向量的 83.3% 提到 91.7%(自建 50 条 QA benchmark)。排第一的结果是否相关,直接决定 Agent 能不能一步找到正确 SOP。
Q2: 你的 RAG 检索流程是什么?#
三阶段漏斗:粗召回 → 融合 → 精排。
用户问题
│
├─ [1] 粗召回 (retrieve_k=30)
│ ├─ pgvector 向量检索 (cosine top-30)
│ └─ ParadeDB BM25 关键词检索 (top-30)
│
├─ [2] RRF 融合
│ └─ 两路结果按 Reciprocal Rank Fusion 合并去重 → 30 条
│
├─ [3] Cross-Encoder 精排 (rerank)
│ └─ DashScope gte-rerank-v2 或本地 FlagEmbedding → top-3
│
└─ [4] Parent 展开
└─ child chunk 命中 → 用 parent_content 喂给 LLM (完整段落)plaintext为什么不直接向量搜 top-3?因为向量搜擅长语义相似但弱于关键词精确匹配(比如搜 “RedisOOM”,BM25 一定能命中包含这个词的文档,但向量可能匹配到语义相近但不含这个词的段落)。混合检索 + Rerank 在我们的 benchmark 上把 R@1 从 83.3% 提到了 91.7%。
Q3: 你有量化数据证明 RAG 的效果吗?#
有,用自建的 50 条 QA 数据集跑了两类 benchmark:
检索质量(Retrieval):
- Hit@3:从纯向量的 83.3% → 混合+Rerank 的 91.7%(+10%)
- MRR@3:从 0.88 → 0.94
生成质量(RAGAS):
- Faithfulness(不编造):0.85+
- Context Precision(相关排前面):0.90+
- Answer Relevancy(回答切题):0.88+
benchmark 脚本支持消融实验:--no-hybrid、--no-rerank 可以单独关掉某一步看效果差异。
二、混合检索#
Q4: 为什么向量搜索不够,还要加 BM25?#
两种搜索各有盲区:
| 场景 | 向量搜索 | BM25 |
|---|---|---|
| ”Redis 内存满了怎么办” | ✅ 语义匹配 “OOM” 相关文档 | ✅ 命中 “Redis” “内存” 关键词 |
| ”RedisOOM” (精确术语) | ❌ 可能匹配到 MySQL OOM 的文档 | ✅ 精确匹配 “RedisOOM" |
| "数据库响应变慢” (模糊描述) | ✅ 语义理解 “响应变慢” | ❌ 文档里可能写的是 “延迟升高” |
混合检索的意义是互补盲区:向量覆盖语义变体,BM25 覆盖精确术语。我测下来,单独加 BM25(不加 Rerank)就能把 R@1 提升约 5%。
Q5: RRF 融合是什么?为什么不直接把两路分数加权求和?#
问题:BM25 分数是无界的(取决于文档长度和词频),cosine 在 [-1, 1]。直接加权求和,BM25 分数大的文档会碾压向量结果。
RRF 公式:只用排名,不用分数:
score(d) = Σ weight_i / (k + rank_i)plaintext# hybrid_retriever.py:69-90
for rank, doc in enumerate(vector_docs):
key = _dedup_key(doc)
scores[key] += vec_weight / (rrf_k + rank + 1)
for rank, doc in enumerate(bm25_docs):
key = _dedup_key(doc)
scores[key] += bm25_weight / (rrf_k + rank + 1)pythonk=60 是 TREC 会议的经验值,作用是平滑排名差异(排第 1 和排第 5 的差距不会太大)。
Q6: BM25 为什么从内存迁到数据库 (ParadeDB pg_search)?#
最初用 Python rank_bm25 库在内存里建索引:
| 问题 | 内存 BM25 | ParadeDB pg_search |
|---|---|---|
| 多进程共享 | 每个 uvicorn worker 各一份副本 | 数据库索引天然共享 |
| 数据量 | 10 万 chunk 后内存吃紧 | 走磁盘 + 缓存,无上限 |
| 新文档入库 | 重建整个索引 | INSERT 自动维护索引 |
| 一致性 | 和向量库不同步风险 | 同一张表,自然一致 |
迁移后 BM25 和向量检索都在 kb_chunks 表上操作,数据一致性由 Postgres 保证。
Q7: 面试追问:如果 pg_search 扩展不可用呢?#
降级处理——bm25_search() 在 except 里返回空列表:
# pg_vector_store.py:343-346
except Exception as exc:
logger.warning(f"[bm25] pg_search 不可用, 降级纯向量: {exc}")
return []pythonRRF 融合时如果 BM25 结果为空,就退化为纯向量排序,不影响系统可用性。
三、Rerank 精排#
Q8: 为什么要 Rerank?向量搜索 + BM25 融合不够吗?#
粗召回用的是 Bi-Encoder(query 和 doc 分别编码再算距离),速度快但不精确——它不看 query 和 doc 的交叉注意力。
Rerank 用的是 Cross-Encoder(query 和 doc 拼接后一起编码),精度高但慢——只能对 top-30 精排,不能对全库扫。
实测数据:加 Rerank 后 R@1 从 86% → 91.7%(+6.6%)。对运维诊断来说,排第一的结果是否相关直接决定 Agent 能不能一步找到 SOP。
Q9: Rerank 支持哪些后端?怎么选?#
# reranker.py — 两种后端
if settings.rag_rerank_provider == "dashscope":
return await _rerank_docs_dashscope(query, docs, top_n=top_n)
else:
return await asyncio.to_thread(_rerank_docs_local_sync, query, docs, top_n=top_n)python| 后端 | 模型 | 延迟 | 适用 |
|---|---|---|---|
| DashScope 云端 | gte-rerank-v2 | ~100ms | 生产:精度高、免部署 |
| 本地 FlagEmbedding | bge-reranker-v2-m3 | ~200ms (CPU) | 离线/无网:免 API Key |
本地模型用 @lru_cache 延迟加载,第一次调用时才下载模型。跑在 asyncio.to_thread 里避免阻塞事件循环。
Q10: Rerank 超时了怎么办?#
# reranker.py:109-123 — 降级到粗排
try:
reranked = await _rerank_docs_xxx(query, docs, top_n=top_n, timeout=timeout)
except Exception as exc:
logger.warning(f"[rerank] 失败, 降级到粗排前 top_n: {exc}")
return docs[:top_n]python任何异常(超时、API 错误、解析失败)都降级到粗排前 N 条。用户拿到的可能不是最优排序,但不会拿不到结果。
Q11: Rerank 的输入文本是怎么构造的?#
不是直接用 child chunk 的原文,而是拼了三级上下文:
# reranker.py:52-79 — _rerank_text(doc)
def _rerank_text(doc):
text = doc.page_content # child chunk (精确匹配段)
parent = doc.metadata.get("parent_content", "")
if parent:
text = f"{parent}\n---\n{text}" # parent 完整段落 (上下文)
chapter = doc.metadata.get("chapter", "")
source = doc.metadata.get("source", "")
if chapter:
text = f"来源: {source} | 章节: {chapter}\n{text}" # 元信息
return textpython给 Cross-Encoder 看更完整的上下文,排序更准确。
四、Parent-Child 分块#
Q12: 什么是 Parent-Child 分块?为什么不直接切固定大小的 chunk?#
矛盾:检索需要小 chunk(300 字以内,embedding 精确),LLM 需要大 chunk(完整段落,理解上下文)。
解法:
- 先按 Markdown 标题切成 parent chunk(自然段落,上限 2400 字)
- 每个 parent 再切成 child chunk(300 字,50 字重叠)
- 向量化和检索基于 child,命中后把 parent_content 传给 LLM
# splitter.py:116-206
def split_markdown(content, source):
# Step 1: MarkdownHeaderTextSplitter → parent chunks
# Step 2: parent > 2400 字 → 二次切分 (保护结构)
# Step 3: RecursiveCharacterTextSplitter → child chunks
# child.metadata["parent_id"] = md5(parent_text)[:12]
# child.metadata["parent_content"] = parent_text # 完整段落pythonQ13: “结构保护”是什么意思?#
Markdown 里有代码块、表格、LaTeX 公式,这些不能从中间切断:
# splitter.py:45-108
_PROTECTED_PATTERNS = [
r"```[\s\S]*?```", # 代码块
r"\|[^\n]*\|\n\|[\s\-:|]+\|(?:\n\|[^\n]*\|)*", # 表格
r"\$\$[\s\S]*?\$\$", # LaTeX 块
]
def _protect_and_split(text, splitter):
# 1. 正则找到所有保护区域
# 2. 替换为 __PLACEHOLDER_0__ 等占位符
# 3. 在占位符文本上切分
# 4. 还原占位符为原始内容python不做结构保护的后果:一张表格被切成两半,上半部分有表头没数据,下半部分有数据没表头,embedding 和 LLM 都无法理解。
Q14: child chunk 的 page_content 里为什么要加章节路径?#
# splitter.py — child 的 page_content
page_content = "[Redis 故障排查 / OOM 处理]\n<chunk_text>"pythonchapter path 前缀让 embedding 模型知道这个 chunk 属于哪个主题。实测消融实验:加 chapter path 后 R@1 从 83.3% → 91.7%(+10%),MRR 从 0.88 → 0.94。
原理:embedding 模型(如 text-embedding-v4)会把 “[Redis 故障排查]” 编码到向量里,当用户搜 “Redis OOM” 时,含有 “Redis 故障排查” 前缀的 chunk 向量天然更近。
五、pgvector 选型#
Q15: 为什么从 Milvus 迁到 pgvector?#
| 维度 | Milvus | pgvector |
|---|---|---|
| 部署 | 3 个容器 (etcd + minio + milvus) | 项目已有的 Postgres 加个扩展 |
| 运维 | 独立备份、独立监控、独立升级 | 和业务数据同一套 |
| 数据量 | 设计目标百万级 | 30 万以内性能充足 |
| 一致性 | 向量和 Postgres 业务数据分两个库 | 同库同事务 |
| BM25 | 不支持 | ParadeDB 扩展直接加 |
我们的知识库 4113 个 chunk,预估上限 10 万级。Milvus 在这个规模下是大炮打蚊子,运维成本和收益不成比例。
Q16: pgvector 用的什么索引?参数怎么调的?#
HNSW(Hierarchical Navigable Small World)索引:
CREATE INDEX idx_kb_chunks_embedding ON kb_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);sql| 参数 | 值 | 含义 |
|---|---|---|
| m | 16 | 每个节点的边数。越大 → 索引越大/建得越慢/查得越准 |
| ef_construction | 200 | 建索引时的搜索宽度。越大 → 建得越慢/索引质量越好 |
| ef_search | 128 | 查询时的搜索宽度。运行时可调(SET LOCAL) |
这些是中等规模(1 万-30 万)的典型值。如果数据量超过 50 万,m 可以调到 32。
Q17: 面试追问:为什么不用 Qdrant?它原生支持混合检索。#
Qdrant 确实是更好的向量数据库(支持原生混合检索、过滤、分组),但:
- 又多一个独立服务需要部署和运维
- 和 Postgres 业务数据分离,事务一致性需要自己保证
- 我需要的 BM25 + 向量混合,pgvector + ParadeDB 已经能做
- 如果未来确实需要更强的向量搜索能力(量化压缩、GPU 加速),可以迁
选型的核心原则是 “能复用已有基础设施就不引入新组件”。
六、知识库同步#
Q18: 知识库怎么更新?支持增量吗?#
支持增量同步。流程:
# kb_sync_service.py — sync_source()
for doc in remote_docs:
if doc.version == local_version and doc.status == 'active':
skip # 版本没变, 直接跳过 (零成本)
blob = await fetch_blob(doc)
md = normalize_to_markdown(blob) # MinerU 解析 PDF/DOCX
content_hash = sha256(md)
if content_hash == local_hash:
update_version_only() # 内容没变但版本号变了 (免费跳过)
else:
chunks = split_markdown(md)
await pg_vector_store.replace_doc(doc_id, chunks) # 原子替换python两级跳过优化:
source_version没变 → 连下载都不做(适用于 API 带版本号的场景)content_hash没变 → 跳过重新分块和 embedding(最贵的步骤)
Q19: replace_doc 是怎么做原子替换的?#
# pg_vector_store.py — replace_doc()
embeddings = await embed(chunks) # ⬅ 在事务外算 (失败不影响旧数据)
async with conn.transaction():
await conn.execute("DELETE FROM kb_chunks WHERE doc_id = $1", doc_id)
await conn.executemany("INSERT INTO kb_chunks ...", rows) # 新数据pythonEmbedding 在事务外计算:如果 embedding API 挂了,旧数据完好不动。只有 embedding 成功后才开事务做 DELETE + INSERT。
Q20: 多进程同时触发同步怎么办?#
用 Postgres advisory lock 做进程间互斥:
# kb_sync_service.py:175-188
got = await lock_conn.fetchval(
"SELECT pg_try_advisory_lock($1, $2)", _SYNC_LOCK_NS, lock_key
)
if not got:
return {"skipped_reason": "locked"} # 另一个进程在同步, 跳过pythonpg_try_advisory_lock 是非阻塞的——拿不到锁直接返回 false,不会排队等。每个 source 独立锁,source A 同步不影响 source B。
七、RAG Chat#
Q21: 多轮对话的上下文是怎么管理的?#
Redis 存会话历史,LLM 调用前做 query rewrite:
# rag/memory.py — rewrite_question()
# "刚才那个 Redis 问题怎么解" + 历史 ["Redis OOM 怎么排查", "..."]
# → 改写为 "Redis OOM 排查的后续处理步骤"python会话管理:
- 每条消息存 Redis(user/assistant 交替)
- 超过阈值后自动压缩(旧消息摘要为 1-2 句)
- TTL 过期自动清理(默认 7 天)
Q22: 如果 query rewrite 失败了怎么办?#
# 降级: 用原始 question
try:
rewritten = await rewrite_question(question, summary=summary, recent=recent)
except Exception:
rewritten = question # 降级到原始问题pythonRewrite 是增强不是必须——用原始 query 检索可能召回率稍低,但不影响系统可用性。
八、设计取舍#
Q23: RAG 在这个运维诊断系统里到底有多大价值?#
核心价值:把企业自有的 SOP(标准操作流程)注入到 LLM 诊断流程中。
LLM 的通用知识知道 “Redis OOM 一般怎么排查”,但不知道 “我们公司的 Redis 集群用的是什么版本、什么配置、历史上出过什么问题、运维团队的标准操作流程是什么”。RAG 补的是这一块。
量化方式:可以做消融实验——关掉 RAG(--no-rag),对比诊断报告是否缺少企业特有的上下文。
Q24: 如果重来 RAG 这块,你会改什么?#
- Query rewriting 加 HyDE:生成一个假设性答案做检索,可能比 query rewrite 效果更好。
- Chunk 级反馈:记录 Agent 实际用了哪些 chunk,没用的说明召回不准,可以做负采样优化 embedding。
- 多源一致性:目前同步是定时的,如果飞书文档刚更新、知识库还没同步,Agent 用的是旧版 SOP。可以在 Agent 调用 RAG 前触发一次实时同步。