检索(Retrieval)
检索是从知识库中找到与用户查询最相关的文档的过程。QAnything 采用"宽召回 → 精排序 → 严过滤"三段式检索,层层递进,平衡召回率与精确率。
一、是什么
检索是从知识库中找到与用户查询最相关的文档的过程。
核心输入 :归一化后的查询向量 + 知识库索引
核心输出:经过过滤和排序的相关文档列表
二、为什么重要
检索决定了 RAG 的天花板,生成只能逼近这个天花板。
核心矛盾:召回率 vs 精确率
- 检索多了 → 噪音多,LLM 被无关信息干扰("大海捞针"问题)
- 检索少了 → 遗漏关键信息,回答不完整
与传统方案的对比
| 优化点 | 传统方案 | 传统方案的问题 | QAnything 方案 | 优势 |
|---|---|---|---|---|
| 检索方式 | 仅向量检索 | 遗漏专有名词、数字等精确匹配 | 向量检索 + BM25 双路召回 | 语义 + 精确匹配互补 |
| 结果合并 | 两路结果直接拼接 | 同一文档出现两次,浪费 Token | Milvus 在前 + ES 独有追加 | 无重复,Token 更节省 |
| 分数融合 | RRF 或加权融合 | Milvus(0~1)和 BM25(无界)量纲不同,融合无意义 | 不做分数融合,交给 Rerank 统一排 | 避免量纲差异导致的排序错误 |
| 排序精度 | 仅用向量余弦相似度排序 | query 和 doc 独立编码,交互深度不足 | Rerank 交叉编码器联合编码 | 每层注意力交互,排序更精确 |
| 过滤策略 | 单一阈值(如 top-k 或 score > 0.5) | top-k 固定数量可能引入噪音;单一阈值无法检测质量断崖 | 两层过滤:绝对阈值 0.28 + 梯度断裂 50% | 保底 + 检测断崖,过滤更精准 |
| 去重方式 | 按 doc_id 去重 | 不同文件相同段落(如法规原文)无法去重 | 按 page_content 内容去重 | 跨文件重复内容也能去重 |
| 断连处理 | Milvus 断连 → 返回空结果 | 用户看到"无结果",体验差 | 空结果时自动重连重试 | 连接中断自动恢复 |
| HTTP 重试 | 无重试或固定间隔重试 | 网络抖动直接失败 | 指数退避(1s→2s→4s) | 给服务端恢复时间 |
三、怎么做
QAnything 采用三段式检索:宽召回 → 精排序 → 严过滤

阶段一:宽召回
3.1 Milvus 向量检索(语义级)
问题:需要理解"同义词"和"概念相似"(如"AI"和"人工智能")。
方案:使用 HNSW 索引进行近似最近邻搜索。
python
# HNSW 索引:在 64 个候选点中精确搜索
search_params = {"params": {"ef": 64}}
# 按知识库分区过滤
expr = f'kb_id in {partition_keys}'
# 检索流程:query → embedding → HNSW 搜索 → top_k 子块 → 映射回父块
HNSW 原理:
- 分层图结构,高层快速定位,低层精确搜索
ef=64:在 64 个候选点中搜索,平衡速度和精度
3.2 Elasticsearch BM25 关键词检索(精确级)
问题:向量检索容易遗漏精确关键词(如专有名词 "Qwen2.5"、数字 "¥0.008")。
方案:使用 BM25 算法进行精确关键词匹配。
python
# BM25 策略:基于倒排索引的精确匹配
es_store = ElasticsearchStore(strategy=BM25RetrievalStrategy())
filter = [{"terms": {"metadata.kb_id.keyword": partition_keys}}]
es_sub_docs = await es_store.asimilarity_search(query, k=top_k, filter=filter)
BM25 核心原理:
- TF(词频):一个词在文档中出现越多 → 越重要
- IDF(逆文档频率):一个词在整个语料中出现越少 → 越有区分度
向量检索 vs BM25 对比:
| 特性 | 向量检索 | BM25 |
|---|---|---|
| 理解同义词 | 是 | 否 |
| 精确匹配 | 弱 | 强 |
| 适合场景 | 语义类问题 | 精确关键词 |
| 速度 | 快 | 快 |
3.3 去重合并(不做分数融合)
问题:两路检索结果可能重复,浪费 Token。
方案:Milvus 在前(语义质量更稳定),ES 独有的追加。
python
# Milvus 在前,ES 独有的追加
milvus_doc_ids = [d.metadata[id_key] for d in query_docs]
for d in es_sub_docs:
if doc_id not in milvus_doc_ids:
es_ids.append(doc_id)
query_docs.extend(es_docs)
为什么不做分数融合:
- Milvus 分数 = 余弦相似度(0~1)
- ES 分数 = BM25(可以是负数或很大的数)
- 量纲不同,直接融合无意义,交给 Rerank 统一排序
阶段二:精排序
3.4 Rerank 交叉编码器精排
问题:向量检索的"粗排"分数不够精确。
方案:使用交叉编码器对候选文档进行精排。
| 特性 | 双编码器(向量模型) | 交叉编码器(Rerank) |
|---|---|---|
| 编码方式 | query 和 doc 独立编码 | query 和 doc 拼接后联合编码 |
| 交互深度 | 只在最后算余弦相似度 | 每一层都有注意力交互 |
| 精度 | 较粗 | 更精确 |
| 速度 | 快(可预计算) | 慢(每对重新计算) |
| 角色 | 大规模初筛 | 少量候选精排 |
为什么不用交叉编码器做初筛:
- 交叉编码器需要对每个 (query, doc) 对重新计算,无法预计算
- 如果知识库有 100 万条文档,交叉编码器要计算 100 万次,不现实
3.5 Rerank 异常降级
python
try:
docs = await self.rerank.arerank_documents(query, docs)
except Exception:
# 降级:用 embedding 余弦相似度打分
embed1 = await self.embeddings.aembed_query(query)
for doc in docs:
embed2 = await self.embeddings.aembed_query(doc.page_content)
doc.metadata['score'] = cosine_similarity(embed1, embed2)
3.6 Embedding/Rerank 客户端并发批处理
python
# 大批量拆成多个小批次,并发发送
tasks = [self._get_embedding_async(session, texts[i:i+batch_size])
for i in range(0, len(texts), batch_size)]
results = await asyncio.gather(*tasks) # 并发等待
阶段三:严过滤
3.7 第一层------绝对阈值过滤
问题:去掉绝对不相关的噪音文档。
python
# 保留 rerank 分数 >= 0.28 的文档
filtered = [doc for doc in docs if doc.metadata['score'] >= 0.28]
# 如果过滤后为空,保留全部(总比什么都不返回好)
0.28 的含义:经验阈值,低于此分数的文档基本不相关。
3.8 第二层------梯度断裂检测
问题:在保留的文档中,找到高质量和低质量的分界线。
python
saved_docs = [docs[0]] # 最高分文档
for doc in docs[1:]:
relative_diff = (saved_docs[0].score - doc.score) / saved_docs[0].score
if relative_diff > 0.5: # 50% 质量断崖
break # 后面的全不要
else:
saved_docs.append(doc)
执行示例:
分数: [0.92, 0.85, 0.80, 0.40, 0.35]
0.92 → 保留(最高分)
0.85 → (0.92-0.85)/0.92 = 7.6% < 50% → 保留
0.80 → (0.92-0.80)/0.92 = 13% < 50% → 保留
0.40 → (0.92-0.40)/0.92 = 56.5% > 50% → 截断!
最终保留: [0.92, 0.85, 0.80]
两层配合的逻辑:
- 只用绝对阈值:可能所有文档都 > 0.28 但后面几个已经很差
- 只用相对差异:如果都很低(如 0.35, 0.30, 0.28),相对差异不大但都不该要
- 两层组合:先保底(去绝对噪音),再检测断裂(去相对噪音)
3.9 内容级去重
python
# 按 page_content 文本内容去重(非 doc_id)
def deduplicate_documents(source_docs):
unique_docs = set()
for doc in source_docs:
if doc.page_content not in unique_docs:
unique_docs.add(doc.page_content)
deduplicated_docs.append(doc)
为什么不用 doc_id:不同文件可能有完全相同的段落(如法规原文),按内容去重更有效。
3.10 已删除文档过滤 + 默认分数兜底
python
# 过滤已删除的文档
if retriever.mysql_client.is_deleted_file(doc.metadata['file_id']):
continue
# 没有分数的文档用位置倒序兜底
if 'score' not in doc.metadata:
doc.metadata['score'] = 1 - (idx / len(query_docs))
3.11 Milvus 自动重连 + HTTP 重试
python
# Milvus 空结果时重建连接
if len(query_docs) == 0:
retriever.vectorstore_client = VectorStoreMilvusClient()
query_docs = await retriever.get_retrieved_documents(...)
# HTTP 指数退避重试
retry = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
# 重试间隔:1s → 2s → 4s
四、解决的问题汇总
| 阶段 | 手段 | 解决的问题 |
|---|---|---|
| 宽召回 | Milvus 向量检索 | 语义级文档匹配 |
| 宽召回 | ES BM25 检索 | 精确关键词匹配 |
| 宽召回 | 去重合并 | 两路结果重复 |
| 精排序 | Rerank 交叉编码器 | 粗排精度不足 |
| 精排序 | 异常降级 | Rerank 服务故障 |
| 精排序 | 客户端并发批处理 | 大批量推理效率 |
| 严过滤 | 绝对阈值 ≥ 0.28 | 绝对噪音 |
| 严过滤 | 梯度断裂 > 50% | 相对噪音 |
| 严过滤 | 内容级去重 | 跨文件相同段落 |
| 严过滤 | 已删除过滤 | 数据一致性 |
| 鲁棒性 | 自动重连 | Milvus 连接中断 |
| 鲁棒性 | HTTP 指数退避 | 网络不稳定 |