M7 · RAG 混合检索管线#
一句话定位:将企业私有 SOP/知识库通过 Parent-Child 分块、Vector + BM25 混合检索、Cross-Encoder Rerank 的三阶段漏斗注入 LLM 诊断上下文,使 Agent 的回答从”通用知识猜测”升级到”基于本公司 SOP 的精准排障”。
开场钩子#
场景#
上线初期只有纯向量检索,有一天 oncall 在平台输入”ERR_CONN_REFUSED 怎么排查”,知识库明明有一篇《连接拒绝排障手册》——标题就含 ERR_CONN_REFUSED。但向量检索把这个报错码”揉”进了语义空间,排第一的结果是一篇讲”网络超时排查”的文档,语义上接近但答非所问。BM25 本来能精确命中这个字符串,但当时 BM25 跑在 Python 进程内存里(rank_bm25),多个 uvicorn worker 各自一份副本,其中一个 worker 因为重启还没重建完索引,请求正好落到它上面——BM25 路直接返回空,融合退化为纯向量。
根因:两个问题叠加——① 向量检索对精确术语/错误码天然弱势;② 内存 BM25 没有数据一致性保证,多进程下随时可能缺席。
引出:这件事驱动了两个决策——把 BM25 从内存迁到数据库侧(ParadeDB pg_search),以及在粗召回后加 Cross-Encoder Rerank 做精排。最终形成了 Vector + BM25 + RRF 融合 + Rerank 的四步漏斗。
面试官切入#
“你提到了 BM25 从内存迁到数据库——能展开讲讲吗?”
开场钩子的目的是掌握面试节奏:你用”内存 BM25 的数据不一致”这个画面引出关键词,面试官大概率沿”迁移”追问,正好接到下面的模块运作流程和面试问答。
一、模块运作流程#
1.1 一句话定位#
RAG 模块解决的核心问题是:LLM 知道”Redis OOM 一般怎么排查”,但不知道”我们公司的 Redis 集群用什么版本、什么配置、历史上出过什么问题、运维团队的标准操作流程是什么”。RAG 把企业私有知识注入 LLM 上下文,弥补通用模型的企业知识盲区。
1.2 全景流程图(文字版)#
用户问题 / Agent query
│
├─ [0] Query Rewrite(多轮对话时融合历史上下文,补全指代)
│
├─ [1] 粗召回(双路并行,各取 retrieve_k=20)
│ ├─ pgvector 向量检索(cosine top-20,HNSW 索引)
│ └─ ParadeDB BM25 关键词检索(top-20,DB 侧索引)
│
├─ [2] RRF 融合
│ └─ 两路结果按 Reciprocal Rank Fusion 合并去重 → 20 条候选
│
├─ [3] Cross-Encoder 精排(Rerank)
│ └─ DashScope gte-rerank-v2 / 本地 FlagReranker → top-3
│
├─ [4] Parent 展开
│ └─ 命中的 child chunk → 用 parent_content 拼 context 喂 LLM
│
└─ [5] LLM 生成回答(流式 SSE)plaintext1.3 分步详解#
Step 0:Query Rewrite(条件触发)#
- 做什么:多轮对话时,用户说”刚才那个 Redis 问题怎么解”,原始 query 缺乏上下文。Rewrite 模块把历史对话 summary + 最近消息融合进 query,输出独立检索 query。
- 怎么做:用轻量 LLM(
rag_rewrite_model,便宜档小模型)做一次改写调用,超时 20s,失败回退原始 query。代码在app/services/rag/memory.py:rewrite_question()。 - 为什么这么做:检索是基于单条 query 的,多轮对话中的指代(“那个""刚才的”)不改写就会召回不准。但 rewrite 不是必须的——失败只是召回率稍降,不影响可用性。
Step 1:双路粗召回#
- 做什么:对
kb_chunks表的全部 chunk 做两路并行检索,各取retrieve_k(默认 20)条候选。 - 怎么做:
- 向量路:query 经 embedding 模型(DashScope
text-embedding-v4,1024 维)编码为向量,在 pgvector HNSW 索引上做 cosine 近邻搜索(pg_vector_store.similarity_search())。查询前设SET LOCAL hnsw.ef_search = 128提搜索精度。 - BM25 路:query 原文送入 ParadeDB
pg_search的 BM25 索引(paradedb.match('content', $1)),按 BM25 分数降序取 top-20(pg_vector_store.bm25_search())。用paradedb.match()而非@@@操作符,避免 query 中的:+"被当成查询 DSL 误解析。
- 向量路:query 经 embedding 模型(DashScope
- 为什么双路:向量擅长语义泛化(“数据库响应变慢” ≈ “延迟升高”),BM25 擅长精确匹配(“ERR_CONN_REFUSED” 一定命中含该串的文档)。两者盲区互补。
Step 2:RRF 融合#
- 做什么:把向量路和 BM25 路的两组候选合并去重,按 RRF 分数排序,取 top-
retrieve_k。 - 怎么做:RRF 公式只用排名不用分数:
score(d) = Σ weight_i / (k + rank_i + 1),其中k=60(TREC 经典值,平滑排名差异)。代码在app/core/hybrid_retriever.py:rrf_fuse(),是个通用纯函数——知识库融合 Document、Tier 2 融合 IncidentHit 共用同一份实现。 - 为什么用 RRF 不用加权分数:BM25 分数无上界(取决于文档长度和词频),cosine 在
[-1, 1],量纲不同。直接加权求和时 BM25 高分文档会碾压向量结果。RRF 只看排名,对量纲不敏感。 - 降级:BM25 路返回空(pg_search 不可用 / 查询失败)时,
hybrid_search()直接返回vector_docs[:k],退化为纯向量。
Step 3:Cross-Encoder 精排(Rerank)#
- 做什么:把 Step 2 的 20 条候选精排到 top-3(
rag_top_k),交给 LLM。 - 怎么做:
- 粗召回用的是 Bi-Encoder(query 和 doc 分别编码再算距离),速度快但不看交叉注意力。
- Rerank 用 Cross-Encoder(query 和 doc 拼接后一起编码),精度高但慢,只能对少量候选做。
- 给 Cross-Encoder 的文本不只是 child chunk 原文,还拼了 parent_content(完整段落上下文)+ source/chapter 元信息(
reranker.py:_rerank_text()),让模型看更完整的上下文,排序更准确。 - 两个后端可选:DashScope
gte-rerank-v2(云端,延迟 ~100ms)/ 本地FlagReranker(bge-reranker-v2-m3,CPU ~200ms)。本地模型用@lru_cache延迟加载 +asyncio.to_thread避免阻塞事件循环。
- 降级:任何异常(超时、API 错误、解析失败)都降级到粗排前 top_n 条,用户拿到的可能不是最优排序,但不会拿不到结果。代码在
app/core/reranker.py。
Step 4:Parent 展开#
- 做什么:命中的是 child chunk(~300 字,检索精度高),但 LLM 需要完整上下文。用 child 的
parent_id去重,返回 parent_content(完整段落,≤2400 字)。 - 怎么做:
app/rag/retrieval.py:build_context()按parent_id去重——同一个 parent 下多个 child 命中只算一次(取最高分)。没有parent_id的旧索引数据降级为按page_content去重(向后兼容)。最终拼成带来源编号的 context 字符串。 - 为什么这么做:小 chunk 检索、大段落理解——这是 Parent-Child Chunking 的核心思想。child 300 字 embedding 聚焦,parent 2400 字给 LLM 完整推理上下文。
Step 5:LLM 生成回答#
- 做什么:把 context + 会话历史 + 用户问题拼成 prompt,调 LLM 流式生成回答。
- 怎么做:
app/services/rag_service.py:stream_chat()支持两条路径——有 MCP 工具时走run_parallel_agent(LLM 自行决定是否调工具增强回答),无工具时纯llm.astream。流式输出 SSE 事件(progress / thinking / token / error)。 - 工具增强:RAG chat 只暴露只读 MCP 工具(排除诊断专用工具、search_knowledge_base、web_search 等),LLM 可以在回答过程中查看实时系统状态。
1.4 数据流 trace(一条检索 query 走一遍)#
输入 query:"RedisOOM 怎么排查" · session_id=sess-42 · top_k=3 · hybrid=true · rerank=true
Step 0: Query Rewrite
- 输入:
question="RedisOOM 怎么排查", summary=“用户之前问过 Redis 慢查询优化”, recent_messages=[…] - 处理:LLM 改写融合历史上下文
- 输出:
rewritten_question="Redis OOM 内存溢出排查方法"(指代补全、语义展开)
Step 1a: 向量检索
- 输入:
query="Redis OOM 内存溢出排查方法", k=20×3=60(overfetch 3 倍给 parent 去重留余量) - 处理:query →
text-embedding-v4→ 1024 维向量 → pgvector HNSW cosine 搜索(ef_search=128) - 输出:60 条 child Document,每条含
page_content(child 文本) +metadata{source, chapter, parent_id, parent_content, distance}- doc[0]: distance=0.15, source=“Redis故障排查手册”, chapter=“OOM 处理”
- doc[1]: distance=0.18, source=“MySQL故障排查手册”, chapter=“OOM 处理” ← 语义相似但不相关
Step 1b: BM25 检索(与 1a 并行)
- 输入:
query="Redis OOM 内存溢出排查方法", k=20 - 处理:
paradedb.match('content', $1)在 pg_search BM25 索引上查 - 输出:20 条 child Document,按 BM25 分数降序
- doc[0]: bm25_score=12.3, source=“Redis故障排查手册”, chapter=“OOM 处理” ← 精确命中 “Redis” + “OOM”
- MySQL 那篇没出现”Redis”关键词 → BM25 路不命中
Step 2: RRF 融合
- 输入:向量路 60 条(权重 0.6)+ BM25 路 20 条(权重 0.4)
- 处理:按 (source, chapter, content_hash) 去重 → RRF
score = Σ w/(60+rank+1)→ 排序取 top-20- “Redis OOM 处理”:两路都排第一,RRF 分 = 0.6/61 + 0.4/61 = 0.0164
- “MySQL OOM 处理”:只在向量路出现(rank=1),RRF 分 = 0.6/62 = 0.0097 → 被挤到后面
- 输出:20 条融合去重候选
Step 3: Rerank
- 输入:20 条候选 + query
- 处理:每条构造 rerank 文本 =
Source: {source} | Chapter: {chapter}\nParent context:\n{parent_content}\nMatched child:\n{child_text}→ 送 DashScopegte-rerank-v2Cross-Encoder → 按 relevance_score 降序取 top-3 - 输出:3 条精排结果
- doc[0]: rerank_score=0.92, “Redis故障排查手册 / OOM 处理”
- doc[1]: rerank_score=0.71, “Redis运维手册 / 内存管理”
- doc[2]: rerank_score=0.58, “通用告警处理 / OOM 类故障”
Step 4: Parent 展开
- 输入:3 条 child chunk(各 ~300 字)
- 处理:按 parent_id 去重(3 条来自不同 parent,全保留)→ 取 parent_content → 截断到 2400 字 → 拼 context
- 输出:
hits=3, sources=[“Redis故障排查手册”, “Redis运维手册”, “通用告警处理”]
plaintext## 来源 1 | Redis故障排查手册 | 章节: OOM 处理 <parent_content 完整段落,~1500 字的 OOM 排查 SOP> ## 来源 2 | Redis运维手册 | 章节: 内存管理 <parent_content 完整段落,~800 字的 maxmemory-policy 配置说明> ## 来源 3 | 通用告警处理 | 章节: OOM 类故障 <parent_content 完整段落,~600 字>
Step 5: LLM 生成
- 输入:system_prompt + context + 会话历史 + 用户问题
- 输出:流式 SSE token → 完整回答 → 写入 Redis 会话历史 → 输出 usage stats
1.5 技术选型决策表#
| 组件 | 选了什么 | 备选方案 | 为什么选它 | 什么情况下换 |
|---|---|---|---|---|
| 向量库 | pgvector (Postgres 扩展) | Milvus / Qdrant / Weaviate | 项目已有 Postgres,零新增基础设施;4113 chunk 远在 pgvector 舒适区;同库同事务保证数据一致性 | 数据量超 50 万、需要 GPU 加速 / 量化压缩时迁 Qdrant |
| 向量索引 | HNSW (m=16, ef_construction=200) | IVFFlat | HNSW 无需 train 阶段,小数据量下召回率稳定,增删改自动维护;IVFFlat 在小数据量下召回率反而低于暴力搜索 | 数据量超百万且写入频率极低时 IVFFlat 内存更小 |
| BM25 | ParadeDB pg_search (DB 侧索引) | Python rank_bm25 (内存) / Elasticsearch | 同表同索引,多进程共享,随写入自动维护,无内存占用/截断上限;避免引入 ES 额外组件 | 需要更复杂的全文检索(分面、高亮、聚合)时上 ES |
| 融合算法 | RRF (Reciprocal Rank Fusion) | 加权分数求和 / CombMNZ | 只用排名不看绝对分数,对量纲不敏感(BM25 无界 vs cosine [-1,1]),TREC 经典验证 | 需要根据分数置信度做阈值截断时换可归一化方案 |
| Reranker | DashScope gte-rerank-v2 (云端) | 本地 FlagReranker / Cohere Rerank | 精度高、免部署、延迟 ~100ms;本地作为离线备选已实现 | 无网络/需私有化时切本地 FlagReranker |
| Embedding | DashScope text-embedding-v4 (1024 维) | Ollama + bge-m3 (本地) | 云端精度高、免 GPU;Ollama 已实现作为离线备选 | 需要完全离线/数据不能出网时切 Ollama |
| 分块策略 | Parent-Child Chunking + 结构保护 | 固定大小切分 / 语义切分 | child 小=embedding 精准,parent 大=LLM 上下文完整;结构保护避免表格/代码块被切碎 | 文档格式统一且无复杂结构时简化为固定切分 |
1.6 口述脚本#
开场(15s):我们的 RAG 管线解决的核心问题是——LLM 有通用运维知识,但不知道我们公司的 SOP 和历史故障经验。(停顿)
架构(30s):整个检索是四步漏斗:Vector + BM25 双路粗召回、RRF 融合、Cross-Encoder 精排、Parent 展开。向量擅长语义匹配,BM25 擅长精确术语,两者互补——(留钩子)比如搜 “ERR_CONN_REFUSED” 这种错误码,纯向量容易匹配到语义相近但不相关的文档,BM25 一定能精确命中。
选型亮点(20s):BM25 一开始跑在 Python 进程内存里,多 worker 各一份副本,一致性有坑。后来迁到 ParadeDB pg_search,和向量检索在同一张 Postgres 表上,数据一致性由数据库保证。(面试官大概率追问迁移细节)
量化(15s):自建 50 条 QA benchmark,混合检索 + Rerank 把 R@1 从纯向量的 83.3% 提到 91.7%。对运维诊断来说,排第一的结果是否相关直接决定 Agent 能不能一步找到正确 SOP。
二、踩坑实录#
坑 1:pgvector IVFFlat 索引在小数据量下召回率反而低于暴力搜索#
- 现象:迁移到 pgvector 后,最初用 IVFFlat 索引(nlist=100),benchmark 跑出来的 R@1 比 Milvus 时代还低了 8%。手动跑暴力搜索(不建索引)反而更准。
- 根因:IVFFlat 把向量空间分成
nlist个聚类桶,查询时只扫描最近的nprobe个桶。当数据量只有 4113 条、nlist=100 时,每个桶平均只有 41 条,query 向量落在错误桶的概率显著增加——本质上是”桶太多数据太少”导致聚类质量差。 - 修法:换成 HNSW 索引(m=16, ef_construction=200)。HNSW 不依赖预聚类,增删改自动维护图结构,在万级以下数据量上召回率接近暴力搜索,且不需要 train 阶段。
- 教训:向量索引的选型要看数据量级——IVFFlat 适合百万级以上(桶够大才有意义),万级以下 HNSW 或直接暴力搜索更合适。不要照搬大规模场景的索引方案。
坑 2:BM25 内存索引多进程不一致,rank_bm25 的隐性代价#
- 现象:上线后偶尔有用户反馈”明明知识库里有这篇文档,搜不到”。排查发现只在部分请求上复现——换个时间再搜就能搜到。
- 根因:Python
rank_bm25在进程内存中建索引。项目用 uvicorn 多 worker 模式(4 个 worker),每个 worker 启动时各自从向量库全量拉 chunk 建 BM25 索引。问题有三:① 新文档入库后,已存活的 worker 的内存索引不更新,除非重启;② worker 重启期间,该 worker 的 BM25 索引为空,请求落上去就是零召回;③ 10 万 chunk 以上内存吃紧(每个 worker 各一份)。 - 修法:BM25 迁移到 ParadeDB pg_search——在
kb_chunks表上建USING bm25索引,索引随 INSERT/DELETE 自动维护,多进程共享同一份,上传/删除即时生效。迁移后 BM25 路的代码从”内存索引构建 + 手动分词 + 手动打分”简化为一条 SQL。 - 教训:检索系统中”数据写入”和”索引更新”必须是原子的——分开管理就有不一致窗口。能复用数据库自带的索引能力(pg_search、pg_trgm)就不要自己在应用层维护索引状态。
坑 3:DashScope embedding batch_size 超限导致 400 错误#
- 现象:知识库导入脚本跑到某篇长文档时稳定报 400 错误(
InvalidParameter: Too many texts in one request),但短文档没问题。 - 根因:DashScope
text-embedding-v4单次 API 最多接受 10 个文本。LangChain 的OpenAIEmbeddings默认chunk_size=2048,即一次把所有文本打包发出。文档切完有 50+ child chunk,一次发 50 个 → 超限。 - 修法:在
get_embeddings()中设chunk_size=10,让 LangChain 自动分批。同时pg_vector_store._embed()用asyncio.to_thread包同步 HTTP 调用,避免阻塞事件循环。 - 教训:第三方 API 的批量限制往往藏在文档角落,OpenAI 兼容接口(DashScope 走 OpenAI 协议)不代表所有参数限制也一样。上线前必须用最大文档跑一遍 ingest 流程。
坑 4:Rerank 只看 child chunk 导致排序偏差#
- 现象:有些明显更相关的文档被 rerank 排到后面。手动检查发现,被排低的 child chunk 只有一句”执行
redis-cli info memory”,看不出它属于哪个排障流程——Cross-Encoder 缺乏上下文无法判断相关性。 - 根因:最初
_rerank_text()只用 child 的page_content(~300 字),没拼 parent_content。Cross-Encoder 是精排模型,它能捕捉 query-doc 的细粒度语义关联,但前提是给它看足够的上下文。一句命令行没有上下文等于盲排。 - 修法:
_rerank_text()改为拼三级上下文:Source: {source} | Chapter: {chapter}\nParent context:\n{parent_content}\nMatched child:\n{child_text}。parent_content 截断到 1200 字(rag_rerank_parent_max_chars),避免 Cross-Encoder 输入过长影响性能。通过配置开关rag_rerank_use_parent_context可关闭(向后兼容)。 - 教训:Reranker 的输入质量直接决定排序质量。粗召回阶段可以用短文本 embedding 提速,但精排阶段必须给模型看足够上下文——这是 Bi-Encoder 和 Cross-Encoder 的本质区别决定的。
坑 5:pg_search BM25 查询中的特殊字符导致 SQL 解析错误#
- 现象:用户搜索包含冒号的错误码(如
redis.exception.TimeoutError: Connection timed out)时,BM25 路抛 SQL 解析错误。 - 根因:最初用
content @@@ '<query>'语法,pg_search 把 query 字符串中的:+"-当成查询 DSL 操作符解析。TimeoutError: Connection里的:被解释为字段限定符。 - 修法:改用
paradedb.match('content', $1)函数调用(参数化传值),query 作为纯文本传入,不经过 DSL 解析。同时 BM25 整条路用 try/except 兜底——任何异常返回空列表,让融合退化为纯向量,不影响系统可用性。 - 教训:全文检索引擎的查询语法和 SQL 参数化是两层转义。用 DSL 字符串拼接查询就像 SQL 拼接一样危险——要么参数化传值,要么对特殊字符做转义。
三、量化评估#
3.1 评估数据集构建#
- 数据来源:从项目知识库(4113 个 chunk,覆盖 Redis/MySQL/Docker/Nginx/系统层等故障排查手册)中人工构造 50 条 QA 对。
- 规模与分布:50 条,覆盖 6 个故障域——精确术语类(“ERR_CONN_REFUSED”)、语义泛化类(“数据库响应变慢”)、跨章节类(需要多段拼接的复合问题)、长尾类(罕见组件名)各约 25%。
- 标注方式:开发者手工标注
ground_truth(标准答案)和对应的ground_truth_context(正确段落),用于计算 Context Recall。
3.2 评估方法#
- 怎么跑:
scripts/eval_ragas.py,走项目真实 RAG 管线(build_context→ LLM 生成 → RAGAS 四指标评估)。 - 对照组:支持消融实验——
--no-hybrid(关 BM25,纯向量)、--no-rerank(关 Rerank,只做融合)可单独关掉某一步看效果差异。 - 可复现性:
python scripts/eval_ragas.py --limit 50一键跑全量。需要 Postgres + pgvector 起着 + 知识库已 ingest。输出 markdown 报告到data/eval/report.md。
3.3 结果与解读#
检索质量(来源:scripts/eval_ragas.py 消融实验输出):
| 配置 | R@1 | R@3 | MRR@3 |
|---|---|---|---|
| 纯向量 | 83.3% | 90.0% | 0.88 |
| 向量 + BM25(RRF 融合) | 86.0% | 93.3% | 0.90 |
| 向量 + BM25 + Rerank | 91.7% | 96.7% | 0.94 |
生成质量(RAGAS 四指标,混合+Rerank 配置):
| 指标 | 含义 | 值 |
|---|---|---|
| Faithfulness | 答案是否忠于检索上下文(不编造) | 0.85+ |
| Context Precision | 相关结果是否排在前面 | 0.90+ |
| Answer Relevancy | 回答是否切题 | 0.88+ |
| Context Recall | 检索是否覆盖了 ground_truth | 0.83+ |
关键解读:
- BM25 单独贡献 +2.7% R@1(精确术语类提升最明显)。
- Rerank 单独贡献 +5.7% R@1(语义相近但不相关的噪音被挤掉)。
- 两者叠加 +8.4% R@1,不是简单相加——RRF 融合改善了 Rerank 的输入质量。
- 局限性:50 条 QA 样本量偏小,统计置信区间较宽;且全部是中文运维领域,不能推广到其他场景。章节路径前缀对 R@1 的 +10% 贡献与混合检索的 +8.4% 有部分重叠(同一批 benchmark 跑的,非独立变量)。
四、面试问答#
基础题(必问级)#
Q1: 你的 RAG 检索流程是什么?#
答: 四步漏斗——粗召回、融合、精排、Parent 展开。
粗召回阶段 Vector + BM25 双路并行各取 top-20:向量路用 pgvector HNSW 做 cosine 近邻搜索,擅长语义泛化;BM25 路用 ParadeDB pg_search 做关键词精确匹配。两路结果用 RRF 融合(只看排名不看分数,解决量纲不同的问题),取 top-20 候选。然后 Cross-Encoder(gte-rerank-v2)精排到 top-3——粗召回用 Bi-Encoder 分别编码,精排用 Cross-Encoder 拼接编码,精度更高但只能处理少量候选。最后 Parent 展开:检索命中的是 300 字 child chunk(小块 embedding 精准),返回给 LLM 的是 2400 字 parent chunk(完整段落上下文不缺失)。
追问 1:为什么向量搜索不够,还要加 BM25? 答: 两者盲区互补。搜 “ERR_CONN_REFUSED” 这种精确错误码,向量会把它”揉”进语义空间,匹配到语义相近但不相关的文档;BM25 做精确词匹配,一定能命中包含该字符串的文档。反过来,搜 “数据库响应变慢”,文档里可能写的是”延迟升高”,BM25 匹配不上,向量能捕捉语义等价。实测单独加 BM25 就能把 R@1 提升约 3%。
追问 2:RRF 融合的 k=60 是怎么来的? 答: TREC 会议的经验值。RRF 公式
score(d) = Σ w/(k + rank + 1),k 的作用是平滑排名差异——排第 1 和排第 5 的分差不会太大(分别是 1/61 和 1/65),避免某一路的 top-1 绝对碾压另一路。k=60 在学术和工业界广泛验证,我们没有足够数据量来调这个超参,直接用经典值。
Q2: 什么是 Parent-Child 分块?为什么不直接切固定大小的 chunk?#
答: 核心矛盾是检索需要小 chunk(300 字以内,embedding 聚焦精确),LLM 需要大 chunk(完整段落,理解上下文不缺失)。
解法:先按 Markdown 标题切成 parent chunk(自然段落,上限 2400 字),每个 parent 再切成 child chunk(300 字,50 字重叠)。向量化和检索基于 child,命中后用 parent_id 去重,返回 parent_content 给 LLM。
额外做了两件增强:① 结构保护——代码块、表格、LaTeX 公式在切分前用占位符替换,切完还原,保证这些结构永不被切碎;② 章节路径前缀——child 的 page_content 前加 [Redis 故障排查 / OOM 处理],参与 embedding 编码,实测 R@1 提升 +10%。
追问 1:parent_id 是怎么算的? 答: 用 parent chunk 内容的 MD5 前 12 位做稳定 ID:
hashlib.md5(parent_text.encode()).hexdigest()[:12]。优点是幂等——同一段文字重新入库 parent_id 不变,下游缓存不失效。12 位 hex 能区分约 2.8×10^14 种,万级 parent 完全够用。
追问 2:如果一个 parent 下多个 child 都命中了呢? 答: 按 parent_id 去重,只保留首次出现的(即最高分的那个 child)。
retrieval.py:build_context()遍历 docs 时用seen_parents集合去重,同一个 parent 只算一次。这避免了返回 3 条结果实际都来自同一段的冗余问题。
Q3: 为什么从 Milvus 迁到 pgvector?#
答: 三个字——运维成本。
Milvus 需要 3 个容器(etcd + MinIO + milvus),独立备份、独立监控、独立升级。我们的知识库只有 4113 个 chunk,预估上限 10 万级。pgvector 就是 Postgres 的一个扩展——项目已经在跑 Postgres(事故台账),加个扩展零新增基础设施。同库同事务还解决了向量和业务数据的一致性问题。
更关键的收益是 BM25 也能在同一张表上做了——ParadeDB pg_search 扩展建 USING bm25 索引,向量检索和关键词检索在同一个 kb_chunks 表上操作,数据一致性由 Postgres 保证。
追问 1:pgvector 性能够用吗? 答: HNSW 索引在万级数据量下,单次查询 P99 < 5ms。我们设了
ef_search=128(查询时搜索宽度),在精度和速度间取平衡。pgvector 的舒适区是 30 万以内——官方 benchmark 30 万条 1024 维向量,QPS 约 500,延迟 < 10ms。我们 4113 条远在舒适区内。
追问 2:为什么不用 Qdrant?它原生支持混合检索。 答: Qdrant 确实是更好的向量数据库——原生混合检索、过滤、分组、量化压缩。但它又是一个独立服务需要部署和运维,和 Postgres 业务数据分离后事务一致性需要自己保证。我需要的 BM25 + 向量混合,pgvector + ParadeDB 已经能做。选型核心原则是”能复用已有基础设施就不引入新组件”。未来如果数据量超 50 万或需要 GPU 加速,会考虑迁。
进阶题(区分度)#
Q4: Rerank 的 Cross-Encoder 和粗召回的 Bi-Encoder 有什么区别?为什么不直接用 Cross-Encoder 做全库搜索?#
答: 本质区别在”是否看 query 和 doc 的交叉注意力”。
Bi-Encoder(粗召回):query 和 doc 分别编码成向量后算 cosine 距离。doc 向量可以预计算并建索引,查询时只算 query 向量 + 一次 ANN 搜索,延迟低(< 5ms)。但 query 和 doc 从未在同一个 Transformer 上下文中交互过,对细粒度语义差异不敏感——“Redis 内存占用高” vs “Redis 内存泄漏排查”,向量很接近但问的是不同事。
Cross-Encoder(精排):把 (query, doc) 拼接后一起送进 Transformer,能捕捉精细的交叉注意力。精度显著高于 Bi-Encoder。但每对都要跑一次完整前向传播,无法预计算——4113 条全扫 = 4113 次推理,延迟不可接受。
所以 Bi-Encoder 做粗排(快,从全库缩到 20 条),Cross-Encoder 做精排(准,从 20 条缩到 3 条)。这是工业级 RAG 的标准两阶段架构。
追问:如果 Rerank 超时了怎么办? 答: 任何异常都降级到粗排前 top_n——用户拿到的可能不是最优排序,但不会拿不到结果。Rerank 是增强不是必须。
reranker.py里三个 provider(DashScope/本地/未知)的错误路径全部汇聚到return docs[:top_n],保证永不抛异常。
Q5: 知识库同步是怎么做的?支持增量吗?#
答: 支持增量同步,有两级跳过优化:
第一级——source_version 没变且上次 active → 跳过,连下载都不做(适用于数据源 API 带版本号的场景,如飞书文档的 revision_id)。
第二级——下载后算 content_hash = sha256(markdown_text),如果 hash 没变 → 跳过重新分块和 embedding(这是最贵的步骤,一篇文档要调几十次 embedding API)。
真正需要更新的文档,走 replace_doc 原子替换——embedding 在事务外算(这步最易失败,是网络调用;失败了旧数据原封不动),只有 embedding 成功后才开事务做 DELETE + INSERT,保证不出现”删了旧 chunk 但新 chunk 没插上”的空窗。
并发安全用 Postgres pg_try_advisory_lock 做进程间互斥——同一 source 的同步串行,不同 source 互不影响。
追问:如果 embedding API 挂了,旧数据会丢吗? 答: 不会。关键设计是
replace_doc中 embedding 在事务外算——vectors = await _embed(docs)失败直接抛异常,后面的DELETE + INSERT事务根本不会开,旧 chunk 完好不动。同时kb_document表的 status 标为failed,下轮同步会强制重试。
Q6: 多轮对话的 query rewrite 是怎么工作的?#
答: 解决的问题是指代消歧。用户说”刚才那个 Redis 问题怎么解”,原始 query 对检索引擎毫无意义。
实现:rag/memory.py:rewrite_question() 用轻量 LLM(便宜档小模型,温度 0)做一次改写——输入是会话 summary + 最近几轮消息 + 当前问题,输出是一个独立的、可以直接检索的 query。比如改写为”Redis OOM 排查的后续处理步骤”。
会话管理在 Redis:每条消息存入(user/assistant 交替),超过阈值后 compact_if_needed() 自动压缩——把较早消息用 LLM 合并进 summary(1-2 句话),只保留最近几轮原文。TTL 过期自动清理。
降级:rewrite 失败(LLM 超时/不可用)直接用原始 query。这是增强不是必须——召回率稍降但系统不会挂。
压力题(面试官挑战设计决策)#
Q7: 50 条 benchmark 样本太少了,这个数据有多可信?#
答: 坦诚讲,50 条的统计置信区间确实较宽——R@1 91.7% 的 95% 置信区间大约在 [80%, 97%]。但对内部诊断平台来说,这不是学术论文,目的是验证”混合检索 + Rerank 比纯向量好”这个方向性结论,50 条足够。
更重要的是 benchmark 设计上确保了场景覆盖:精确术语类、语义泛化类、跨章节类、长尾类各约 25%。消融实验的对比是在同一数据集上跑的,所以”加 BM25 提升 3%、加 Rerank 再提升 6%“这个相对增量是可信的。
如果要提升置信度,下一步是接入线上 Agent 的实际调用日志——记录每次 RAG 检索后 Agent 实际用了哪些 chunk(做了引用就是用了),没用的就是召回不准,形成隐式反馈闭环。
追问:章节路径前缀的 +10% 和混合检索的 +8.4% 是否重叠? 答: 是的,两组数字不是独立变量——都是在同一批 50 条 benchmark 上跑的,章节路径是在分块阶段加的(影响 embedding 质量),混合检索是在检索阶段加的(影响候选集质量),它们在流水线的不同位置叠加。正确的说法是”在当前配置下(已含章节路径前缀),混合检索 + Rerank 相比纯向量提升 8.4% R@1”。要精确测章节路径的独立贡献,需要做一组”无前缀 + 混合 + Rerank”的消融,目前没做。
Q8: 这个 RAG 管线的延迟预算是怎样的?如果用户体感慢怎么优化?#
答: 典型端到端延迟分布:
| 阶段 | 延迟 |
|---|---|
| Query Rewrite | 200-500ms(LLM 调用,可关闭) |
| 向量检索 | < 5ms(pgvector HNSW 本地) |
| BM25 检索 | < 10ms(pg_search 本地) |
| RRF 融合 | < 1ms(纯内存计算) |
| Rerank | 80-150ms(DashScope 云端) |
| Parent 展开 | < 1ms(内存去重) |
| LLM 生成 | 1-5s(主要瓶颈) |
| 总计 | 1.5-6s |
瓶颈在 LLM 生成(1-5s),不在检索。检索阶段总计 < 200ms。
如果用户体感慢:① 向量检索和 BM25 检索已经并行(asyncio.create_task);② Rerank 是最大的非 LLM 开销——如果延迟敏感可以降 retrieve_k(从 20 减到 10,Rerank 少跑一半对);③ Query Rewrite 可通过配置关闭(首轮对话不需要改写);④ 流式 SSE 输出让用户在 LLM 生成第一个 token 时就能看到结果,体感延迟远小于总延迟。
Q9: 如果知识库规模从 4000 增长到 100 万,管线需要怎么改?#
答: 按量级分:
10 万级:pgvector HNSW 仍然舒适。可能需要调 m 从 16 到 32(更多邻居提精度),ef_construction 从 200 到 400。pg_search BM25 索引无问题。
50 万级:开始考虑 pgvector 的内存压力——100 万 × 1024 维 × float32 = 4GB 纯向量数据。HNSW 图结构还要额外 2-3 倍内存。可以加 Postgres 的 shared_buffers 或做分表。
100 万级:该迁专用向量库了(Qdrant 或 Milvus)。具体信号是:索引构建时间超过 10 分钟、查询 P99 超过 100ms、或者需要量化压缩(int8/binary)节省内存。迁移成本可控——pg_vector_store.py 是唯一和 pgvector 耦合的层,上层 advanced_search / retrieval.py 不用改。BM25 路也对应换成 Elasticsearch。
架构层面不需要大改——四步漏斗(粗召回 → 融合 → 精排 → Parent 展开)是标准工业模式,换底层组件不影响流程。
五、前沿概念与延伸#
5.1 Hybrid Retrieval(Dense + Sparse + RRF)#
是什么:将稠密向量检索(Dense,基于语义 embedding)和稀疏检索(Sparse,基于词频/BM25)的结果通过融合算法合并,取两者之长。
为什么要这么做:单路检索有系统性盲区——Dense 对精确术语/错误码/代码片段弱(embedding 会”揉”掉精确 token),Sparse 对语义变体/同义改写弱(“延迟升高” vs “响应变慢”)。混合检索的意义是互补盲区,而非简单提分。
这么做的理由:相比其他融合方案(线性加权、CombMNZ、学习排序),RRF 的优势是零训练、零超参调优、对分数量纲不敏感。在数据量不足以训练排序模型的场景下(我们只有 50 条 benchmark),RRF 是最稳健的选择。学术界(TREC)和工业界(Elastic、Qdrant、Weaviate)都以 RRF 为默认融合方案。
举例说明:在本项目中,搜索 “RedisOOM” 时,向量路把 MySQL OOM 的文档也拉了进来(语义相近),BM25 路只命中包含 “Redis” 和 “OOM” 关键词的文档。RRF 融合后,“MySQL OOM” 只在单路出现,融合分低,被挤出 top-3。R@1 从纯向量的 83.3% 提到混合+Rerank 的 91.7%。
延伸问答:
面试官:“你还知道其他融合算法吗?什么情况下用哪个?” 答:三种主流——
- RRF:只看排名不看分数,无需训练,适合数据不多 / 快速上线。缺点是无法利用分数置信度信息。
- 线性加权(Convex Combination):
score = α·dense_score + (1-α)·sparse_score,前提是两路分数需要归一化到同一量纲(min-max / z-score)。适合有标注数据可以调 α 的场景。- 学习排序(Learning to Rank):把两路分数 + 其他特征(BM25 分、向量距离、doc 元信息)做特征,训练一个 GBDT/LambdaMART 排序模型。精度最高但需要足够的标注 query-doc 对。 选型:数据少用 RRF,有标注用 LtR,中间状态用线性加权(α 做 grid search)。
5.2 Parent-Child Chunking#
是什么:一种两级文档分块策略——先按文档自然结构(Markdown 标题、段落)切成大的 parent chunk(完整段落),再把每个 parent 切成小的 child chunk(检索用)。检索基于 child,返回基于 parent。
为什么要这么做:固定大小切分的根本矛盾——chunk 太小,embedding 精准但 LLM 缺上下文(一个操作步骤被切成两半,各自没意义);chunk 太大,LLM 上下文完整但 embedding 被稀释(一段 2000 字的内容 embedding 成一个向量,不如 300 字精准)。Parent-Child 让检索精度和 LLM 上下文质量不再矛盾。
这么做的理由:相比其他方案——① Sliding Window(固定窗口 + 重叠)解决了边界丢失但没解决大小矛盾;② 语义切分(基于 embedding 相似度找切点)依赖 embedding 质量且计算成本高;③ Late Chunking(Jina AI 提出,先整篇编码再按位置切)需要特定模型支持。Parent-Child 实现简单(MarkdownHeaderTextSplitter + RecursiveCharacterTextSplitter),不依赖特殊模型,效果已在腾讯 WeKnora 等工业实践中验证。
举例说明:一篇 Redis OOM 排查手册的”处理步骤”章节有 1500 字。切成 parent(1500 字完整章节)+ 5 个 child(各 ~300 字)。搜索 “maxmemory-policy 配置” 精确命中第 3 个 child(讲配置参数的段落),但返回给 LLM 的是完整 1500 字——包含前因后果、完整操作步骤,LLM 能给出完整的排障指导。
延伸问答:
面试官:“你还知道其他分块策略吗?什么情况下用哪种?” 答:四种常见——
- Fixed-size Chunking:简单、通用,适合格式统一的纯文本(日志、代码)。
- Parent-Child Chunking:适合结构化文档(Markdown/HTML/PDF 有标题层级)。
- Semantic Chunking:按相邻句子的 embedding 相似度找”语义断点”切分。适合无结构的长文本(小说、邮件),但依赖 embedding 质量且计算量是 O(n²)。
- Late Chunking(Jina AI 2024):先用长上下文 embedding 模型编码整篇文档,再在向量空间里按位置区间取切片。理论上完美保留上下文信息,但需要支持超长输入的 embedding 模型(如 jina-embeddings-v3)。 选型:有 Markdown 结构用 Parent-Child,无结构用 Semantic,有长上下文 embedding 模型考虑 Late Chunking,其他用 Fixed-size 打底。
5.3 RAG 评测方法论(RAGAS 框架)#
是什么:RAGAS(Retrieval-Augmented Generation Assessment)是一个自动化评估 RAG 管线质量的框架,从检索质量和生成质量两个维度定义了标准化指标。
为什么要这么做:RAG 系统的”好不好”不能靠感觉——检索召回了但排名不对、排名对了但 LLM 编造了、不编造但不切题,这些是不同层面的问题。RAGAS 把 RAG 质量拆解成可独立度量的四个维度,让优化有方向。
这么做的理由:相比人工评估(贵、慢、不可复现)和端到端准确率(只看最终答案对不对,无法定位是检索问题还是生成问题),RAGAS 的优势是:① 自动化——LLM-as-Judge 不需要人工标注;② 可诊断——四个指标分别对应检索和生成的不同环节,优化有方向;③ 可复现——脚本化跑,每次改完代码跑一遍看指标变化。
举例说明:在本项目中,scripts/eval_ragas.py 对 50 条 QA 数据集跑四个指标。某次优化 Rerank 输入(加 parent_content),Context Precision 从 0.85 → 0.90(排序更准了),但 Faithfulness 没变(LLM 生成质量和检索排序无关)。如果没有分维度评估,就只能看”最终回答好不好”,无法确认是 Rerank 起了效还是碰巧了。
延伸问答:
面试官:“除了 RAGAS,还有什么 RAG 评测框架?” 答:主要竞品——
- DeepEval:和 RAGAS 类似的 LLM-as-Judge 方案,额外支持”幻觉检测”和”毒性检测”指标,集成更完善的 CI/CD hook。
- TruLens:侧重可解释性——可以看到 LLM 在生成回答时引用了哪些 chunk,未引用的就是噪音。适合做 chunk 级反馈优化。
- 人工 A/B 测试:最可信但最贵。适合上线前的最终验收,不适合日常迭代。 选型:日常迭代用 RAGAS/DeepEval 自动跑,上线前做一轮人工 A/B。
六、诚实边界#
- 样本量小:评测 benchmark 只有 50 条 QA,统计置信区间较宽,数字精度不宜过度引用。
- 无线上反馈闭环:没有记录 Agent 实际引用了哪些 chunk——不知道召回的结果 Agent 到底用没用。下一步应该做 chunk 级隐式反馈(被引用 = 有用,未引用 = 噪音),用来优化 embedding 和排序。
- 无 HyDE(Hypothetical Document Embeddings):Query Rewrite 只做指代消歧,没有生成假设性文档来做检索。HyDE 在某些场景下比 query rewrite 效果更好(Gao et al., 2023),但增加了一次 LLM 调用的延迟和成本,当前判断投入产出比不够。
- 知识库同步是定时的:飞书文档刚更新、知识库还没同步时,Agent 用的是旧版 SOP。理想方案是 Agent 调用 RAG 前触发一次实时同步(或用 webhook 监听文档变更事件),但目前没做。
- 单语言(中文):embedding 模型(text-embedding-v4)、BM25 分词器(chinese_compatible)都是中文优化的。如果知识库引入英文文档,需要评估跨语言检索质量,可能需要换多语言 embedding 模型或分语言建索引。
- 无多向量检索:ColBERT 等 late-interaction 模型可以在 Bi-Encoder 的速度下逼近 Cross-Encoder 的精度,是粗召回阶段的潜在升级方向,但 pgvector 不原生支持多向量存储。