前端转 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.json8 题小集,覆盖基础命中 + ≥2 条"应该拒答"的负样本,用来秒级验证链路没断;
  • evals/real/golden.json20 题真实集,题目来自一份叫「金浩财税」的企业手册,贴近真实提问。

每条题尽量用"机器可判"的方式描述,而不是写一段主观答案:

{
  "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.pySettings

# 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 判分 → 验收文档);
  • 后端单测我实跑过:pytest 53 passed(规则打分 / 混合检索 / rerank / flow 注册 / eval API / feedback / 解析元数据 / LLM 判分);
  • Golden Set 真实填充:smoke 8 题、real 20 题;
  • 双策略可切换、引用高亮、反馈收集、eval 面板——代码层面都落地了。

⚠️ 还差一件、且只有一件没收尾的事:量化对比表

P2 的"灵魂"是 naive-vs-advanced 对比表,但目前它还是空的,原因很具体、也很诚实:

  1. 本机 backend/.envRERANK_MODEL 是空的——按设计,此时 advanced明确报错而不是静默降级,所以"advanced 列"目前根本跑不出来(这是设计如此,不是 bug);
  2. 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 工程化

posted @ 2026-08-12 01:00  南珂丶一梦  阅读(13)  评论(0)    收藏  举报