Qdrant 召回不一致踩坑实录:跑了 300 次测试才发现是索引没刷新

凌晨两点,用户反馈 AI Agent 突然忘了昨天聊过的项目细节,我睡眼惺忪地打开 Grafana,看到记忆召回率从 98% 掉到 60%。第一反应是 embedding 模型又抽风了,结果查了半天日志,发现是 Qdrant 的写入和查询之间有个"时间差"------数据明明 upsert 了,查询却时有时无。这不是第一次了,我决定用 pytest + Qdrant 把召回一致性测试自动化,结果发现坑比想象中深。

问题拆解

AI Agent 的记忆存储用 Qdrant 存向量和 payload,核心需求是「写入后立刻可召回」。但在真实场景中,召回不一致的表现很诡异:本地测试全过,CI 上偶尔挂;同一个查询,连续执行两次结果不一样。根因在于 Qdrant 的写入和索引构建是异步的------upsert 方法默认不等待索引刷新就返回,导致后续查询可能读到空结果或部分结果。常规方案是手动在代码里加 time.sleep(2),但这不是工程做法,而且 sleep 时间不固定,治标不治本。更麻烦的是,召回一致性还涉及距离度量和 score 阈值的选择,一个不小心测试断言就会误导你。

方案设计

我选 pytest 作为测试框架,因为它的 fixture 和参数化能力非常适合做隔离和覆盖多场景测试。Qdrant 官方 Python 客户端支持 :memory: 模式,每个测试用例可以拿到一个干净的向量数据库实例,完全隔离,不用 Mock------Mock 测不出真实索引行为。为什么不选 unittest?因为 fixture 清理和参数化写起来太啰嗦。为什么不选 mock?因为我们要验证的是真实 Qdrant 的召回行为,mock 只会掩盖异步索引问题。

架构上,我在 conftest.py 里定义一个 qdrant_client fixture,每个测试自动创建独立 collection,测试结束自动清理。核心测试覆盖三类场景:单点召回、批量写入后召回、带 payload filter 的召回。

核心实现

这段代码解决测试环境隔离问题,确保每个测试用独立的 Qdrant 实例和 collection。

python 复制代码
# conftest.py
import pytest
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance

@pytest.fixture
def qdrant_client():
    # 内存模式:每个测试完全隔离,不落盘,速度快
    client = QdrantClient(":memory:")
    yield client
    client.close()

@pytest.fixture
def mem_collection(qdrant_client):
    collection_name = "agent_memory_test"
    # 向量维度 3,余弦距离
    qdrant_client.create_collection(
        collection_name=collection_name,
        vectors_config=VectorParams(size=3, distance=Distance.COSINE)
    )
    return qdrant_client, collection_name

这段代码暴露第一个坑:直接 upsert 后立即查询,召回结果不稳定。

python 复制代码
# test_recall_failure.py
import pytest
from qdrant_client.models import PointStruct

def test_immediate_recall_without_wait(mem_collection):
    client, collection = mem_collection
    # 写入一条向量,注意:没有 wait=True
    client.upsert(
        collection_name=collection,
        points=[PointStruct(id=1, vector=[0.1, 0.2, 0.3], payload={"user": "alice"})]
    )
    # 立即查询相同向量,期望能召回
    result = client.query_points(
        collection_name=collection,
        query=[0.1, 0.2, 0.3],
        limit=1
    )
    # 这一行经常失败:result.points 为空
    assert len(result.points) == 1

运行这个测试,10 次有 6 次挂掉,报 AssertionError: assert 0 == 1。这就是线上召回率抖动的原因。

这段代码修复索引刷新问题:wait=True 强制等待索引就绪后再查询。

python 复制代码
# test_recall_fix.py
import time
from qdrant_client.models import PointStruct, VectorParams, Distance

def test_immediate_recall_with_wait(mem_collection):
    client, collection = mem_collection
    # 关键参数:wait=True,确保写入后索引刷新完成
    client.upsert(
        collection_name=collection,
        points=[PointStruct(id=1, vector=[0.1, 0.2, 0.3], payload={"user": "alice"})],
        wait=True  # 官方文档没明说,但这是解决异步索引的关键
    )
    # 现在查询稳定返回结果
    result = client.query_points(
        collection_name=collection,
        query=[0.1, 0.2, 0.3],
        limit=1
    )
    assert len(result.points) == 1
    assert result.points[0].id == 1
    # 还可以进一步验证 payload 一致性
    assert result.points[0].payload["user"] == "alice"

如果不想每个调用都加 wait=True,可以封装一个 safe_upsert 函数,内部轮询 collection 状态直到 optimizers_status 变为 ok

踩坑记录

坑 1:upsert 后立即查询返回空结果,CI 上随机挂掉。 现象:本地测试通过,推到 CI 就挂,错误信息 assert 0 == 1。原因:Qdrant 的 upsert 方法默认不等待索引刷新,数据虽然写入存储,但 HNSW 索引还没构建好,查询走了索引,结果为空。解决:使用 wait=True 参数,或者轮询 client.get_collection(collection).status 直到 optimizers_statusok。官方文档对 wait 参数的说明在 API 参考里,很容易被忽略。

坑 2:用 score_threshold 过滤时,阈值设置错误导致测试误判。 现象:测试断言 result.points[0].score > 0.7 一直失败,即使返回了正确向量。原因:Qdrant 使用余弦距离时返回的 score 是余弦相似度,范围是 -1, 1,不是距离,所以越小越不相似。我在另一个测试里用了欧氏距离的阈值 0.5,结果全部不通过。解决:明确距离度量对应的 score 语义------余弦相似度越大越好,欧氏距离越小越好;测试断言要和实际语义一致,不要混用。

效果验证

修复前,召回一致性测试套件在 CI 上 10 次运行 6 次失败,平均排查时间 20 分钟/次;修复后,100 次运行全部通过,回归测试从手动 10 分钟缩短到自动化 8 秒。

指标 修复前 修复后
测试通过率 40% 100%
单次回归耗时 10 分钟(人工) 8 秒(自动化)
线上召回率抖动 频繁

可直接用的代码/工具

把下面这个 safe_upsert 函数复制到你的 Qdrant 工具类里,所有写入走这个函数,告别召回不一致:

python 复制代码
def safe_upsert(client, collection, points):
    client.upsert(collection_name=collection, points=points, wait=True)
    # 双保险:轮询索引状态
    while True:
        status = client.get_collection(collection).status
        if status.optimizers_status == "ok":
            break
        time.sleep(0.1)

#Python #后端 #向量数据库 #AI工程 #测试自动化

关于作者

一个实战派后端/架构方向的开发者,专注 AI Agent 基础设施和向量数据库落地。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮到你,请我喝杯咖啡

提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

相关推荐
Highcharts.js1 小时前
Highcharts 主流前端框架无缝集成指南
前端·vue.js·前端框架·highcharts·可视化图表
JAVA面经实录9171 小时前
网络编程基础(Java Web/分布式前置·完整版)(十一)
java·前端·网络
IT_陈寒1 小时前
搞不定JavaScript的数组去重?你可能漏了这两个坑
前端·人工智能·后端
三8441 小时前
CSRF跨站请求伪造基础
前端·csrf
岁岁种桃花儿2 小时前
Vue核心语法第十一篇:绑定样式
前端·javascript·vue.js
程序员黑豆9 小时前
Java类型推断完全指南:从var到菱形运算符,掌握使用限制与最佳实践
java·前端·ai编程
To_OC10 小时前
踩了个 TS 的坑之后,我终于把 type 和 interface 掰明白了
前端·react.js·typescript
GreenTea10 小时前
深度解读 Anthropic 多智能体报告:更强的模型 ≠ 更好的协调
前端·后端·算法