用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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。