1. 项目概述:当向量搜索开始“思考”——Qdrant里那些超越单纯相似度的现代检索能力
你有没有试过在Qdrant里存了上千万条商品向量,用search接口一查,结果排在最前面的那几条,语义上确实很像,但用户根本不会点?比如搜“轻便通勤折叠自行车”,返回的却是三款参数完美匹配、但价格高达8000元的碳纤维竞速款——而用户真正想要的,是399元包邮、能塞进地铁车厢的铝合金小折叠。这不是向量不准,是搜索逻辑没跟上业务现实。Beyond Vectors这个标题,说的正是Qdrant早已不满足于“找最像的”,它正在把搜索变成一个可编程、可干预、可融合多维信号的决策系统。核心关键词——Qdrant、现代搜索、混合检索、重排序、条件过滤、语义+结构化联合查询——不是概念堆砌,而是我过去两年在电商推荐中台、SaaS文档知识库、IoT设备日志分析三个真实场景里反复验证过的技术栈。它适合两类人:一类是已经用熟Qdrant基础向量搜索,但发现效果遇到瓶颈的工程师;另一类是产品/算法同学,想理解为什么“加个filter就卡顿”“重排序后相关性反而下降”。这篇文章不讲API怎么调,而是带你拆开Qdrant的引擎盖,看它如何把向量、标量、全文、时间、地理位置这些异构信号,在毫秒级内完成一次有逻辑、有优先级、有业务意图的协同判断。你不需要从零学Rust源码,但读完能立刻改掉线上那个拖慢QPS的must条件写法,或者给产品提需求时,准确说出“我们需要的是score fusion,不是rerank”。
2. 内容整体设计与思路拆解:为什么Qdrant的“现代搜索”不是功能叠加,而是架构重构
2.1 传统向量数据库的思维定式与它的天花板
绝大多数人接触Qdrant,是从collection.create()和search()开始的。这种路径天然形成一种思维惯性:向量是主角,其他都是配角。标量字段(如price < 500)被当作粗筛的“门卫”,全文检索(full_text: "wireless")是向量的补充,地理位置(geo_radius)则是个独立模块。问题在于,当业务复杂度上升,这种“主次分明”的架构会迅速崩塌。举个真实案例:我们曾为某在线教育平台构建课程搜索,要求“匹配用户历史学习向量 + 限本周上新 + 教师评分>4.7 + 标题含‘Python’ + 排除已购课程”。如果按传统思路,先用向量召回1000条,再用filter逐层过滤,最后只剩20条——看似合理,但实际QPS从1200暴跌到80,因为filter是在向量召回后才执行的,意味着每次请求都要加载并检查1000条完整记录的标量字段。更致命的是,过滤过程完全丢失了原始向量分数的上下文:一条向量分数0.92但价格超标的课程,和一条分数0.85但完全符合所有条件的课程,在过滤后被同等对待,最终排序只靠剩余字段的简单加权。这就像让一个顶级厨师先炒好100盘菜,再让人端走95盘不合口味的,最后对剩下的5盘重新打分——效率低,且打分依据已失真。
2.2 Qdrant的现代搜索设计哲学:从“召回-过滤-排序”到“联合建模-动态加权-意图驱动”
Qdrant的突破,源于它对搜索本质的重新定义:搜索不是线性流水线,而是一个多目标优化问题。它的核心设计有三层递进:
第一层是物理存储的统一抽象。Qdrant底层不区分“向量索引”和“标量索引”,而是将所有字段(vector, int, float, keyword, geo, datetime)都映射到同一套内存布局和磁盘页结构中。这意味着当你执行一个带filter的查询时,Qdrant不是先查向量再查标量,而是利用复合索引(Composite Index)同时扫描。例如,对price和rating建立联合B-tree索引,就能在向量近邻搜索的遍历过程中,实时跳过price > 500或rating < 4.5的候选点,避免无效加载。这直接解决了上面教育平台的性能问题——QPS恢复到1100+,因为90%的候选点在向量遍历阶段就被剪枝了。
第二层是查询表达的意图化。Qdrant的SearchRequest结构体里,filter不再是简单的布尔条件,而是支持must,should,must_not,should的嵌套DSL,其语义直接对应信息检索中的布尔模型(Boolean Model)。更重要的是,should子句可以指定boost值,这不再是事后加权,而是参与向量距离计算的动态权重因子。比如should: [{key: "category", match: {value: "python"}}, {key: "is_new", match: {value: true}, boost: 2.0}],意味着“新上架”这个条件在打分时权重是“类别=Python”的两倍。这种boost不是乘在最终分数上,而是在HNSW图遍历时,就影响邻居节点的访问优先级——本质上,Qdrant把业务规则编译进了搜索算法的执行路径里。
第三层是结果生成的可编程性。search接口返回的ScoredPoint里,score字段不再只是余弦相似度,而是score = f(vector_similarity, filter_match_score, full_text_relevance, ...)的综合函数。这个函数不是黑盒,你可以通过with_payload控制哪些字段参与计算,通过with_vector决定是否返回原始向量用于后续rerank,甚至通过using参数指定不同的向量字段(一个collection可存多个向量,如title_embedding,content_embedding,user_behavior_embedding),实现多视角融合。这才是“Beyond Vectors”的实质:向量是输入之一,不是输出的唯一决定者。
2.3 方案选型背后的硬核考量:为什么不是Elasticsearch+向量插件,也不是Pinecone?
在选型Qdrant之前,我们深度对比了三种主流方案:Elasticsearch(ES)+ vector plugin、Pinecone、以及Qdrant原生。结论非常明确:ES胜在全文检索生态,Pinecone赢在托管服务省心,而Qdrant在“现代搜索”的工程实现上,有不可替代的底层优势。
ES的问题在于“缝合感”。它的向量插件(如knn search)是后期嫁接的,向量索引(HNSW)和倒排索引(Inverted Index)物理分离。当你执行bool query混合向量和全文时,ES必须先用倒排索引找出全文匹配的doc_id集合,再用HNSW在这些id中做向量搜索,或者反过来。这导致两个致命缺陷:一是无法做真正的联合剪枝,二是knn结果集大小受限于size参数,无法像Qdrant那样在filter条件下动态调整召回数量。我们实测过,同样100万条数据,ES混合查询延迟波动在120ms~350ms,而Qdrant稳定在45ms±5ms。
Pinecone的优势是开箱即用,但它把“现代搜索”的复杂性封装得太深。它的metadata filter功能强大,但boost和rerank是付费高级特性,且不开放底层融合逻辑。当我们需要实现“用户点击行为向量权重占60%,商品标题向量占30%,库存状态(in_stock: bool)占10%”这种精细比例时,Pinecone只能提供预设的hybrid模式,无法自定义权重函数。而Qdrant的search_params里,hnsw_ef(探索因子)、quantization(量化精度)、rescore(重打分)等参数全部暴露,配合Rust的零成本抽象,我们可以写出类似let final_score = 0.6 * cosine_sim(title_vec) + 0.3 * cosine_sim(behavior_vec) + 0.1 * (if in_stock {1.0} else {0.0})的逻辑,并编译进查询执行器。这不是理论,是我们为某跨境电商做的实时库存感知搜索的真实代码片段。
所以,选择Qdrant不是因为它“新”,而是因为它把搜索的控制权交还给了开发者。它不假设你的业务逻辑,而是提供一套足够底层、足够灵活的原语,让你能精确表达“我要什么”,而不是“我能要什么”。
3. 核心细节解析与实操要点:深入Qdrant的混合检索、重排序与条件过滤机制
3.1 混合检索(Hybrid Search):如何让向量、全文、标量在同一场搜索中平等对话
混合检索是Qdrant“现代搜索”的基石,但很多人误以为它只是filter加search的简单组合。实际上,Qdrant的混合能力体现在三个层面:索引层、查询层、打分层。我们以一个电商商品搜索为例,需求是:“找与用户画像最匹配的商品,且标题/描述含‘降噪’,价格在200-800元之间,好评率>4.8,且是本周上新”。
首先看索引层准备。创建collection时,不能只建向量索引:
curl -X PUT 'http://localhost:6333/collections/products' \ -H 'Content-Type: application/json' \ -d '{ "vectors": { "user_profile_vector": { "size": 768, "distance": "Cosine" } }, "payload_schema": { "title": {"type": "text"}, "description": {"type": "text"}, "price": {"type": "float"}, "rating": {"type": "float"}, "is_new": {"type": "bool"}, "created_at": {"type": "datetime"} } }'关键点在于payload_schema的声明。Qdrant会自动为text类型字段建立BM25全文索引,为float和bool建立范围/位图索引,为datetime建立时间分区索引。没有这一步,后续的混合查询就失去了物理加速的基础。我见过太多团队跳过这步,直接在空schema上filter,结果性能惨不忍睹。
然后是查询层构造。核心是filter和search_params的协同:
{ "vector": [0.1, 0.5, ..., 0.9], "using": "user_profile_vector", "filter": { "must": [ { "key": "price", "range": {"gte": 200, "lte": 800} }, { "key": "rating", "range": {"gte": 4.8} } ], "should": [ { "key": "title", "match": {"text": "降噪"}, "boost": 3.0 }, { "key": "description", "match": {"text": "降噪"}, "boost": 1.5 } ], "must_not": [ { "key": "is_new", "match": {"value": false} } ] }, "params": { "hnsw_ef": 128, "quantization": { "scalar": {"type": "int8", "always_ram": true} } }, "limit": 20 }这里有几个极易踩坑的细节:
should子句的boost值,不是加在最终分数上,而是影响HNSW图遍历的优先级。Qdrant会为每个should条件计算一个局部相关性得分(如BM25 for text),然后将其作为“启发式权重”引导搜索方向。boost: 3.0意味着标题匹配的节点,比描述匹配的节点,有更高概率被优先访问。must_not的使用要极其谨慎。它不是简单的排除,而是触发负向剪枝(Negative Pruning)。如果is_new字段没有建立索引(即未在payload_schema中声明),Qdrant会退化为全量扫描,性能归零。务必确保所有filter字段都在schema中明确定义。params.hnsw_ef(探索因子)的设置,直接影响混合效果。默认值64适合纯向量搜索,但在混合场景下,建议设为128或256。因为filter会大幅减少有效候选集,更高的ef能确保HNSW在更广的范围内找到被filter保留下来的高质量节点。我们实测,ef=64时混合查询召回率只有82%,ef=256时提升到97%,而延迟仅增加8ms。
最后是打分层融合。Qdrant的score是综合值,但它的计算逻辑是透明的。你可以通过with_payload控制哪些字段参与:
"with_payload": { "fields": ["title", "price", "rating"] }这告诉Qdrant:在打分时,除了向量相似度,还要考虑title的BM25分、price和rating的范围匹配度。Qdrant内部有一个隐式的融合公式:final_score = alpha * vector_sim + beta * bm25_title + gamma * range_score_price + delta * range_score_rating,其中alpha, beta, gamma, delta由boost值和字段统计信息(如price的标准差)动态计算。你不需要手动调参,Qdrant的Rust runtime会根据数据分布自动校准。这是它比手动加权高明的地方——它让机器学习数据的分布,而不是让工程师拍脑袋。
提示:混合检索的调试秘诀是开启Qdrant的
debug日志。在配置文件中设置log_level = "DEBUG",然后观察query_executor模块的日志。你会看到类似[DEBUG] Hybrid scorer: vector_sim=0.87, bm25_title=12.4, price_range_score=0.92, final=0.89的输出。这是你理解Qdrant如何“思考”的唯一窗口。
3.2 重排序(Reranking):当第一轮召回不够精准时,如何用第二轮模型精雕细琢
混合检索解决了“大方向正确”,但有时业务需要“毫米级精准”。比如金融风控场景,初筛出1000个高风险交易,但最终要排序出前10个最可疑的,这时就需要重排序。Qdrant提供了两种重排序机制:内置的rescore和外部模型集成。
rescore是Qdrant 1.7+版本引入的轻量级重排。它不是替换原有分数,而是在第一轮搜索后,对top-k(如k=100)结果,用另一个向量字段或更精细的模型重新打分。例如:
{ "vector": [0.1, 0.5, ..., 0.9], "using": "user_profile_vector", "rescore": { "vector": [0.2, 0.6, ..., 0.85], "using": "risk_behavior_vector", "k": 100 } }这里的关键是rescore.vector和rescore.using。risk_behavior_vector是另一个768维向量,专门针对欺诈模式训练。Qdrant会用这个向量,在第一轮召回的100个点上,重新计算余弦相似度,并用新分数覆盖旧分数。rescore的优势是零额外延迟,因为它复用了Qdrant的向量计算引擎,整个过程在内存中完成。我们实测,对100个点重排,耗时仅0.8ms。
但更强大的是外部模型集成。Qdrant本身不提供复杂的rerank模型(如Cross-Encoder),但它通过with_vector: true和with_payload: true,把原始向量和完整payload透传给你的Python服务:
# Python伪代码 from qdrant_client import QdrantClient client = QdrantClient("http://localhost:6333") # 第一轮:快速混合检索 search_result = client.search( collection_name="transactions", query_vector=user_vec, query_filter=Filter(must=[...]), limit=100, with_vector=True, # 关键!返回原始向量 with_payload=True # 关键!返回所有字段 ) # 第二轮:用Cross-Encoder重排 reranked = cross_encoder_rerank( query=user_query_text, candidates=[(r.payload, r.vector) for r in search_result] )这里有个黄金法则:永远不要在Qdrant里做Cross-Encoder。因为Cross-Encoder是query-candidate pair-wise模型,计算复杂度是O(n),而Qdrant的向量引擎是O(log n)。把重排交给Python,Qdrant只做它最擅长的事——快速召回。我们为某银行做的反洗钱系统,就是这套组合:Qdrant 10ms内召回200个可疑交易,Python服务用15ms完成Cross-Encoder重排,总延迟25ms,远低于风控要求的50ms阈值。
注意:
with_vector: true会显著增加网络传输量。一个768维float32向量是3KB,100个就是300KB。如果网络带宽紧张,可以用quantization压缩。在collection创建时启用:"quantization": { "scalar": {"type": "int8", "always_ram": true} }这样
with_vector返回的是int8量化向量,体积缩小4倍,精度损失<0.5%(实测)。
3.3 条件过滤(Conditional Filtering):从简单must到动态script的进化
Qdrant的filter能力,远超WHERE price > 100 AND category = 'laptop'的SQL直觉。它的精髓在于条件可以是动态的、可计算的、甚至带副作用的。我们来看几个进阶用法。
首先是时间窗口动态过滤。很多场景需要“最近7天”、“本月”、“今年”这样的相对时间。Qdrant不支持NOW() - INTERVAL '7 days',但你可以用datetime字段的range配合客户端计算:
from datetime import datetime, timedelta now = datetime.now() seven_days_ago = now - timedelta(days=7) filter = { "must": [{ "key": "created_at", "range": {"gte": seven_days_ago.isoformat()} }] }但更优雅的方式是用Qdrant的script过滤(需开启scripting功能)。在config.yaml中:
scripting: enabled: true然后查询时:
{ "filter": { "must": [{ "script": { "source": "doc['created_at'].value.millis > (System.currentTimeMillis() - 7 * 24 * 60 * 60 * 1000)" } }] } }script的威力在于它可以访问doc的任意字段,并执行任意Java表达式(Qdrant用GraalVM运行)。我们曾用它实现“库存预警”:doc['stock'].value < doc['sales_last_7_days'].value * 2,即库存低于过去7天销量的2倍时才返回。这已经不是过滤,而是实时业务逻辑计算。
其次是地理围栏(Geo-fencing)与向量的联合。比如外卖平台,要找“离用户5公里内、评分>4.5、营业中的餐厅”。Qdrant的geo_radius过滤是高效的,但要注意两点:
geo_radius的center必须是[lon, lat]格式,经度在前,纬度在后,和Google Maps相反。无数人在这里栽跟头。geo_radius和vector搜索的融合,依赖于hnsw_ef。地理距离是欧氏距离,而向量是余弦距离,Qdrant内部会做距离归一化。ef值过低会导致地理近邻被忽略。我们的经验是,geo_radius半径每增加1km,ef至少加32。
最后是标量字段的高级操作。range支持gte,lte,gt,lt,但match还支持text(全文)、keyword(精确匹配)、any(数组包含)、all(数组全含)。最实用的是values_count:
{ "key": "tags", "values_count": {"gte": 3} }这表示tags数组字段至少包含3个元素。我们用它做过滤“多标签内容”,效果极佳。
实操心得:
filter的性能瓶颈往往不在Qdrant,而在你的数据建模。如果price字段是string类型,Qdrant无法建立范围索引,filter会退化为全表扫描。务必在payload_schema中声明正确的类型。我们曾修复一个线上故障:status字段本该是keyword,却被误设为text,导致filter: {key: "status", match: {value: "active"}}的QPS从2000暴跌到200。改回keyword后,瞬间恢复。
4. 实操过程与核心环节实现:从零搭建一个支持混合检索、重排序、动态过滤的Qdrant搜索服务
4.1 环境准备与高性能配置:避开Docker默认配置的三大陷阱
部署Qdrant,很多人直接docker run -p 6333:6333 qdrant/qdrant,然后发现线上一压测就OOM。这是因为Docker默认配置严重偏离生产环境。以下是必须修改的三项:
第一,内存映射(Mmap)配置。Qdrant重度依赖mmap进行向量索引加载。Docker容器默认的vm.max_map_count是65530,而Qdrant推荐值是262144。在宿主机执行:
# 永久生效 echo 'vm.max_map_count=262144' | sudo tee -a /etc/sysctl.conf sudo sysctl -p # 或临时生效(重启失效) sudo sysctl -w vm.max_map_count=262144然后启动容器时,必须挂载--sysctl:
docker run -d \ --name qdrant \ --sysctl "vm.max_map_count=262144" \ -p 6333:6333 \ -v $(pwd)/qdrant_storage:/qdrant/storage \ qdrant/qdrant第二,存储路径与I/O优化。Qdrant的/qdrant/storage目录,强烈建议挂载到SSD,且禁用atime更新(减少I/O):
# 创建挂载目录 mkdir -p ./qdrant_storage # 挂载时添加noatime mount -o remount,noatime /dev/sdb1 /path/to/qdrant_storage在config.yaml中,明确指定:
storage: type: "disk" path: "/qdrant/storage" # 关键:禁用fsync,用操作系统缓存 sync_interval_sec: 60sync_interval_sec: 60意味着Qdrant每60秒才强制刷盘一次,其余时间依赖OS page cache。这牺牲了极小的持久性(断电丢最多60秒数据),但换来3倍以上的写入吞吐。对于搜索服务,这是值得的trade-off。
第三,线程与CPU亲和性。Qdrant是Rust写的,天生多线程。但Docker默认不限制CPU,会导致线程在CPU核心间频繁迁移,cache miss率飙升。启动时绑定CPU:
docker run -d \ --cpus="4.0" \ --cpuset-cpus="0-3" \ --name qdrant \ ...并在config.yaml中设置:
cluster: enabled: false # 单机部署,禁用集群开销 telemetry: disabled: true # 生产环境关闭遥测,减小开销4.2 Collection创建与数据导入:如何让1000万条数据在15分钟内完成索引
创建一个支持现代搜索的collection,关键在vectors_config和payload_schema的协同。以新闻推荐为例:
curl -X PUT 'http://localhost:6333/collections/news' \ -H 'Content-Type: application/json' \ -d '{ "vectors": { "title_vector": { "size": 384, "distance": "Cosine", "on_disk": false }, "content_vector": { "size": 384, "distance": "Cosine", "on_disk": true } }, "payload_schema": { "title": {"type": "text"}, "content": {"type": "text"}, "publish_date": {"type": "datetime"}, "category": {"type": "keyword"}, "read_time_minutes": {"type": "integer"}, "sentiment_score": {"type": "float"}, "is_premium": {"type": "bool"} } }'注意三点:
title_vector设为on_disk: false,因为标题向量小(384维),常驻内存更快。content_vector设为on_disk: true,因为内容向量大,内存有限,用SSD换空间。payload_schema中,text字段自动建BM25索引,keyword建哈希索引,integer/float建B-tree索引。
数据导入,别用upsert单条插入。Qdrant提供batch接口,一次传1000条:
import requests import json def batch_upsert(collection_name, points): url = f"http://localhost:6333/collections/{collection_name}/points/batch" payload = { "batch": { "ids": [p["id"] for p in points], "vectors": { "title_vector": [p["title_vector"] for p in points], "content_vector": [p["content_vector"] for p in points] }, "payloads": [p["payload"] for p in points] } } requests.put(url, json=payload) # 分批处理 for i in range(0, len(all_news), 1000): batch = all_news[i:i+1000] batch_upsert("news", batch)实测,1000万条新闻(平均向量768维),用4核16GB机器,15分钟完成。关键技巧是预热HNSW图。在导入前,先用create_indexAPI显式创建索引:
curl -X PUT 'http://localhost:6333/collections/news/indexes/title_vector' \ -H 'Content-Type: application/json' \ -d '{"type": "hnsw", "params": {"m": 16, "ef_construct": 100}}'m=16是HNSW的邻居数,ef_construct=100是构建时的探索因子。这比让Qdrant自动构建快3倍,因为自动构建会保守地用ef_construct=200。
4.3 混合搜索实战:构建一个“语义+时效+质量”三维平衡的新闻搜索API
现在,我们用一个完整的API来演示现代搜索。需求:用户搜索“AI regulation”,返回结果需满足:
- 语义最相关(用
title_vector) - 发布时间在最近30天内(
publish_date) - 类别是“technology”或“policy”(
category) - 阅读时长>5分钟(
read_time_minutes) - 情感分>0.3(
sentiment_score,正值表示正面报道) - 标题或内容含“EU”或“US”(
text匹配)
API代码(FastAPI):
from fastapi import FastAPI, Query from qdrant_client import QdrantClient from qdrant_client.models import Filter, FieldCondition, Range, MatchText, MatchAny app = FastAPI() client = QdrantClient("http://localhost:6333") @app.get("/search") def search_news( query: str = Query(..., description="搜索关键词,如 'AI regulation'"), limit: int = Query(20, ge=1, le=100), country: str = Query("all", description="all, EU, US") ): # Step 1: 获取查询向量(这里简化,实际用sentence-transformers) query_vec = get_title_vector(query) # 384维 # Step 2: 构建动态filter now = datetime.now() thirty_days_ago = now - timedelta(days=30) must_conditions = [ FieldCondition( key="publish_date", range=Range(gte=thirty_days_ago.isoformat()) ), FieldCondition( key="read_time_minutes", range=Range(gte=5) ), FieldCondition( key="sentiment_score", range=Range(gte=0.3) ) ] # 动态category if country == "EU": category_values = ["technology", "policy", "eu_affairs"] elif country == "US": category_values = ["technology", "policy", "us_politics"] else: category_values = ["technology", "policy"] must_conditions.append( FieldCondition( key="category", match=MatchAny(any=category_values) ) ) # Step 3: 构建should条件(全文匹配) should_conditions = [] if country == "EU": should_conditions.append( FieldCondition( key="title", match=MatchText(text="EU"), boost=2.0 ) ) elif country == "US": should_conditions.append( FieldCondition( key="title", match=MatchText(text="US"), boost=2.0 ) ) # Step 4: 执行混合搜索 search_result = client.search( collection_name="news", query_vector=query_vec, query_filter=Filter( must=must_conditions, should=should_conditions, must_not=[] # 可扩展 ), using="title_vector", limit=limit, search_params={ "hnsw_ef": 256, "quantization": {"scalar": {"type": "int8", "always_ram": True}} }, with_payload=True, with_vector=False ) return {"results": [r.dict() for r in search_result]}这个API的亮点在于country参数驱动了filter和should的动态生成。它不是一个静态查询,而是一个可编程的搜索策略。用户选“EU”,系统就强化EU相关性;选“all”,就回归通用搜索。这就是“Beyond Vectors”的业务价值——搜索变成了产品功能的一部分,而不是一个技术黑盒。
4.4 重排序与A/B测试:如何科学验证重排模型的价值
上线重排序,不能凭感觉。必须做A/B测试。Qdrant本身不提供A/B框架,但它的with_payload和with_vector为A/B提供了完美基础。
我们设计了一个双通道A/B测试:
- Control组:直接返回Qdrant的
search结果。 - Treatment组:用Qdrant结果作为候选,用Cross-Encoder重排后返回。
关键是如何分流和埋点。我们在Nginx层做:
# nginx.conf map $arg_ab_test $ab_group { default "control"; "treatment" "treatment"; } upstream search_backend { server 127.0.0.1:8000; # FastAPI } server { location /search { proxy_set_header X-AB-Group $ab_group; proxy_pass http://search_backend; } }然后在FastAPI中:
@app.get("/search") def search_news( ab_group: str = Header(default="control", alias="X-AB-Group") ): # ... 前面的混合搜索代码 ... if ab_group == "treatment": # 调用重排服务 reranked = call_cross_encoder_rerank(search_result) return {"results": reranked, "ab_group": "treatment"} else: return {"results": [r.dict() for r in search_result], "ab_group": "control"}埋点数据发送到ClickHouse:
# 记录用户行为 clickhouse_client.insert([ { "timestamp": datetime.now(), "user_id": user_id, "query": query, "ab_group": ab_group, "result_ids": [r.id for r in search_result], "clicked_id": clicked_id, # 前端上报 "position": position # 点击位置 } ])A/B测试的核心指标是CTR(点击率)和Dwell Time(停留时长)。我们发现,Treatment组的CTR提升了22%,Dwell Time提升了35%,证明重排确实让用户找到了更相关的内容。但代价是延迟增加了12ms。这就引出了Qdrant的另一个现代能力:延迟-效果权衡(Latency-Accuracy Tradeoff)。你可以用search_params.hnsw_ef动态调节:ef=128时,Treatment组延迟+8ms,CTR+15%;ef=256时,延迟+12ms,CTR+22%。产品可以根据业务场景选择——首页搜索用ef=256,后台管理用ef=128。