面试知识库

面试 Q&A — RAG 检索管线#

覆盖:混合检索(Vector + BM25 + RRF)、Rerank、Parent-Child 分块、pgvector 选型、知识库同步。


一、整体架构#

Q1: 举个具体例子,一个运维问题是怎么从知识库里找到答案的?#

场景:oncall 收到告警,在诊断平台输入 “RedisOOM 怎么排查”。知识库里有一篇 SOP 文档《Redis 故障排查手册》,其中”OOM 处理”章节正好覆盖这个问题。系统怎么从 4113 个 chunk 里精确找到它?

四步漏斗,逐层收敛:

  1. 粗召回(双路并行,各取 top-30)

    • 向量检索(pgvector cosine):把 query 编码成向量,和所有 chunk 向量算距离。“RedisOOM” 在向量空间里离 “Redis 内存溢出” 很近 → 命中。但 “MySQL OOM” 也语义相似,被拉了进来——答非所问。
    • BM25 关键词检索(ParadeDB pg_search):精确匹配 “RedisOOM” 这个词。文档原文就是 “RedisOOM”,BM25 直接命中,不会错配到 MySQL。
  2. RRF 融合:两路结果只用排名不用分数(BM25 分数无界 vs cosine 在 [-1,1],不能直接相加)。公式 score(d) = Σ 1/(60+rank)。去重后保留 top-30。“MySQL OOM” 只在向量路出现、BM25 路没有,融合分低 → 被挤掉。

  3. Cross-Encoder 精排(Rerank):粗召回的 Bi-Encoder 是 query 和 doc 分别编码再比距离,不看交叉信息。Rerank 用 gte-rerank-v2(Cross-Encoder),把 query 和候选 chunk 拼接后一起编码,精度高但慢——只能对 top-30 做。排完取 top-3。

  4. Parent 展开:命中的 child chunk 只有 300 字(检索精度高),但 LLM 需要完整上下文。用 child 的 parent_id 找到 parent chunk(完整段落,最多 2400 字),喂给 LLM。

阶段输入输出关键作用
向量 + BM254113 chunks各 30 条(有重叠)互补盲区:语义 + 精确词
RRF 融合~60 条30 条去重排序挤掉只在单路出现的噪音
Rerank30 条3 条精排Cross-Encoder 交叉注意力提精度
Parent 展开3 个 child3 个 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)
python

k=60 是 TREC 会议的经验值,作用是平滑排名差异(排第 1 和排第 5 的差距不会太大)。

最初用 Python rank_bm25 库在内存里建索引:

问题内存 BM25ParadeDB 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 []
python

RRF 融合时如果 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生产:精度高、免部署
本地 FlagEmbeddingbge-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 text
python

给 Cross-Encoder 看更完整的上下文,排序更准确。


四、Parent-Child 分块#

Q12: 什么是 Parent-Child 分块?为什么不直接切固定大小的 chunk?#

矛盾:检索需要小 chunk(300 字以内,embedding 精确),LLM 需要大 chunk(完整段落,理解上下文)。

解法

  1. 先按 Markdown 标题切成 parent chunk(自然段落,上限 2400 字)
  2. 每个 parent 再切成 child chunk(300 字,50 字重叠)
  3. 向量化和检索基于 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  # 完整段落
python

Q13: “结构保护”是什么意思?#

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

chapter 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?#

维度Milvuspgvector
部署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
参数含义
m16每个节点的边数。越大 → 索引越大/建得越慢/查得越准
ef_construction200建索引时的搜索宽度。越大 → 建得越慢/索引质量越好
ef_search128查询时的搜索宽度。运行时可调(SET LOCAL)

这些是中等规模(1 万-30 万)的典型值。如果数据量超过 50 万,m 可以调到 32。

Q17: 面试追问:为什么不用 Qdrant?它原生支持混合检索。#

Qdrant 确实是更好的向量数据库(支持原生混合检索、过滤、分组),但:

  1. 又多一个独立服务需要部署和运维
  2. 和 Postgres 业务数据分离,事务一致性需要自己保证
  3. 我需要的 BM25 + 向量混合,pgvector + ParadeDB 已经能做
  4. 如果未来确实需要更强的向量搜索能力(量化压缩、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

两级跳过优化

  1. source_version 没变 → 连下载都不做(适用于 API 带版本号的场景)
  2. 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)  # 新数据
python

Embedding 在事务外计算:如果 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"}  # 另一个进程在同步, 跳过
python

pg_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  # 降级到原始问题
python

Rewrite 是增强不是必须——用原始 query 检索可能召回率稍低,但不影响系统可用性。


八、设计取舍#

Q23: RAG 在这个运维诊断系统里到底有多大价值?#

核心价值:把企业自有的 SOP(标准操作流程)注入到 LLM 诊断流程中。

LLM 的通用知识知道 “Redis OOM 一般怎么排查”,但不知道 “我们公司的 Redis 集群用的是什么版本、什么配置、历史上出过什么问题、运维团队的标准操作流程是什么”。RAG 补的是这一块。

量化方式:可以做消融实验——关掉 RAG(--no-rag),对比诊断报告是否缺少企业特有的上下文。

Q24: 如果重来 RAG 这块,你会改什么?#

  1. Query rewriting 加 HyDE:生成一个假设性答案做检索,可能比 query rewrite 效果更好。
  2. Chunk 级反馈:记录 Agent 实际用了哪些 chunk,没用的说明召回不准,可以做负采样优化 embedding。
  3. 多源一致性:目前同步是定时的,如果飞书文档刚更新、知识库还没同步,Agent 用的是旧版 SOP。可以在 Agent 调用 RAG 前触发一次实时同步。