从 chunk 到 LLM 输入之间的所有工程

检索栈:从一堆 chunk 到 LLM 的输入

embedding 选好了、文档切好了——chunk 已经在内存里、每个都带着一个 1024 维向量。接下来要解决三件具体的事:怎么存(向量库),怎么查得准(向量检索单独不够,得加 BM25 做混合),怎么把"召回前 50"再精排成"真正最相关的 5 条"(reranker)。这三步加起来就是 RAG 的检索栈,也是这一篇要讲的全部。

讲完之后你应该能从前一篇切好的 chunk 起步,搭出一个生产可用的检索系统。

向量数据库:为什么是 Qdrant

主流向量库一张表先看清楚:

名称部署形态上手难度规模上限何时选
Chroma嵌入式(一个 Python 库 + 一个文件)极低100 万级个人项目、原型、教程
QdrantDocker / 单机 / 集群亿级多数生产环境的甜区
MilvusK8s 集群(Zilliz Cloud 托管版)中等偏高十亿+超大规模、企业级
PgVectorPostgreSQL 扩展低(如果已经在用 PG)千万级已有 PG、不想多一个组件
Pinecone纯 SaaS极低不限(按量付费)完全不想运维
WeaviateDocker / 集群中等亿级需要内置 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,                   # 建图搜索宽度
    ),
)

mef_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 里。HNSW 索引在每个 collection 内是独立的,混在一起意味着检索时模型要在跨业务的全集里挑——不仅慢,召回质量也会被互相干扰。分开建 collection 比靠 doc_type 过滤干净得多。

向量数到 1000 万级以上,开 quantization。Qdrant 支持两档——scalar quantization(float32 → int8)几乎无损,召回掉点 < 1%,存储立减 4 倍;binary quantization 把每维压到 1 bit,存储省 32 倍但召回掉 5%~10%(用两阶段检索 + float32 精排可以补回来)。不开就是在浪费机器钱。

最后一条小细节:Python 客户端用 prefer_grpc=True 切到 gRPC,批量插入会快几倍——HTTP 在大批量场景下是瓶颈。

纯向量检索的软肋,以及 BM25 为什么回来了

embedding 做"语义相似"很强,但它有个特别明显的软肋——对精确匹配不敏感。具体什么意思?看几个真实业务里反复出现的场景:

用户搜 "ABC-1234" 这个产品编号,向量空间里 "ABC-1234" 和 "ABC-5678" 高度相似(毕竟字符级长得太像),结果错的产品被召回;用户搜 "Q1 财报",被 "Q2 财报" 抢先召回("Q1" 和 "Q2" 在 embedding 里就是近邻);用户在代码库里搜 parseInt,向量检索觉得 parseFloatparseFloat32 这些都很像。这些场景的共同特征是——字面是否精确匹配比语义相近更有价值。

这正是 1990 年代搜索引擎的老兵 BM25(Best Match 25,TF-IDF 的进化版,番外 11 提过)擅长的。BM25 给每个文档一个"和 query 字面匹配程度"的分数,数字、专有名词、产品代码、缩写、错别字——所有 embedding 不擅长的精确匹配场景上它都强。它还有几个朴素的工程优势:完全可解释(每个 token 贡献多少分都看得见)、计算极快(倒排索引)、存储极小、不需要 GPU、不需要训练。

但 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%。

当 hybrid 还不够:几种"用 LLM 帮检索"的套路

混合检索是基础。但实际业务里你常常会遇到 hybrid 搞不定的 case——多数源于一个共同问题:用户的 query 和文档的语言风格对不上。用户敲"咋办"、文档写"如何处理";用户问得很具体、文档讲得很抽象;用户表述不规范、文档措辞规整。这些场景里,再换 embedding 也难有质变,得在改写 query 这一侧下手。

主流套路有四种,按从轻到重排——

最轻的是 Multi-Query:让 LLM 把用户的 query 改写成 3~5 个不同表述,分别检索后取并集。这条最直接,对"用户表达不规范"特别有效,开发成本几乎只有一次额外的 LLM 调用。

进一步是 HyDE(Hypothetical Document Embeddings):让 LLM 先假设性地写一段答案(即使不准),再用这段假答案的 embedding 去检索。原理是文档库里的 chunk 本身就是"答案风格",用一段假答案做 query 比用一个问题做 query,向量分布上更接近真实命中。番外 09 提过这个。这条对"问题和文档语言风格差太大"的场景效果显著。

再激进一点是 Step-back prompting:先让 LLM 把具体问题"抽象一层"再检索。"重置密码的具体步骤" → "账户管理",粗召相关章节、再回到原问题做细查。适合知识库结构层级清晰的场景。

最复杂的是 Recursive retrieval:基于第一轮召回结果生成第二轮查询,做多轮迭代。开发和延迟成本都高,但对开放性问题("分析 X 公司的产品策略"这种没有单一文档能完整回答的)效果好。

实战顺序很重要:先把 hybrid + reranker 做扎实,再去看哪些 case 系统性地翻车——是用户表达问题,那加 Multi-Query 或 HyDE;是知识结构问题,那加 Step-back;是开放性问题,再考虑 Recursive。一上来全堆上去,工程复杂度会失控、延迟也会变成几秒。

Reranker:为什么 hybrid 之后还要再过一层

到这一步你已经有 hybrid 召回了,为什么还要加一层 reranker?答案在 embedding 检索的本质里——它是双塔模型:query 和 document 分别过 encoder、各自被压成一个 1024 维向量,再算余弦相似度。这种"先各自浓缩再比较"的做法天然会丢精细信息——query 里的某个限定词、document 里某个反义结构,被压成向量时就模糊掉了。

Reranker 是另一种结构——交叉编码器(cross-encoder)——它不分别编码,而是把 query 和 document 拼在一起过一次模型,输出一个"它俩相关吗"的分数。模型内部能看到 query 和 document 的 token 之间的 attention 交互,捕捉双塔模型丢掉的那些细节。精度通常比纯 embedding 高一档。

代价是慢——每对 (query, document) 都要跑一次完整 forward pass,没法像 embedding 那样预先索引。这导致 reranker 不能直接用作召回,只能用作精排。所以工业上的做法是两阶段检索:先让 embedding + BM25 hybrid 在百万级文档里快速召回 top 100,再让 reranker 对这 100 条精排到 top 10。慢的部分只跑 100 次而不是百万次,性能就可控了。

中文场景下两个最常用的 reranker 选型——

BAAI/bge-reranker-v2-m3(开源、自托管、和 bge-m3 配套)。多数项目的起手式,效果够、免费、部署简单:

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
scores = reranker.compute_score([
    ("如何重置密码", "重置密码的步骤是登录后..."),
    ("如何重置密码", "修改用户名需要先验证邮箱..."),
    ("如何重置密码", "如果忘记密码,请点击登录页的找回链接..."),
])
# 输出: [3.45, -2.10, 4.21],越大越相关,能为负

资源充裕时可以换更强的 BAAI/bge-reranker-v2-gemma(基于 Gemma-2B),质量再高一档但模型大、推理慢。

Cohere rerank-3(API、托管)。多语言场景里的商业 SOTA,上下文 4K(很多开源 reranker 还卡在 512 token):

import cohere

co = cohere.Client(api_key="...")
results = co.rerank(
    model="rerank-multilingual-v3.0",
    query="如何重置密码",
    documents=candidates,
    top_n=10,
)

价格 $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"。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 14:向量数据库 Qdrant 实战、混合检索与 Reranker

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外14-向量库与检索栈/

本文最后一次更新为 天前,文章中的某些内容可能已过时!