向量库选型与 Hybrid 检索配方:pgvector / Milvus / ES / FAISS(含 Python 伪代码 + hit@k 回归脚手架)

194 阅读4分钟

做企业知识库(RAG)时,“向量库选型”经常被过度神化。
但工程里更真实的情况是:

命中率差,换库不会神奇变好。
你需要的是:Hybrid(BM25+Vector)+ 可观测 + 评测闭环。

这篇直接给你工程可落地的东西:

  • 选型决策树(按规模/并发/延迟/运维)
  • pgvector/Milvus/ES/FAISS 对比边界
  • Hybrid 配方(BM25 + Vector + Filter + Rerank)
  • 排错顺序(打印 TopK、消融实验)
  • hit@k 回归脚手架(vector/bm25/hybrid 三条链路分别评)

0)TL;DR

  • 小中规模优先工程简单:pgvector;单机极致性能:FAISS;大规模分布式:Milvus;关键词/过滤重:ES
  • 企业知识库最终大概率需要 Hybrid:型号/数字/人名靠 BM25,口语化/同义改写靠 Vector
  • 排错第一生产力:打印 TopK(三条链路对比)

1)选型决策树(先把约束写清楚)

你只需要回答四个问题:

  1. chunk 规模(10万 / 100万 / 1000万)
  2. 峰值并发(QPS)
  3. 检索延迟预算(P95)
  4. 运维能力(能否维护集群)

决策建议:

  • 小中规模 + 工程简单:pgvector
  • 单机/离线/极致延迟:FAISS(你自己服务化)
  • 大规模向量检索 + 分布式扩展:Milvus
  • 关键词检索强、过滤/聚合重、Hybrid 常态:Elasticsearch

2)四种方案的边界(工程视角)

方案强项典型适用你要付出的代价
pgvector工程简单、过滤/Join 方便小中规模 RAG、业务库同库性能/扩展上限
FAISS单机性能强、可控本地检索服务、离线索引高可用/更新/运维自己补
Milvus大规模向量检索100万+ chunk、需要扩展集群复杂度更高
ESBM25 强、过滤成熟型号/数字/权限过滤多配置与成本要评估

3)Hybrid 检索配方(BM25 + Vector + Filter + Rerank)

你可以把它拆成四步:

  1. Query 规范化(可选)
  2. 双路召回(bm25 + vector)
  3. 合并去重
  4. 重排(可选,但很常见)

链路图:

query
  -> normalize(query) (可选)
  -> bm25_recall(top_k_bm25, filter)
  -> vec_recall(top_k_vec, filter)
  -> merge_dedup(limit)
  -> rerank(top_m) (可选)
  -> select_top_n_for_context

默认参数起点(可复制)

参数起点调参方向
top_k_vec5–10命中不足→调大;噪音污染→调小
top_k_bm255–20型号/数字场景命中不足→调大
merge_limit20–50候选太少→调大;成本高→调小
rerank_top_m20–50“像但不对”多→调大
context_top_n3–8答不全→调大;跑偏→调小

4)排错顺序(别上来就换库/换模型)

4.1 固定失败样本

  • query
  • expected_source_ids(正确证据段落 id)
  • observed(线上输出)

4.2 打印三条 TopK(第一生产力)

  • Vector TopK
  • BM25 TopK
  • Hybrid TopK

你会立刻看到:正确证据到底在哪条链路命中、为什么排不上来。

4.3 消融实验顺序(一次只改一个变量)

  1. 关 rerank
  2. 调 top_k(vec/bm25 各自调)
  3. 查 filter 是否误伤
  4. 做 query 规范化(同义词/数字/单位)
  5. 最后再考虑换库/换模型

5)hit@k 回归脚手架(vector / bm25 / hybrid 三条链路分别评)

5.1 评测集格式(jsonl)

{"id":"q001","query":"退款规则是什么?","expected_source_ids":["refund_v2025#p12","refund_v2025#p13"]}

5.2 通用 hit@k

import json
from typing import List

def load_jsonl(path: str):
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            yield json.loads(line)

def hit_at_k(retrieved_ids: List[str], expected_ids: List[str], k: int) -> int:
    topk = set(retrieved_ids[:k])
    return 1 if any(eid in topk for eid in expected_ids) else 0

def eval_hit(cases_path: str, retrieve, ks=(3, 5, 10)):
    totals = {k: 0 for k in ks}
    hits = {k: 0 for k in ks}
    for c in load_jsonl(cases_path):
        results = retrieve(c["query"])  # -> [{"id": "...", "text": "..."}]
        retrieved_ids = [r["id"] for r in results]
        for k in ks:
            hits[k] += hit_at_k(retrieved_ids, c["expected_source_ids"], k)
            totals[k] += 1
    return {f"hit@{k}": hits[k] / max(1, totals[k]) for k in ks}

5.3 三条链路对比(示意)

print("vector:", eval_hit("cases.jsonl", retrieve_vector))
print("bm25:", eval_hit("cases.jsonl", retrieve_bm25))
print("hybrid:", eval_hit("cases.jsonl", retrieve_hybrid))

6)Python 伪代码骨架(可直接搬到项目里)

def retrieve_vector(query, top_k, flt):
    return [{"id": "doc#p1", "text": "...", "score": 0.1}]

def retrieve_bm25(query, top_k, flt):
    return [{"id": "doc#p2", "text": "...", "score": 12.3}]

def merge_dedup(vec, kw, limit=50):
    seen, out = set(), []
    for r in vec + kw:
        if r["id"] in seen:
            continue
        seen.add(r["id"])
        out.append(r)
        if len(out) >= limit:
            break
    return out

def rerank(query, candidates, top_m=30):
    # TODO: 使用 rerank 模型对 candidates 重排
    return candidates[:top_m]

def retrieve_hybrid(query):
    flt = {}
    vec = retrieve_vector(query, top_k=10, flt=flt)
    kw = retrieve_bm25(query, top_k=20, flt=flt)
    cand = merge_dedup(vec, kw, limit=50)
    cand = rerank(query, cand, top_m=30)
    return cand

资源区

如果你们要做多模型 A/B 或评测,实践里常见做法是用 OpenAI 兼容接入方式把入口统一起来(多数情况下只改 base_urlapi_key)。
我自己用的是 147ai 官网(参数以其文档与控制台为准)。