用Spring AI+Qdrant实现混合检索服务:Dense、Sparse、RRF与权限过滤
文章摘要
单一Dense向量检索擅长语义理解,却容易漏掉产品型号、合同编号和专业缩写;纯关键词检索能够精确命中术语,却难以理解自然语言。本文使用Spring Boot、Spring AI和Qdrant设计一个可复用的企业混合检索服务,完成Dense与Sparse双路召回、租户权限过滤、RRF融合、去重、可观测日志和统一响应结构,并给出后续接入Cross-Encoder重排的扩展点。
一、我们要实现什么
目标调用:
HybridSearchResponse response =
hybridSearchService.search(
principal,
"五码身份如何支持白酒渠道控盘?",
10
);
内部链路:
用户问题
→ 构造权限过滤
├─ Dense向量召回Top50
└─ Sparse关键词召回Top50
→ RRF融合
→ 去重
→ 返回Top10
本篇重点是检索层,不包含最终大模型生成。
二、项目依赖
pom.xml示意:
org.springframework.ai
spring-ai-bom
2.0.0
pom
import
org.springframework.boot
spring-boot-starter-web
org.springframework.ai
spring-ai-starter-model-deepseek
org.springframework.ai
spring-ai-starter-vector-store-qdrant
io.qdrant
client
具体版本和Starter名称应以当前Spring AI与Qdrant Java SDK文档为准。
三、为什么需要直接使用Qdrant Client
Spring AI VectorStore提供统一相似度检索接口,适合普通Dense RAG。
混合检索需要:
- Named Dense Vector;
- Sparse Vector;
- Prefetch;
- RRF或DBSF Fusion;
- Formula Query;
- 多阶段查询。
这些数据库特有能力不一定全部通过统一VectorStore接口暴露。
因此可以采用:
Spring AI管理Embedding和上层RAG
Qdrant Client实现高级检索
业务层仍然通过自己的统一接口隔离数据库细节。
四、定义检索主体
public record RetrievalPrincipal(
String userId,
String tenantId,
Set departmentIds,
int securityLevel,
String permissionVersion
) {
}
五、定义统一检索结果
public record HybridSearchHit(
String documentId,
String chunkId,
String content,
double score,
int rank,
Set matchedRetrievers,
Map metadata
) {
}
响应:
public record HybridSearchResponse(
String requestId,
String query,
List hits,
long durationMs
) {
}
六、定义检索接口
public interface HybridSearchService {
HybridSearchResponse search(
RetrievalPrincipal principal,
String query,
int limit
);
}
七、Dense Embedding接口
业务层不要直接依赖具体Provider。
public interface QueryEmbeddingService {
float[] embed(String query);
}
Spring AI实现:
@Service
public class SpringAiQueryEmbeddingService
implements QueryEmbeddingService {
private final EmbeddingModel embeddingModel;
public SpringAiQueryEmbeddingService(
EmbeddingModel embeddingModel
) {
this.embeddingModel = embeddingModel;
}
@Override
public float[] embed(String query) {
EmbeddingResponse response =
embeddingModel.embedForResponse(
List.of(query)
);
return response.getResults()
.getFirst()
.getOutput();
}
}
不同Spring AI小版本的返回类型可能略有差异,应以当前API为准。
八、Sparse查询向量
Sparse向量可以来自:
- BM25服务;
- SPLADE;
- Qdrant Inference;
- 自建关键词模型;
- 第三方Sparse Embedding API。
统一接口:
public record SparseVector(
List indices,
List values
) {
}
public interface SparseEmbeddingService {
SparseVector embed(String query);
}
如果暂时没有Sparse模型,也可以先使用外部全文检索引擎,随后在应用层RRF融合。
九、构造权限过滤器
@Component
public class QdrantPermissionFilterFactory {
public Filter create(
RetrievalPrincipal principal
) {
// 伪代码,具体Builder以Qdrant Java SDK为准
return Filter.newBuilder()
.addMust(match(
"tenant_id",
principal.tenantId()
))
.addMust(rangeLessOrEqual(
"security_level",
principal.securityLevel()
))
.addMust(match(
"status",
"EFFECTIVE"
))
.addShould(matchAny(
"department_ids",
principal.departmentIds()
))
.addShould(match(
"visibility",
"PUBLIC_TENANT"
))
.build();
}
}
生产实现必须正确处理:
must
should
must_not
minimum_should_match
null字段
空部门集合
十、双路检索服务
@Service
public class QdrantHybridSearchService
implements HybridSearchService {
private final QdrantClient qdrantClient;
private final QueryEmbeddingService denseService;
private final SparseEmbeddingService sparseService;
private final QdrantPermissionFilterFactory filterFactory;
public QdrantHybridSearchService(
QdrantClient qdrantClient,
QueryEmbeddingService denseService,
SparseEmbeddingService sparseService,
QdrantPermissionFilterFactory filterFactory
) {
this.qdrantClient = qdrantClient;
this.denseService = denseService;
this.sparseService = sparseService;
this.filterFactory = filterFactory;
}
@Override
public HybridSearchResponse search(
RetrievalPrincipal principal,
String query,
int limit
) {
long start = System.nanoTime();
String requestId = UUID.randomUUID()
.toString();
float[] dense = denseService.embed(query);
SparseVector sparse =
sparseService.embed(query);
Filter filter =
filterFactory.create(principal);
List denseHits =
searchDense(dense, filter, 50);
List sparseHits =
searchSparse(sparse, filter, 50);
List fused =
rrfFuse(
denseHits,
sparseHits,
limit
);
long durationMs =
(System.nanoTime() - start)
/ 1_000_000;
return new HybridSearchResponse(
requestId,
query,
fused,
durationMs
);
}
}
Qdrant本身支持Universal Query和服务端融合。如果Java SDK已经暴露对应能力,应优先在数据库端完成双路Prefetch和Fusion,减少网络往返。
十一、RawSearchHit
public record RawSearchHit(
String pointId,
String documentId,
String chunkId,
String content,
double rawScore,
int rank,
String retriever,
Map metadata
) {
}
十二、RRF融合实现
private List rrfFuse(
List denseHits,
List sparseHits,
int limit
) {
int k = 60;
Map merged =
new HashMap();
addRrfScores(merged, denseHits, k);
addRrfScores(merged, sparseHits, k);
return merged.values().stream()
.sorted(
Comparator.comparingDouble(
MutableFusionHit::score
).reversed()
)
.limit(limit)
.map(MutableFusionHit::toResult)
.toList();
}
累积分数:
private void addRrfScores(
Map merged,
List hits,
int k
) {
for (RawSearchHit hit : hits) {
double score =
1.0 / (k + hit.rank());
merged.compute(
hit.chunkId(),
(key, current) -> {
MutableFusionHit target =
current == null
? MutableFusionHit.from(hit)
: current;
target.addScore(score);
target.addRetriever(
hit.retriever()
);
return target;
}
);
}
}
十三、为什么按chunk_id去重
Dense和Sparse可能返回同一个Chunk。
如果不去重:
同一段内容进入上下文两次
→ 浪费Token
→ 放大某个证据权重
→ 结果缺乏多样性
去重Key可以是:
chunk_id
但还要处理近重复Chunk,例如同一段出现在不同版本文档中。
可以进一步使用:
content_hash
source_version
十四、服务端RRF更推荐
如果数据库支持原生Fusion:
Dense Prefetch Top50
Sparse Prefetch Top50
→ RRF Query
→ Top10
优势:
- 一次网络请求;
- 数据库内部执行;
- 少传输候选结果;
- 更容易扩展多阶段查询;
- 统一监控。
应用层RRF适合:
- 组合不同搜索系统;
- 数据库能力不一致;
- 需要自定义逻辑;
- 快速验证。
十五、接入Cross-Encoder
定义:
public interface Reranker {
List rerank(
String query,
List candidates,
int limit
);
}
调用:
RRF Top30
→ Reranker Top10
不要把50或100个长Chunk全部发给第三方API,先控制长度和候选数。
十六、查询类型动态路由
精确编号:
HT-2026-001
SKU-A892
Sparse更重要。
概念问题:
渠道费用为什么无法形成闭环?
Dense更重要。
可以分类:
public enum QueryType {
EXACT,
SEMANTIC,
MIXED
}
策略:
EXACT:Sparse Top80+Dense Top20
SEMANTIC:Dense Top80+Sparse Top20
MIXED:两路Top50
如果使用RRF,不同路权重可通过Weighted RRF或候选数量间接调整。
十七、可观测性
记录:
request_id
query_type
tenant_id
dense_duration_ms
sparse_duration_ms
fusion_duration_ms
dense_candidate_count
sparse_candidate_count
overlap_count
final_count
permission_version
不要在普通日志中记录完整文档正文。
十八、错误处理
Dense失败
可降级到Sparse,但要标记:
retrieval_mode = SPARSE_ONLY
Sparse失败
可降级到Dense。
权限服务失败
必须Fail Closed,不能取消过滤继续搜索。
两路都失败
返回稳定错误码:
RETRIEVAL_UNAVAILABLE
十九、测试数据
至少包含:
- 产品型号;
- 合同编号;
- 中文同义词;
- 缩写;
- 错别字;
- 新旧版本;
- 跨租户同名文档;
- 表格Chunk;
- 长问题。
评测:
Dense Recall@10
Sparse Recall@10
Hybrid Recall@10
MRR
nDCG
P95延迟
二十、生产优化
- Dense和Sparse并行执行;
- Embedding查询缓存;
- 限制最大查询长度;
- 批量Embedding;
- 服务端Fusion;
- Payload字段索引;
- 候选数量动态调整;
- Reranker批处理;
- 超时和熔断;
- 权限过滤自动化测试。
总结
混合检索服务的核心链路是:
可信权限上下文
→ Dense与Sparse召回
→ RRF融合
→ 去重
→ 可选重排
→ 统一结果
Spring AI负责模型与RAG上层集成,Qdrant高级查询能力负责检索,业务层再通过统一接口隔离底层实现。这样既能获得混合检索质量,也能保留后续更换数据库和重排器的空间。
延伸阅读
如果你正在关注企业级 AI 应用、RAG、Agent、MCP 与大模型工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。