前端转 AI · 第 03 篇|P2:混合检索、rerank 与可量化的 RAG 评测
本文是系列第 3 篇。上一篇 P1 MVP:FastAPI + Next.js 做出能演示的知识库 把 RAG 套上了 Web 外壳,做出了"能演示、带引用"的知识库;再往前是 P0:用 CLI 跑通 RAG 闭环;整套路线见 总纲。
但 P1 有个没说破的问题:"能答"不等于"答得对",更不等于"比朴素检索更好"。这一篇就来解决"对不对、好多少、能不能证明"。
阅读约定:
⚠️ 易错点都是真实踩过的坑,紧跟的✅ 解决方案可以直接照抄。代码已脱敏,但结构和参数与真实实现一致。
1. 这篇要做出什么
P1 的检索就是"embedding 相似度 top-k 拼进 prompt"。它在大部分问题上够用,但有几个典型翻车场景:
- 问"税号 91110000MA01... 对应的公司名"——向量检索对长串编号这种字面精确锚点很不敏感,经常召回一堆无关 chunk;
- 问"年假和病假的区别"——光靠向量可能把两条制度都召回,但最该被顶上来的那几句反而被压在后面;
- 改了 chunk 参数、加了 rerank,到底变好还是变坏?凭感觉说"好像更准了"——这是 P2 最想消灭的一句话。
所以 P2 的目标不是"再加个功能",而是把"质量"变成可量化、可对比的东西。做完长这样:
┌──────────────────────── Next.js 管理台 ────────────────────────┐
│ /kb/[id]/chat 策略下拉(naive|advanced) · [n] 引用高亮 · 👍/👎 │
│ /eval 选集/策略 → 跑分 → 坏案例表 → 下载 JSON │
└───────────────┬───────────────────────────────┬─────────────────┘
│ /api/chat/stream(strategy) │ /api/eval/* /api/feedback
┌───────────────▼──────────── FastAPI ───────────▼────────────────┐
│ orchestration/ │
│ get_flow(strategy) ──▶ naive_rag │ advanced_rag │
│ (retrieve→answer) (rewrite→hybrid→rerank→answer) │
│ services/ chunking · embed · retrieve · hybrid_retrieve · rerank · feedback_store · eval_* │
└─────────────────────────────────────────────────────────────────┘
一句话:Chat 和 Eval 共用同一套 get_flow(strategy),改策略只换 flow,不复制检索实现。 这是后面所有"可对比"的地基。
2. 先造"尺子":Golden Set + 规则打分
P2 成功的标准只有一条——能量化报告提升。而量化的前提是先有一把固定的"尺子"。
⚠️ 易错点 1:进 P2 第一反应是"接 rerank、调参数",然后凭手感说"变好了"。结果过两周加了新文档,老问题又回来了,你却说不清是哪次改动引入的。
✅ 解决方案:进 P2 的第一件事不是接 rerank,是攒 20~50 条"问答对 + 标准答案片段"。这套题集叫 Golden Set,之后所有优化都拿它当回归基准——改坏一眼就能看出来。
我建了两套集子(真实存在,已入库):
evals/smoke/golden.json:8 题小集,覆盖基础命中 + ≥2 条"应该拒答"的负样本,用来秒级验证链路没断;evals/real/golden.json:20 题真实集,题目来自一份叫「金浩财税」的企业手册,贴近真实提问。
每条题尽量用"机器可判"的方式描述,而不是写一段主观答案:
{
"id": "q001",
"question": "年假每年有几天?",
"kb_name": "金浩财税",
"source": "员工手册-休假制度",
"must_contain": ["10", "天"],
"must_not_contain": ["无限"],
"should_refuse": false
}
打分写成一个无 I/O 的纯函数,这样它既能在 CLI 里跑、也能在单测里跑、还能在 API 里跑,结果完全一致:
# app/services/eval_score.py
REFUSE_MARKERS = ("无法回答", "不足以回答", "没有相关资料")
def score_answer(case: dict, answer: str) -> dict:
reasons: list[str] = []
text = answer or ""
lower = text.lower()
if case.get("should_refuse"):
ok = any(m in text for m in REFUSE_MARKERS)
if not ok:
reasons.append("expected refuse markers missing")
return {"passed": ok, "reasons": reasons}
for needle in case.get("must_contain") or []:
if needle.lower() not in lower:
reasons.append(f"missing:{needle}")
for needle in case.get("must_not_contain") or []:
if needle.lower() in lower:
reasons.append(f"forbidden:{needle}")
return {"passed": not reasons, "reasons": reasons}
⚠️ 易错点 2:规则分写得要么太严(标点的半角全角差异就判错),要么太松(
must_contain: ["年假"]基本永远过)。
✅ 解决方案:三角度组合——must_contain(必须出现的关键信息)+must_not_contain(绝不能瞎编的内容)+should_refuse(该拒答时必须有拒答标记)。三个维度覆盖了"答对、没编、不乱答"。
3. 编排壳:naive / advanced 两条流并存
P1 的问答逻辑全堆在 chat.py 里。P2 要"同一批题跑两个版本比分数",如果继续在 chat() 里写 if strategy == "hybrid": ... elif strategy == "hyde": ...,三个策略之后这个函数就没法看了,而且根本没法做"A/B 各跑一遍比分数"。
解决方式是新建一个 orchestration/ 目录,把问答链路拆成可组合的节点(rewrite / retrieve / rerank / answer),再用"流"把它们串起来:
naive_rag(基线,就是 P1 的行为)
retrieve → answer
advanced_rag(增强)
rewrite HyDE / 多查询改写
→ hybrid_retrieve 向量 ∪ BM25
→ rerank 重排序
→ answer [条件分支] 检索为空 → 拒答;否则 → 回答
两条流共用同一批节点,只是组合方式不同;而节点只是薄薄包一层 services/,所以 P1 的验收和单测一条都不破。注册表就十几行:
# app/orchestration/registry.py
from app.orchestration.flows.advanced_rag import AdvancedRagFlow
from app.orchestration.flows.naive_rag import NaiveRagFlow
_FLOWS = {"naive": NaiveRagFlow(), "advanced": AdvancedRagFlow()}
def get_flow(strategy: str):
try:
return _FLOWS[strategy]
except KeyError as exc:
raise ValueError(f"unknown strategy: {strategy!r}") from exc
前端聊天请求里加一个 strategy 字段,后端经 registry 分发——Chat 和 Eval 走的是同一个入口:
# app/models/schemas.py
from typing import Literal
class ChatRequest(BaseModel):
message: str = Field(min_length=1)
history: list[dict[str, str]] = Field(default_factory=list)
strategy: Literal["naive", "advanced"] = "naive"
⚠️ 易错点 3:觉得"反正迟早要做编排",P0/P1 就急着上 DAG / 工作流引擎。结果两个节点的链路硬被节点化,代码量翻倍、可读性下降,收益为零。
✅ 解决方案:抽象是长出来的,不是设计出来的。等到你真的需要"两条策略并存对比"那一刻再做——那时候你已经知道节点边界该切在哪了。引擎也不用自己写,LlamaIndex 的llama_index.core.workflow已经在依赖里,事件驱动比 DAG 更顺手,几行就能把"检索为空就拒答"写成return RefuseEvent()。
4. 混合检索:向量 ∪ BM25
向量检索擅长语义,但对字面精确锚点(编号、专有名词、短术语)经常漏。混合检索就是先各自召回一批,再合并去重,把"语义相关"和"字面命中"两拨候选都放进候选池。
# app/services/hybrid_retrieve.py(核心逻辑,已简化)
def hybrid_retrieve(kb_id, query, *, manager=None, cfg=None, vector_fn=None):
cfg = cfg or settings
vector_hits = (vector_fn or retrieve)(
kb_id, query, top_k=cfg.hybrid_vector_top_k, manager=manager, cfg=cfg)
keyword_hits = _keyword_retrieve(
kb_id, query,
top_k=cfg.hybrid_bm25_top_k,
scan_limit=cfg.hybrid_keyword_scan_limit,
manager=manager)
return merge_chunks(vector_hits, keyword_hits)
def merge_chunks(*lists):
"""按 node_id 去重,同 id 保留较高分。"""
best: dict[str, RetrievedChunk] = {}
for lst in lists:
for chunk in lst:
key = f"id:{chunk.node_id}" if chunk.node_id else f"ds:{chunk.document_id}\0{chunk.snippet}"
prev = best.get(key)
if prev is None or (chunk.score or float("-inf")) > (prev.score or float("-inf")):
best[key] = chunk
return sorted(best.values(), key=lambda c: c.score or float("-inf"), reverse=True)
关键词打分用了一个很轻的重叠算法:中文做 unigram + bigram,英文按词,算"查询里有多少 token 命中了文档":
def keyword_score(query: str, document: str) -> float:
q_tokens = tokenize(query)
if not q_tokens:
return 0.0
doc_tokens = set(tokenize(document))
if not doc_tokens:
return 0.0
hits = sum(1 for t in q_tokens if t in doc_tokens)
return hits / len(q_tokens)
⚠️ 易错点 4:关键词检索对 collection 全量扫描,KB 一大就慢。
✅ 解决方案:用hybrid_keyword_scan_limit(默认 500)做采样上限,再取hybrid_bm25_top_k(默认 8)。候选池够用就行,真要全量再上倒排索引。这两个值都在config.py里,改.env即可调,不必动代码。
⚠️ 易错点 5:把混合检索当成"排序器"——以为合并完顺序就对了。
✅ 解决方案:混合只负责"扩召回",不负责"定最终顺序"。合并后候选仍然噪声多,最终排序交给下一步 rerank。顺序:rewrite → hybrid_retrieve → rerank → answer。
5. rerank:不降级才是负责任的实现
rerank 在混合召回的候选上做精排,把真正相关的 chunk 顶到 prompt 前面。我接的是 OpenAI 兼容的 rerank HTTP 接口(DashScope 的 qwen3-rerank、Cohere 等都行)。
⚠️ 易错点 6(最反直觉但最关键):没配 rerank 模型时,让
advanced静默降级成 naive 接着跑。表面看"高级功能优雅兜底",实际上你拿 naive 的分数冒充 advanced 的分数,对比表直接虚高,自己骗自己。
✅ 解决方案:未配置就显式报错,绝不在用户不知情时降级。代码里rerank()一上来就检查:
# app/services/rerank.py
def rerank(query, chunks, *, cfg=None, client=None):
cfg = cfg or settings
if not rerank_configured(cfg): # rerank_model 为空即视为未配置
raise RerankError("rerank not configured")
...
base = (cfg.rerank_api_base or "").strip() or (cfg.openai_api_base or "").strip()
if not base:
raise RerankError("rerank config error: RERANK_API_BASE (or OPENAI_API_BASE) is empty")
⚠️ 易错点 7:把 chat 用的
OPENAI_API_BASE直接拿去调 rerank。很多服务商的 chat 兼容端点和 rerank 端点不是同一个,直接 404。
✅ 解决方案:rerank 用独立的RERANK_API_BASE。约定:以/rerank或/reranks结尾就原样使用,否则自动补/rerank。DashScope 的正确写法是:RERANK_API_BASE=https://dashscope.aliyuncs.com/compatible-api/v1/reranks RERANK_MODEL=qwen3-rerank
.env.example里这两行是留空的——空就是"advanced 会明确报错",而不是"悄悄用 naive"。
配置集中在 backend/app/config.py 的 Settings:
# app/config.py —— P2 advanced_rag
rewrite_enabled: bool = True # 是否做查询改写(失败回退原查询)
hybrid_vector_top_k: int = 8 # 向量召回数
hybrid_bm25_top_k: int = 8 # 关键词召回数
hybrid_keyword_scan_limit: int = 500 # 关键词扫描上限
rerank_top_n: int = 5 # rerank 后进 prompt 的 chunk 数
rerank_model: str = "" # 空 = 未配置,advanced 显式报错
rerank_api_base: str = "" # 独立 rerank 端点
citation_snippet_max: int = 2000 # 引用片段上限,避免正文被截断
6. 把"提升"变成数字:评测 CLI / API / 前端面板
有了 Golden Set 和打分函数,"跑分"就是四行命令的事。CLI 直接读集子、调 flow、打分、写结果:
# 基线:naive 跑 smoke 集
uv run python scripts/eval_cli.py --set smoke --strategy naive
# 增强:advanced 跑同集(需先配好 RERANK_MODEL)
uv run python scripts/eval_cli.py --set smoke --strategy advanced
输出就是一行 passed/total 加失败 id 列表:
passed/total: 8/8
failed ids: (none)
results: evals/results/smoke_naive_20260811.json
后端把这个能力暴露成 API,前端 /eval 页面就能在浏览器里选集子、选策略、点"开始跑分",并把 naive 和 advanced 并排显示 pass rate:
POST /api/eval/runs # 同步跑一轮,返回汇总(≤50 题)
GET /api/eval/runs/{run_id} # 查某次运行
GET /api/eval/runs/{run_id}/cases?failed_only=true # 坏案例,方便复盘
POST /api/eval/golden/import # 导入新题集
⚠️ 易错点 8:把"单测全绿"当成"P2 完成"。单测只证明函数没写错,证明不了 advanced 真的比 naive 好。
✅ 解决方案:P2 的硬验收是端到端真实跑分——smoke / real 两套集,naive / advanced 两种策略,各跑一轮,把 pass rate 填进对比表。只有数字摆出来,才算"可量化提升"。
(关于这张对比表,第 8 章有一句必须如实交代的话,先别跳。)
7. 引用高亮、来源页码、👍/👎 反馈
检索变强了,前端体验也得跟上,否则"用户还是不知道答案从哪来"。P2 在这块补了三件事:
① 引用高亮。 在 prompt 里要求模型对事实句尽量加 [1] [2] 引用标记,前端把正文里的 [n] 解析成可点击高亮,点一下滚动到引用列表第 n 项。
② 来源带页码。 P1 的引用只有"文件名 + 片段",P2 在解析阶段给每个 chunk 打了 page 元数据(PDF 按页 extract_text,页码从 1),前端展示变成 文件名 · 第 N 页 · 完整片段。
⚠️ 易错点 9:引用片段用 500 字硬截断,结果关键那句正好在截断线后面,用户点开引用却看不到证据。
✅ 解决方案:citation_snippet_max默认放到2000,保证片段完整;真要省 token 再调小,而不是默认就砍。
③ 反馈收集。 每条回答下加 👍/👎,写入 JSONL 留作后续回归:
# app/services/feedback_store.py
def append_feedback(*, kb_id, question, answer, rating, strategy="naive", path=None):
row = {"kb_id": kb_id, "question": question, "answer": answer,
"rating": rating, "strategy": strategy,
"timestamp": datetime.now(timezone.utc).isoformat()}
with (path or FEEDBACK_PATH).open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
return row
⚠️ 易错点 10:反馈只记"这个问题答得好不好",不记用的是哪个策略。下次想看"advanced 的坏案例集中在哪"却无从查起。
✅ 解决方案:反馈里一定带上strategy字段(见上面代码)。坏案例回流时按策略分组,才能说清"是哪条流在翻车"。
接口就是一个 POST:
POST /api/feedback # body: {kb_id, question, answer, rating:"up"|"down", strategy}
8. 这一阶段的诚实验收状态
写技术文章最容易犯的毛病是"把没做完的写成做完了"。这一节专门把 P2 的真实状态摆清楚,不美化:
✅ 已经确凿完成的部分
- 全部 9 个实现任务都已提交(Golden Set → 编排壳 → eval CLI → eval API → 混合检索/rerank → 解析增强 → 引用/反馈 → LLM 判分 → 验收文档);
- 后端单测我实跑过:
pytest53 passed(规则打分 / 混合检索 / rerank / flow 注册 / eval API / feedback / 解析元数据 / LLM 判分); - Golden Set 真实填充:smoke 8 题、real 20 题;
- 双策略可切换、引用高亮、反馈收集、eval 面板——代码层面都落地了。
⚠️ 还差一件、且只有一件没收尾的事:量化对比表
P2 的"灵魂"是 naive-vs-advanced 对比表,但目前它还是空的,原因很具体、也很诚实:
- 本机
backend/.env里RERANK_MODEL是空的——按设计,此时advanced会明确报错而不是静默降级,所以"advanced 列"目前根本跑不出来(这是设计如此,不是 bug); - real 集绑定的「金浩财税」真实 KB 文档还没入库,real 集的 advanced 跑分需要先把那份手册 ingest 进去。
所以这张表现在长这样(待补):
| 集合 | strategy | passed/total | pass rate | 备注 |
|---|---|---|---|---|
| smoke | naive | 待跑 | 待跑 | 需本机 Key + 已 ingest 的 smoke KB |
| smoke | advanced | 待跑 | 待跑 | 需 RERANK_MODEL |
| real | naive | 待跑 | 待跑 | 需真实 KB 与文档入库 |
| real | advanced | 待跑 | 待跑 | 需 RERANK_MODEL + 真实 KB |
填表命令就四行(拿到 Key 和文档后):
uv run python scripts/eval_cli.py --set smoke --strategy naive
uv run python scripts/eval_cli.py --set smoke --strategy advanced
uv run python scripts/eval_cli.py --set real --strategy naive --kb 金浩财税
uv run python scripts/eval_cli.py --set real --strategy advanced --kb 金浩财税
为什么我不在文章里"编一组提升数字":P2 整篇讲的就是"用数字说话、别凭感觉"。如果我自己在这里填个 advanced 92% vs naive 78% 的假数,等于亲手打自己的脸。等本机配好 rerank、真实 KB 入库,我会把这张表填实——那才是这篇该有的闭环。
另外还有个可选能力:LLM 判分(
--judge llm)。它算相关性/忠实度,结果并列写入、不覆盖规则分,适合规则过脆或语义等价但字面不同的情况,不适合单独当门禁。这步代码已经写完,但同样等真实 Key 才能跑出对照。
9. 下一步:P3 工程化
P2 让"单租户知识库"的答案变准了,但它还是个玩具级实现:元数据是 JSON 文件、入库是同步阻塞、没有权限、没有审计、出问题看不到日志。
下一步 第 04 篇:P3 工程化 要做的,就是从"能跑"到"能上线":
- 异步入库:大文件上传不再阻塞 API,入库链路搬成工作流,事件流直接当进度条;
- 权限:JWT + 基础 RBAC,知识库级读/管权限;
- 审计与可观测:结构化日志、请求追踪、token/费用统计;
- 元数据与向量分离:把 JSON
meta.json换成 Postgres(接口早已抽象成MetaStore,迁移只换实现)。
P3 的细节下一篇展开。如果你正在做自己的 RAG,建议先把 P2 的 Golden Set 和双策略对比跑通——它会让你后面每一次调参都有据可依,而不是在黑暗里摸。
系列文章会陆续更新,有问题欢迎评论区交流。下一篇:第 04 篇:P3 工程化。
如果你觉得本文还可以,那就点击一下推荐,让更多人看到吧!
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

浙公网安备 33010602011771号