做企业知识库(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)选型决策树(先把约束写清楚)
你只需要回答四个问题:
- chunk 规模(10万 / 100万 / 1000万)
- 峰值并发(QPS)
- 检索延迟预算(P95)
- 运维能力(能否维护集群)
决策建议:
- 小中规模 + 工程简单:pgvector
- 单机/离线/极致延迟:FAISS(你自己服务化)
- 大规模向量检索 + 分布式扩展:Milvus
- 关键词检索强、过滤/聚合重、Hybrid 常态:Elasticsearch
2)四种方案的边界(工程视角)
| 方案 | 强项 | 典型适用 | 你要付出的代价 |
|---|---|---|---|
| pgvector | 工程简单、过滤/Join 方便 | 小中规模 RAG、业务库同库 | 性能/扩展上限 |
| FAISS | 单机性能强、可控 | 本地检索服务、离线索引 | 高可用/更新/运维自己补 |
| Milvus | 大规模向量检索 | 100万+ chunk、需要扩展 | 集群复杂度更高 |
| ES | BM25 强、过滤成熟 | 型号/数字/权限过滤多 | 配置与成本要评估 |
3)Hybrid 检索配方(BM25 + Vector + Filter + Rerank)
你可以把它拆成四步:
- Query 规范化(可选)
- 双路召回(bm25 + vector)
- 合并去重
- 重排(可选,但很常见)
链路图:
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_vec | 5–10 | 命中不足→调大;噪音污染→调小 |
| top_k_bm25 | 5–20 | 型号/数字场景命中不足→调大 |
| merge_limit | 20–50 | 候选太少→调大;成本高→调小 |
| rerank_top_m | 20–50 | “像但不对”多→调大 |
| context_top_n | 3–8 | 答不全→调大;跑偏→调小 |
4)排错顺序(别上来就换库/换模型)
4.1 固定失败样本
- query
- expected_source_ids(正确证据段落 id)
- observed(线上输出)
4.2 打印三条 TopK(第一生产力)
- Vector TopK
- BM25 TopK
- Hybrid TopK
你会立刻看到:正确证据到底在哪条链路命中、为什么排不上来。
4.3 消融实验顺序(一次只改一个变量)
- 关 rerank
- 调 top_k(vec/bm25 各自调)
- 查 filter 是否误伤
- 做 query 规范化(同义词/数字/单位)
- 最后再考虑换库/换模型
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_url 与 api_key)。
我自己用的是 147ai 官网(参数以其文档与控制台为准)。