从 chunk 到 LLM 输入之间的所有工程
检索栈是 RAG 的心脏
embedding 选好了、文档切好了,下一步是把它们存起来、查得出来、查得准。这一篇覆盖检索栈完整链路——
- 向量数据库:选型对比 + Qdrant 实战
- 混合检索:纯向量为什么不够、BM25 怎么补、RRF 怎么融
- Reranker:为什么需要、BGE / Cohere 怎么用
读完这篇你能从一堆 chunk 起步,搭出生产可用的检索系统。
向量数据库:选型对比
主流向量库一张表先看清楚:
| 名称 | 部署形态 | 上手难度 | 规模上限 | 何时选 |
|---|---|---|---|---|
| Chroma | 嵌入式(一个 Python 库 + 一个文件) | 极低 | 100 万级 | 个人项目、原型、教程 |
| Qdrant | Docker / 单机 / 集群 | 低 | 亿级 | 多数生产环境的甜区 |
| Milvus | K8s 集群(Zilliz Cloud 托管版) | 中等偏高 | 十亿+ | 超大规模、企业级 |
| PgVector | PostgreSQL 扩展 | 低(如果已经在用 PG) | 千万级 | 已有 PG、不想多一个组件 |
| Pinecone | 纯 SaaS | 极低 | 不限(按量付费) | 完全不想运维 |
| Weaviate | Docker / 集群 | 中等 | 亿级 | 需要内置 GraphQL / 多模态 |
| Faiss | 纯库(无服务) | 中等 | 内存上限 | 嵌入到自己的服务里、超低延迟 |
为什么推荐 Qdrant 作为起步:它在功能完整度(payload filter、混合检索原生支持、HNSW 调参充分)和上手简单度(单 Docker 容器即跑、Python SDK 友好)之间平衡得最好;用 Rust 写的,性能比 Python 实现的 Chroma 快几倍;中等规模生产基本不用换;要扩到集群也支持。Chroma 学起来最快,但单机性能上限低、缺少 hybrid search、生产案例不多。
Qdrant 实战:从零到能用
起服务
最简单一行 Docker:
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
6333 是 HTTP API 端口、6334 是 gRPC(生产用 gRPC 性能好不少)。qdrant_storage 是数据持久化目录。起来后访问 http://localhost:6333/dashboard 有个内置 Web UI 能看 collection 状态。
Python 客户端:
pip install qdrant-client
创建 collection 与索引
向量库里"集合(collection)"就是一张表的概念。建一个:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="docs",
vectors_config=VectorParams(
size=1024, # bge-m3 的维度
distance=Distance.COSINE, # 余弦相似度
),
hnsw_config=HnswConfigDiff(
m=32, # HNSW 图连接数
ef_construct=200, # 建图搜索宽度
),
)
m 和 ef_construct 是 HNSW 算法的两个核心参数。m 越大召回越高、内存越大、建库越慢,生产 32 是甜区;ef_construct 影响建库时间,给 200~400 即可。番外 10 详细讲过 HNSW。
插入向量 + payload
Qdrant 把"向量"和"附带数据(payload)"分开存——payload 就是上一篇讲的 metadata:
from qdrant_client.models import PointStruct
import uuid
points = []
for chunk in chunks: # chunks 来自番外 13 的切分函数
vec = embed_model.encode(chunk["content"], normalize_embeddings=True).tolist()
points.append(PointStruct(
id=str(uuid.uuid4()),
vector=vec,
payload={
"content": chunk["content"],
"source": chunk["metadata"]["source"],
"section": chunk["metadata"].get("section_path"),
"doc_type": chunk["metadata"]["doc_type"],
},
))
# 批量插入,每批 100~500 条
for i in range(0, len(points), 200):
client.upsert(collection_name="docs", points=points[i:i+200])
upsert 是"存在则更新、不存在则插入",重跑 pipeline 不会重复。批量传比逐条传快几十倍——千万别一条一条 upsert。
带过滤的检索
from qdrant_client.models import Filter, FieldCondition, MatchValue
query = "如何重置密码"
qvec = embed_model.encode(query, normalize_embeddings=True).tolist()
results = client.search(
collection_name="docs",
query_vector=qvec,
limit=10,
query_filter=Filter(
must=[
FieldCondition(key="doc_type", match=MatchValue(value="用户手册")),
]
),
)
for r in results:
print(f"score={r.score:.3f} source={r.payload['source']}")
print(r.payload["content"][:100])
query_filter 在向量检索之前先做一遍过滤,比检索完再过滤快得多——Qdrant 在索引构建时会为常用 payload 字段加倒排索引:
from qdrant_client.models import PayloadSchemaType
client.create_payload_index(
collection_name="docs",
field_name="doc_type",
field_schema=PayloadSchemaType.KEYWORD,
)
Collection 设计的几条经验
- 不同业务的 chunk 不要混在一个 collection 里——分开建 collection 比靠 doc_type 过滤更清爽,HNSW 索引在每个 collection 内是独立的,互不干扰
- 超过 1000 万条向量建议开 quantization:Qdrant 支持 scalar quantization(int8)和 binary quantization,前者基本无损召回掉点 < 1%,后者能省 32 倍存储
- 用 gRPC 客户端:Python 客户端
prefer_grpc=True在批量插入时快好几倍
为什么纯向量检索不够
embedding 检索做"语义相似"很强,但有个软肋:对精确匹配不敏感。试想这些场景——
- 用户搜 "ABC-1234" 这个产品编号,但向量空间里 "ABC-1234" 和 "ABC-5678" 高度相似,结果错的产品被召回
- 用户搜 "Q1 财报",结果被 "Q2 财报" 抢先召回("Q1" 和 "Q2" 在 embedding 里太像)
- 用户搜代码里的
parseInt,向量检索觉得parseFloat也很像
这些场景里,字面精确匹配比语义相似更有价值。这正是关键词检索(BM25)擅长的。
BM25:被低估的传统武器
BM25(Best Match 25)是 1990 年代搜索引擎的产物,TF-IDF 的进化版(番外 11 提过)。它给每个文档一个"和 query 字面匹配程度"的分数。
BM25 的好处:
- 数字、专有名词、产品代码、缩写——所有 embedding 不擅长的精确匹配,BM25 都强
- 完全可解释——分数怎么来的看得见
- 计算极快、存储极小(一个倒排索引)
- 不需要 GPU、不需要训练
BM25 的弱:
- 不懂语义——"忘记密码"和"重置密码"在 BM25 看来毫不相关
- 对 query 改写敏感——"咋办"和"怎么办"被当成不同的词
BM25 和向量检索是互补的——前者强在精确匹配、后者强在语义匹配。生产 RAG 系统几乎都同时跑两路检索再融合,叫混合检索(hybrid search)。
Python 里 rank_bm25 是最简单的实现,几行就能跑:
from rank_bm25 import BM25Okapi
import jieba # 中文要先分词
corpus = [chunk["content"] for chunk in chunks]
tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
query = "如何重置密码"
tokenized_query = list(jieba.cut(query))
scores = bm25.get_scores(tokenized_query) # 每个 chunk 一个分数
top_n = scores.argsort()[-10:][::-1] # top-10
生产环境用 Elasticsearch / OpenSearch 跑 BM25 更稳,性能也好——但 demo 阶段 rank_bm25 够用。
Qdrant 从 1.10 开始也原生支持稀疏向量(sparse vectors),可以直接在 Qdrant 里同时存稠密向量和 BM25 索引:
from qdrant_client.models import SparseVectorParams, SparseVector
client.recreate_collection(
collection_name="docs",
vectors_config={
"dense": VectorParams(size=1024, distance=Distance.COSINE),
},
sparse_vectors_config={
"bm25": SparseVectorParams(),
},
)混合检索:RRF 融合算法
跑了两路检索(向量 + BM25)拿到两份 top-k,怎么合成一份?
最朴素的做法是把分数加起来——但向量相似度通常在 0~1 之间,BM25 分数能到几十甚至上百,直接加权完全被 BM25 主导。归一化两个分数也有问题:BM25 的分数分布因 query 长度不同差别巨大,归一化后仍然不稳定。
Reciprocal Rank Fusion(RRF) 是当前公认最稳的融合算法:
RRF_score(d) = Σ over all retrievers: 1 / (k + rank(d))
含义是:每个检索器给文档 d 一个排名,把排名(不是分数)的倒数求和——排名第 1 贡献 1/(60+1),第 2 贡献 1/(60+2),依此类推。k 是一个常数,通常取 60。
直觉上 RRF 做的是用排名而非分数——这样不管两个检索器的分数尺度差多少,融合都稳。文档同时在两个榜单都靠前的,最终分数高;只在一个榜单靠前的,分数中等。
def rrf_fuse(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""rankings: 每个检索器返回的 doc_id 列表(按相关性排序)"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
# 用法
vector_results = [r.id for r in client.search(...)]
bm25_results = [doc_ids[i] for i in bm25.get_scores(tokenized_query).argsort()[-30:][::-1]]
fused = rrf_fuse([vector_results, bm25_results])[:10]
Qdrant 1.10+ 直接内置 RRF,可以一次 query 完成混合检索:
from qdrant_client.models import Prefetch, FusionQuery, Fusion
results = client.query_points(
collection_name="docs",
prefetch=[
Prefetch(query=qvec, using="dense", limit=30),
Prefetch(query=SparseVector(indices=..., values=...), using="bm25", limit=30),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=10,
)
实战经验:先在你的评测集上测一下纯向量、纯 BM25、混合三种方案的 Recall@10,多数中文 RAG 业务上混合检索比纯向量 Recall 高 5%~15%。
还有更激进的检索增强
混合检索是基础。生产 RAG 还有几个常用 trick:
Multi-Query(多查询生成):用 LLM 把用户的 query 改写成 3~5 个不同表述,分别检索后合并。能 cover 用户表达不规范的情况。
HyDE(Hypothetical Document Embeddings):让 LLM 先假设性地写一段答案(即使不准),再用这段假答案的 embedding 去检索——比用问题本身的 embedding 更接近文档分布。番外 09 提过这个。
Step-back prompting:先让 LLM 把具体问题"抽象一层"再检索。"重置密码的具体步骤" → "账户管理"。先粗召回相关章节、再细查具体细节。
Recursive retrieval:基于第一轮召回的结果生成第二轮查询,做多轮检索。复杂但对开放性问题效果好。
这些都不是必须,先把 hybrid search + reranker 做扎实,再按需上。
Reranker:为什么 embedding 召回还不够准
embedding 检索是双塔模型——query 和 document 分别过 encoder,再算余弦相似度。这种"先各自浓缩成向量再比较"的方式天然会丢精细信息。
Reranker 是交叉编码器(cross-encoder)——把 query 和 document 拼在一起过一次模型,输出"它俩相关吗"的分数。能看到 query 和 document 的 token 级 attention 交互,精度比双塔高一档。
代价是慢——每对 (query, document) 要跑一次完整 forward pass。所以工业实践是两阶段检索:
- 用 embedding + BM25 hybrid 快速召回 top 100 候选
- 用 reranker 对这 100 条精排,取最终 top 10
慢的部分只跑 100 次而不是百万次,性能可控。
BGE-reranker(开源、自托管)
BAAI/bge-reranker-v2-m3 是当前最常用的开源 reranker,多语言、和 bge-m3 配套:
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
query = "如何重置密码"
candidates = [
"重置密码的步骤是登录后...",
"修改用户名需要先验证邮箱...",
"如果忘记密码,请点击登录页的找回链接...",
]
# 直接传 (query, doc) 对,返回相关度分数
scores = reranker.compute_score([(query, doc) for doc in candidates])
# scores: [3.45, -2.10, 4.21] ← 越大越相关,能为负
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
BAAI/bge-reranker-v2-gemma 是基于 Gemma-2B 的更强版本,质量更高但模型大、推理慢。资源充裕的场景用这个。
Cohere Rerank(API、托管)
Cohere 的 rerank-3 系列是商业 SOTA,多语言支持出色:
import cohere
co = cohere.Client(api_key="...")
results = co.rerank(
model="rerank-multilingual-v3.0",
query="如何重置密码",
documents=candidates,
top_n=10,
)
for r in results.results:
print(f"score={r.relevance_score:.3f} index={r.index}")
Cohere rerank 的优势是不用自己部署、多语言强、上下文 4K(很多开源 reranker 还在 512)。价格 $1 / 1000 次 search(一次 search 是 query + 100 docs)。中文场景的精度在我自己测过的几个业务上比 bge-reranker-v2-m3 略好,但部署成本高。
选型建议:起步用 bge-reranker-v2-m3(开源免费、效果够、自托管);规模上来嫌部署烦或追求最好效果,换 Cohere。
完整检索链路代码
把上面所有零件拼成一个 starter——切片已经在 Qdrant 里了,下面是检索 + 精排:
from qdrant_client import QdrantClient
from qdrant_client.models import Prefetch, FusionQuery, Fusion, SparseVector
from sentence_transformers import SentenceTransformer
from FlagEmbedding import FlagReranker
import jieba
from rank_bm25 import BM25Okapi
# 初始化
client = QdrantClient(host="localhost", port=6333)
embedder = SentenceTransformer("BAAI/bge-m3")
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def search(query: str, top_k: int = 10, candidate_k: int = 50) -> list[dict]:
"""混合检索 + Reranker 精排,返回最终 top_k 文档。"""
# 1. 向量分支
qvec = embedder.encode(query, normalize_embeddings=True).tolist()
# 2. BM25 分支:直接用 Qdrant sparse vector,或者外挂 ES
sparse = compute_bm25_sparse_vector(query) # 业务里实现,输出 (indices, values)
# 3. Qdrant 内置 RRF 融合
fused = client.query_points(
collection_name="docs",
prefetch=[
Prefetch(query=qvec, using="dense", limit=candidate_k),
Prefetch(
query=SparseVector(indices=sparse[0], values=sparse[1]),
using="bm25", limit=candidate_k,
),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=candidate_k, # 给 reranker 50 条候选
).points
# 4. Reranker 精排
pairs = [(query, p.payload["content"]) for p in fused]
scores = reranker.compute_score(pairs)
reranked = sorted(zip(fused, scores), key=lambda x: x[1], reverse=True)
return [
{"content": p.payload["content"], "score": float(s), "metadata": p.payload}
for p, s in reranked[:top_k]
]
这条链路的"齿轮关系":
- 召回阶段广撒网(candidate_k=50 甚至 100),允许有噪声
- 精排阶段精选(top_k=5~10),把噪声筛出去喂给 LLM
收尾
整个检索栈的本质就是一句话:embedding 找语义近邻,BM25 找字面命中,RRF 把两路融起来,reranker 在融合结果上精排。每一节都不复杂,关键是把它们组合好。
下一篇番外 15 我们讲怎么衡量这条链路的质量——你现在 Recall@10 是多少?换 reranker 后回答质量提升了几个百分点?这些不靠测,永远是"感觉好像 work"。
参考资料
- Qdrant 官方文档
- Qdrant Hybrid Search 教程 — RRF 融合的具体实现
- BGE-Reranker 仓库
- Cohere Rerank API 文档
- BM25 综述(Ken Wikipedia)
- HyDE 论文:Precise Zero-Shot Dense Retrieval without Relevance Labels
- RRF 原始论文:Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:番外 14:向量数据库 Qdrant 实战、混合检索与 Reranker
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外14-向量库与检索栈/
本文最后一次更新为 天前,文章中的某些内容可能已过时!