向量化、召回与重排序服务的实现

摘要

知识库问答的质量不只取决于大模型,向量化、召回和重排序决定了模型最终能看到哪些证据。如果召回阶段漏掉正确文档,后续 Prompt 写得再好也无法生成可靠答案;如果召回结果包含大量噪声,模型又会被无关内容干扰。

本文继续"企业智能客服与知识库助手"项目,设计向量化、召回和重排序服务,覆盖:

  • 文档 Chunk 如何批量向量化;
  • Embedding Profile 和索引代次如何管理;
  • 向量召回、关键词召回和混合召回;
  • Metadata、租户和权限过滤;
  • Reranker 的作用和调用链;
  • 超时、批量、缓存、重试和降级;
  • Recall@K、MRR、NDCG 等评估指标;
  • 如何将检索链路拆成可观测、可替换的服务。

一、背景与问题

1. 召回错了,生成就没有依据

RAG 链路可以表示为:

text 复制代码
用户问题
    ↓
查询向量
    ↓
候选文档召回
    ↓
重排序
    ↓
上下文组装
    ↓
模型回答

如果正确文档没有进入候选集合,重排序和生成都无法补救。

2. 只使用向量召回的局限

向量召回擅长语义相似,但对以下内容可能不稳定:

  • 订单号;
  • 错误码;
  • 产品型号;
  • 法规编号;
  • 精确日期;
  • 特殊缩写。

关键词召回擅长精确匹配,却可能漏掉同义表达。企业知识库通常需要混合召回。

3. 为什么需要重排序

初次召回通常追求覆盖率,可能取 Top 20 或 Top 50;模型上下文又不适合放这么多内容。Reranker 可以对"问题---文档片段"二元关系重新打分:

text 复制代码
向量 / 关键词召回 30 条
    ↓
Reranker 重新判断相关性
    ↓
保留前 5 条

重排序增加延迟和成本,需要通过评估集验证收益。

二、核心概念

1. Embedding Profile

Embedding Profile 描述一组固定向量化配置:

text 复制代码
profile_id
model_name
provider
dimension
normalization
max_input_tokens
version

一个知识库索引必须绑定一个 Profile。更换 Profile 通常意味着重建向量。

2. Recall Candidate

Candidate 是初次召回结果,包含:

  • Chunk ID;
  • 文本;
  • 来源;
  • 召回方式;
  • 原始分数;
  • Metadata;
  • 索引代次。

不要在召回阶段丢掉原始分数,否则无法分析阈值和排序问题。

3. Hybrid Retrieval

混合检索同时使用:

text 复制代码
向量检索:语义相似
关键词检索:精确匹配
Metadata 过滤:权限和业务范围

结果合并时可以使用加权分数、RRF 或重排序模型。

4. Reranker

Reranker 接收问题和候选文档,输出相关性分数:

json 复制代码
{
  "query": "差旅报销多久提交",
  "document": "出差结束后十个工作日内提交报销申请",
  "score": 0.94
}

它不是生成模型,也不负责回答问题。

三、工作原理

1. 离线向量化流程

text 复制代码
读取有效文档版本
    ↓
读取 Chunk
    ↓
按批次调用 Embedding Model
    ↓
校验向量维度
    ↓
写入向量库
    ↓
更新索引进度
    ↓
标记 generation 可用

2. 在线检索流程

text 复制代码
用户问题
    ↓
权限和租户校验
    ↓
查询改写或规范化
    ↓
Embedding
    ↓
向量召回
    ↓
关键词召回
    ↓
结果合并和去重
    ↓
Reranker
    ↓
上下文压缩

3. 过滤顺序

推荐先做安全过滤,再做相关性排序:

text 复制代码
租户和知识库范围
    ↓
文档版本和状态
    ↓
用户权限
    ↓
向量 / 关键词召回
    ↓
重排序

不能先从全库召回 Top-K 再过滤,因为其他租户文档可能挤占结果,也可能出现在日志和中间数据中。

4. 服务边界

text 复制代码
EmbeddingService
  └─ 文本 → 向量

CandidateRetriever
  └─ 问题 → 候选 Chunk

RerankService
  └─ 问题 + 候选 → 排序结果

ContextBuilder
  └─ 排序结果 → 模型上下文

每个服务都可以单独替换和评估。

四、实战示例

1. 数据模型

java 复制代码
public record EmbeddingProfile(
        String id,
        String model,
        int dimension,
        String version) {
}

public record RetrievalCandidate(
        String chunkId,
        String content,
        double score,
        String source,
        String retrievalMethod,
        Map<String, Object> metadata) {
}

public record RankedCandidate(
        RetrievalCandidate candidate,
        double rerankScore,
        int rank) {
}

2. 批量向量化

java 复制代码
public class EmbeddingService {

    private final EmbeddingModel embeddingModel;
    private final EmbeddingProfile profile;

    public List<float[]> embedBatch(List<String> texts) {
        List<float[]> vectors = embeddingModel.embed(texts);

        for (float[] vector : vectors) {
            if (vector.length != profile.dimension()) {
                throw new IllegalStateException(
                        "embedding dimension mismatch"
                );
            }
        }
        return vectors;
    }
}

批量大小要根据 Provider 限制、网络延迟、内存和失败重试成本调整。

3. 向量召回

java 复制代码
public List<RetrievalCandidate> vectorRecall(
        UUID tenantId,
        UUID knowledgeBaseId,
        String question,
        int topK) {

    String filter = "tenant_id == '" + tenantId
            + "' && knowledge_base_id == '" + knowledgeBaseId
            + "' && index_status == 'ACTIVE'";

    SearchRequest request = SearchRequest.builder()
            .query(question)
            .topK(topK)
            .similarityThreshold(0.55)
            .filterExpression(filter)
            .build();

    return vectorStore.similaritySearch(request)
            .stream()
            .map(documentMapper::toCandidate)
            .toList();
}

过滤值必须来自服务端可信上下文。非 UUID 字符串应通过过滤表达式构建器处理,不能直接拼接未校验输入。

4. 关键词召回

java 复制代码
public List<RetrievalCandidate> keywordRecall(
        UUID tenantId,
        UUID knowledgeBaseId,
        String question,
        int topK) {

    return searchRepository.fullTextSearch(
            tenantId,
            knowledgeBaseId,
            question,
            topK
    );
}

关键词检索可以使用 PostgreSQL Full Text Search、Elasticsearch 或其他搜索引擎。关键是返回统一的 Candidate 结构。

5. 合并候选

java 复制代码
public List<RetrievalCandidate> merge(
        List<RetrievalCandidate> vector,
        List<RetrievalCandidate> keyword) {

    Map<String, RetrievalCandidate> unique =
            new LinkedHashMap<>();

    Stream.concat(vector.stream(), keyword.stream())
            .forEach(candidate ->
                    unique.merge(
                            candidate.chunkId(),
                            candidate,
                            this::preferHigherScore
                    ));

    return unique.values()
            .stream()
            .limit(30)
            .toList();
}

实际混合排序可以使用 RRF:

text 复制代码
RRF(chunk) = Σ 1 / (k + rank_i)

RRF 不要求不同召回器的分数在同一尺度上,适合作为简单的候选合并方法。

6. 调用 Reranker

java 复制代码
public List<RankedCandidate> rerank(
        String question,
        List<RetrievalCandidate> candidates) {

    List<RerankRequestItem> items = candidates.stream()
            .map(candidate -> new RerankRequestItem(
                    candidate.chunkId(),
                    question,
                    candidate.content()
            ))
            .toList();

    List<RerankScore> scores =
            rerankClient.score(items);

    return scores.stream()
            .sorted(Comparator.comparingDouble(
                    RerankScore::score
            ).reversed())
            .limit(6)
            .map(score -> new RankedCandidate(
                    findCandidate(candidates, score.id()),
                    score.score(),
                    score.rank()
            ))
            .toList();
}

Reranker 超时时应有降级策略,例如使用初次召回排序,但要记录 rerank_status=DEGRADED。

7. 上下文构建

java 复制代码
public String buildContext(
        List<RankedCandidate> ranked) {

    return IntStream.range(0, ranked.size())
            .mapToObj(i -> {
                RankedCandidate item = ranked.get(i);
                return "[资料 " + (i + 1) + "]\n"
                        + item.candidate().content();
            })
            .collect(Collectors.joining("\n\n"));
}

上下文构建时要限制:

  • 最大 Chunk 数;
  • 最大字符数或 Token 数;
  • 单文档最大片段数;
  • 重复内容;
  • 敏感字段;
  • 引用 Metadata。

8. 统一检索服务

java 复制代码
public RetrievalResult retrieve(
        RetrievalRequest request) {

    List<RetrievalCandidate> vector =
            vectorRecall(
                    request.tenantId(),
                    request.knowledgeBaseId(),
                    request.question(),
                    20
            );

    List<RetrievalCandidate> keyword =
            keywordRecall(
                    request.tenantId(),
                    request.knowledgeBaseId(),
                    request.question(),
                    20
            );

    List<RetrievalCandidate> candidates =
            merge(vector, keyword);

    List<RankedCandidate> ranked =
            rerank(request.question(), candidates);

    return new RetrievalResult(
            ranked,
            metrics(vector, keyword, ranked)
    );
}

9. 索引代次

sql 复制代码
CREATE TABLE knowledge_index_generation (
    id UUID PRIMARY KEY,
    knowledge_base_id UUID NOT NULL,
    embedding_profile VARCHAR(128) NOT NULL,
    splitter_version VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    activated_at TIMESTAMPTZ
);

查询只使用当前 Active Generation:

text 复制代码
Generation 001:旧 Embedding,INACTIVE
Generation 002:新 Embedding,ACTIVE

10. 召回指标

text 复制代码
Recall@K = Top-K 中包含正确证据的问题比例

MRR = 第一个正确结果排名的倒数平均值

NDCG = 考虑结果相关等级和排序位置的指标

准备人工标注集:

json 复制代码
{
  "question": "差旅报销多久提交",
  "relevantChunkIds": ["chunk-1001", "chunk-1002"]
}

每次调整切片、Embedding、召回、重排或阈值后重新评估。

五、常见问题与实践建议

1. 为什么向量召回没有找到正确内容

排查:

  • 原文是否解析成功;
  • Chunk 是否包含完整事实;
  • Embedding 模型是否一致;
  • 查询是否需要改写;
  • Top-K 是否过小;
  • Metadata 是否过滤过严;
  • 向量距离和阈值是否匹配。

2. 为什么加入重排序后更慢

重排序通常需要额外模型请求。优化方法:

  • 只对 20 到 50 条候选排序;
  • 批量发送;
  • 设置短超时;
  • 缓存重复查询;
  • 对简单问题跳过重排;
  • 使用更轻量的 Reranker。

3. Reranker 失败是否要让问答失败

低风险问答可以降级到初次召回,高风险领域可以直接返回"检索服务暂时不可用",避免用未经重排的低置信证据生成结论。降级策略需要按业务等级配置。

4. 向量和关键词结果如何去重

使用稳定的 chunkId 去重,不要只比较文本内容,因为同样的文字可能来自不同文档版本和不同租户。

5. 如何处理长问题

长问题可以先做:

  • 问题摘要;
  • 子问题拆分;
  • 查询改写;
  • 去除无关背景;
  • 多路检索后合并。

改写查询不能带入用户无权限的历史信息。

6. 召回服务是否应该同步执行

在线问答可以同步执行,但文档批量向量化应放到异步 Worker。向量化失败不应阻塞文件上传接口。

7. 如何避免跨租户泄露

每次检索必须从认证上下文获取租户和用户,并将权限过滤写进底层查询。引用返回前再次校验,不要只信任向量库返回的 Metadata。

8. 如何缓存 Embedding

可以使用内容哈希缓存:

text 复制代码
cache_key =
embedding_profile + content_hash

更换 Profile 时自动失效,避免不同模型的向量混用。

六、进阶思考

1. 查询改写和多路召回

一个复杂问题可以生成多个检索查询:

text 复制代码
原始问题
    ↓
查询改写
  ├─ 业务术语查询
  ├─ 关键词查询
  └─ 语义问题查询
    ↓
分别召回
    ↓
合并和重排

多路召回增加成本,应只对复杂或低置信问题启用。

2. Contextual Compression

重排序后可以进一步压缩 Chunk,只保留与当前问题相关的句子或段落,减少上下文 Token。但压缩过程可能丢失限定条件,重要制度和法律条款需要保留完整上下文。

3. 召回缓存

对相同知识库、同一索引代次和相同查询,可以缓存候选结果:

text 复制代码
tenant + knowledgeBase + generation + normalizedQuery

缓存必须绑定权限和索引版本,不能跨租户共享。

4. 线上反馈

可以收集:

  • 用户是否点击引用;
  • 是否追问;
  • 是否手动改写问题;
  • 是否标记答案无帮助;
  • 人工选择的正确文档。

这些反馈可以用于调整 Chunk、Embedding、阈值和 Reranker。

5. 检索服务的高可用

向量库、关键词搜索和 Reranker 需要分别设置:

  • 超时;
  • 并发上限;
  • 重试;
  • 熔断;
  • 降级;
  • 监控;
  • 资源配额。

不能让 Reranker 故障拖垮全部问答请求。

结论

向量化、召回与重排序服务是 RAG 系统的证据层。稳定实现需要:

  • 用固定 Embedding Profile 管理向量空间;
  • 用索引代次支持模型和切片策略迁移;
  • 将租户、权限和文档状态过滤前置;
  • 通过向量和关键词混合召回提升覆盖;
  • 用 Reranker 提升候选相关性;
  • 对超时、失败和成本设计降级;
  • 使用 Recall@K、MRR 和 NDCG 建立评估基线;
  • 保留候选、分数、引用和检索配置,支持问题排查。

先建立可观测的基础召回,再根据评估结果逐步加入查询改写、混合检索、重排和上下文压缩。

参考资料

相关推荐
王中阳Go1 小时前
面到 harness 架构就卡住的人,多半是做过但没这么叫过
架构·go·agent
智驭未来掌门人1 小时前
雷达架构梳理:那些容易混淆的边界与分层
后端
琳达爱分享1 小时前
四种限流算法实测对比:固定窗口、滑动窗口、漏桶、令牌桶(附 Redis+Lua 实现)
后端·架构
用户9888321871741 小时前
别把一次性任务写成常驻 Worker:从 .NET 托管服务的生命周期说起
后端
韩振方1 小时前
Kubernetes StatefulSet 让每个副本找回自己的存储
后端
小卿噢1 小时前
把连接池从 4 调到 128,吞吐掉了 60%
后端
yunwei371 小时前
eBPF 示例:使用 CUPTI 构建 GPU 火焰图分析器
linux·后端·性能优化
网腾无限1 小时前
多智能体编排的长事务翻车实录:重试风暴、重复扣款与补偿失效怎么治
后端
吱吱鼠1 小时前
四、搞懂文件存储管理:多级索引寻道、位示图秒级分配与访问控制体系
架构