QAnything 阅读优化策略05——检索

检索(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 指数退避 网络不稳定

相关推荐
ruofu331 小时前
wsl端是py312,但是项目是py311, 如何下载py311的依赖包(wheels)
python·docker
小大宇1 小时前
python flask框架 SSE流式返回、跨域、报错
开发语言·python·flask
zhangfeng11332 小时前
CVOCA 卷积模型,《Nature》子刊 特征提取技术突破性研究的综合分析报告
人工智能
炎火星2 小时前
直播切片素材杂乱,怎么用 AI 剪辑做成可用带货视频?
人工智能
像风一样的男人@2 小时前
python --fastapi推流AI推理
人工智能·python·fastapi
行走的小派2 小时前
从个人开发到企业部署:OPi AI Station四大应用场景与选型指南
人工智能·边缘计算·香橙派·边缘ai
天云数据2 小时前
从“雇佣一个人”到“组建一个团队”:AI生产力的组织方式被重写
人工智能
2601_956319882 小时前
最新量化开发卡住,先查规则和流程是否完整
人工智能·python