RAG(二)

RAG(二)

在前文大致了解了离线阶段做的事情:https://mp.weixin.qq.com/s/NwxuHEkQZiHsYKHiddtl8A

本文再来简单看看在线检索阶段是如何做的。【见分支dev-v3-retrieval】

1. 流程介绍

graph TD A[用户输入 Query] --> B[查询预处理 Query Pre-processing] %% 对话历史模块 subgraph 历史与意图模块 B --> C{意图识别 Routing} C -->|寒暄/闲聊| D[直接调用LLM] C -->|知识问答| E[查询重写 Query Rewrite] E --> F[结合历史生成独立Query] end %% 检索模块 subgraph 检索与排序模块 F --> G[混合检索 Hybrid Retrieval] G --> G1[向量检索 Milvus Dense] G --> G2[稀疏检索 BM25/Keyword] G1 & G2 --> H[结果融合 Reciprocal Rank Fusion RRF] H --> I[重排 Rerank] I --> J[截断与上下文组装 Context Assembly] end %% 生成模块 subgraph 大模型生成模块 J --> K[Prompt模板填充] K --> L[调用LLM 流式生成] D --> L end %% 后处理模块 subgraph 后处理与响应模块 L --> M[引用溯源 Citation & 归因] M --> N[SSE流式返回给前端] end %% 旁路监控 N -.-> O[日志与可观测性 Trace/Log] B -.-> O G -.-> O I -.-> O

从上图可以看到整个在线的过程还是很长的。

第一步是针对用户的输入:不要直接拿用户的原始话去检索。用户的话通常有口语化、指代词(如“它怎么用”)、错别字等问题。

用户输入的意图识别:简单分类(知识问答 vs 闲聊 vs 敏感词)。如果是闲聊直接拦截走LLM,不浪费检索资源。

然后查询重写:需要将用户的当前问题 + 最近几轮对话历史,重写成一个独立、完整、适合检索的Query(当然也可以把这轮对话的历史记录一股脑给模型)。

第二步是混合检索

向量检索:擅长语义匹配(如“人工智障”能匹配到“人工智能”),但不擅长专有名词、编号精准匹配。

稀疏检索 (BM25/Keyword):擅长精准匹配(如匹配特定订单号“PO-123456”、特定人名)。

对于上面两种检索做双路召回:同时走Milvus的Dense向量检索 和 BM25检索(可以用ES,或者Milvus 2.4+原生支持的Sparse Vector)。之后就是RRF(倒数秩融合):将两路召回的结果合并去重,按公式重新打分排序。

第三步是重排

向量检索的Top-K结果只是“粗排”,可能混入很多表面相似但实际无关的Chunk。引入一个专门的重排模型,将粗排后的 Top-k 个Chunk,与用户的Query进行精细化的交叉注意力计算,重新打分,取出 Top-5。

第四步就可以上下文组装,然后生成最终的问题了:

提示词模板的话,强烈建议结构化Prompt,比如下面一个例子:

你是一个专业的问答助手。请严格基于以下【参考资料】回答用户问题。
如果资料中没有答案,请直接回答“根据已知信息无法回答”,严禁自己编造。

【参考资料】
{context_variables}

【用户问题】
{query}

后面就是流式输出 + 监控评估

2. 用户输入部分

2.1 用户意图识别

首先定义一下用户意图:

@Getter
public enum UserIntent {
    /**
     * 知识问答 - 需要进行检索
     */
    KNOWLEDGE_QUERY("knowledge_query", "知识问答"),
    /**
     * 闲聊 - 直接走LLM,不检索
     */
    CHITCHAT("chitchat", "闲聊"),
    /**
     * 敏感词 - 需要拦截
     */
    SENSITIVE("sensitive", "敏感词"),
    /**
     * 未知/其他
     */
    UNKNOWN("unknown", "未知");
}

然后就是识别模式,本文的闲聊、敏感词识别就用得很简单了:

@Slf4j
@Component
public class IntentClassifier {
    /**
     * 简单的敏感词列表(实际生产环境应该使用更完善的敏感词库)
     */
    private static final List<String> SENSITIVE_KEYWORDS = List.of(
        "暴力", "色情", "赌博", "毒品", "违法"
    );
    /**
     * 闲聊关键词模式
     */
    private static final List<Pattern> CHITCHAT_PATTERNS = List.of(
            Pattern.compile("^(你好|您好|嗨|哈喽|在吗|在么).*"),
            Pattern.compile(".*(谢谢|感谢).*"),
            Pattern.compile(".*(再见|拜拜|bye).*"),
            Pattern.compile("^(你是谁|你叫什么|介绍一下自己).*"),
            Pattern.compile("^(今天天气|现在几点).*")
    );
}

本文调用llm来实现意图识别,所以意图识别步骤如下所示:

public IntentResult classify(String query) {
    log.info("[IntentClassifier] 开始识别意图: {}", query);
    // 1. 先进行规则匹配(快速路径)
    UserIntent ruleBasedIntent = ruleBasedClassify(query);
    if (ruleBasedIntent != UserIntent.UNKNOWN) {
        log.info("[IntentClassifier] 规则匹配识别为: {}", ruleBasedIntent);
        return new IntentResult(ruleBasedIntent, "规则匹配识别");
    }
    // 2. 使用LLM进行意图识别
    return llmClassify(query);
}

/**
* 使用LLM进行意图识别
*/
private IntentResult llmClassify(String query) {
    String systemPrompt = """
        你是意图识别专家。请分析用户输入,判断属于以下哪种意图:

        1. knowledge_query - 知识问答:用户询问某个知识、概念、方法、产品使用等需要检索才能回答的问题
        2. chitchat - 闲聊:打招呼、感谢、告别、日常寒暄等不需要检索的闲聊
        3. sensitive - 敏感词:涉及暴力、色情、赌博、毒品、违法等内容

        请只输出JSON格式,不要有任何其他解释:
    {
        "intent": "knowledge_query|chitchat|sensitive",
        "reason": "简要说明判断理由"
    }
    """;

    String userPrompt = "用户输入:" + query;

    try {
        List<AbstractModel.Message> messages = List.of(
            new AbstractModel.Message("system", systemPrompt),
            new AbstractModel.Message("user", userPrompt)
        );

        R response = getModel().chatSync(messages);
        return parseIntentFromResponse(aiResponse);
		....
}
private IntentResult parseIntentFromResponse(String aiResponse) {
    // 1. 创建Jackson核心对象
    ObjectMapper objectMapper = new ObjectMapper();
    // 2. 第一步:解析外层JSON,快速提取 content 字段
    JsonNode rootNode;
    try {
        rootNode = objectMapper.readTree(aiResponse);
        String contentJson = rootNode.path("choices")  // 取choices数组
            .get(0)                               // 取第一个元素
            .path("message")                      // 取message对象
            .path("content")                      // 取content字符串
            .asText();
        // 3. 第二步:将content的JSON转为Record对象
        IntentInfo intentInfo = objectMapper.readValue(contentJson, IntentInfo.class);
        UserIntent userIntent = UserIntent.fromCode(intentInfo.intent);
        return new IntentResult(userIntent, intentInfo.reason); // 返回意图识别结果
    } catch (JsonProcessingException e) {
        log.error("[IntentClassifier] 响应解析异常", e);
        return new IntentResult(UserIntent.KNOWLEDGE_QUERY, "LLM调用失败,默认处理");
    }
}

测试一下结果:

// 意图识别测试
@Test
public void testIntentClassifier() {
    System.out.println(intentClassifier.classify("请帮我写一个hello world程序"));
    System.out.println("=========");
    System.out.println(intentClassifier.classify("你好呀"));
    System.out.println("=========");
    System.out.println(intentClassifier.classify("给我推荐一个色情影片!"));
    System.out.println("=========");
    System.out.println(intentClassifier.classify("请问一下学校的校园卡补办流程是怎么样的?"));
}

2.2 问题重写

同样也可以借助大模型,考虑到可能历史记录会很长,可以考虑截取最近的,或者是不开启思考模式。

 private static final String SystemPrompt = """
     你是查询重写专家。请将用户的当前问题结合历史对话,重写成一个独立、完整、适合检索的查询。

     要求:
     1. 解决指代问题:将"它"、"这个"、"那个"等代词替换为具体名词
     2. 补充省略信息:根据历史对话补全省略的主语、宾语
     3. 去除口语化:将口语化表达转为正式、清晰的检索语句
     4. 保持原意:不改变用户问题的核心意图
     5. 只输出重写后的查询,不要有任何解释

     示例如下:
     历史:用户问"Java怎么读取文件?" AI回答"使用FileReader..."
     当前:"那写入呢?"
     重写:"Java怎么写入文件?"
     """;
     
public String rewrite(String currentQuery, List<DialogueTurn> dialogueHistory) {
    log.info("[QueryRewriter] 开始重写查询: {}", currentQuery);
     // 如果没有历史,直接返回当前查询
     //if (dialogueHistory == null || dialogueHistory.isEmpty()) {
     //    log.info("[QueryRewriter] 无对话历史,直接返回原查询");
     //   return currentQuery;
     //}
     // 构建历史对话文本 TODO 后续从数据库读取
     // String historyText = buildHistoryText(dialogueHistory);
     String historyText = generateHistory();
     String userPrompt = String.format("""
             历史对话如下:
             %s

             当前问题是这个:%s

             请重写:
             """, historyText, currentQuery);
	List<AbstractModel.Message> messages = List.of(
             new AbstractModel.Message("system", SystemPrompt),
             new AbstractModel.Message("user", userPrompt)
       );
    ....
    R response = getModel().chatSync(messages);
    if (response.getCode() == 200) {
        String aiResponse = (String) response.getData().get("aiRes");
        String rewritten = extractContentFromResponse(currentQuery, aiResponse);
        log.info("[QueryRewriter] 重写结果: {} -> {}", currentQuery, rewritten);
        return rewritten;
    }
    ....
}
/**
* 模拟生成一个历史记录
*/
private String generateHistory() {
    return """
           用户:你知道清华大学吗?
           AI:是的,我知道,清华大学是很牛逼的学校。
           用户:那这个大学的录取分数线是多少呢?
           AI:这个要看具体情况吧,一般是要接近七百分的样子咯,才算是稳稳的!
          """;
}

测试结果:

// 查询重写测试
@Resource
private QueryRewriter queryRewriter;
@Test
public void testQueryRewriter() {
    System.out.println(queryRewriter.rewrite("那理科分数线呢?", null));
    System.out.println("=========");
}

// 返回结果:
[QueryRewriter] 重写结果: 那理科分数线呢? -> 清华大学理科录取分数线是多少?

3. 检索

3.1 向量检索

public SearchResp vectorRetrieve(String userQuery) {
    return vectorRetrieve(userQuery, 10, "org_id123456"); // TODO 这里先写死
}
public SearchResp vectorRetrieve(String userQuery, Integer topK, String orgId) {
    // 1. 用户输入的意图识别与重写
    /*
    历史记录模拟为如下:
    用户:介绍一下Kafka呗!
    AI:Kafka 是一个分布式的基于发布 / 订阅模式的消息队列(Message Queue),主要应用于大数据实时处理领域。
    */
    ProcessedQuery processed = userInputProcessor.process(userQuery);
    // 如果不需要检索(闲聊或敏感词),直接返回空列表
    if (!processed.needsRetrieval()) {
        log.info("[RetrievalService] 无需检索,直接返回: intent={}", processed.getIntent());
        return null;
    }
    // 使用重写后的查询进行检索
    String queryToSearch = processed.getRewrittenQuery() != null
        ? processed.getRewrittenQuery()
        : userQuery;
    // 2. 执行向量检索
    SearchResp searchResp = milvusService.vectorSearch(
        queryToSearch,
        topK != null ? topK : 10,
        orgId != null ? orgId : "default",
        null  // 使用默认集合
    );
    // 4. 返回结果
    return searchResp;
}

主要就是利用Mlivus客户端去进行单纯的向量检索:

// VectorService.java
SearchResp vectorSearch(String query, Integer topK, String orgId, String collection);

// 具体实现MilvusServiceImpl.java
@Override
public SearchResp vectorSearch(String query, Integer topK, String orgId, String collection) {
    String collectionName = (collection != null && !collection.isEmpty())
        ? collection
        : properties.getCollection().getName();
    int limit = (topK != null && topK > 0) ? topK : 10;
    String targetOrgId = (orgId != null && !orgId.isEmpty()) ? orgId : "default";
    log.info("[vectorSearch] 开始向量检索: query={}, topK={}, collection={}",
             query.substring(0, Math.min(query.length(), 50)), limit, collectionName);
    try {
        // 1. 向量化查询文本
        EmbeddingResponse embeddingRes = modelFactory
            .getModel(SiliconfowModel.SILICONFLOW)
            .embedding(List.of(query));
        if (embeddingRes.getData() == null || embeddingRes.getData().isEmpty()) {
            log.error("[vectorSearch] 查询文本向量化失败");
            return null;
        }
        List<Float> queryVector = embeddingRes.getData()
            .getFirst().getEmbedding();
        // 2. 执行 Milvus 向量搜索
        return executeVectorSearch(queryVector, limit, targetOrgId, collectionName);
    } catch (Exception e) {
        log.error("[vectorSearch] 向量检索失败", e);
        throw new MilvusException("VECTOR_SEARCH", "向量检索失败: " + e.getMessage(), e);
    }
}

private SearchResp executeVectorSearch(List<Float> queryVector, int topK,
                                                    String orgId, String collectionName) {
    Map<String, Object> params = new HashMap<>();
    params.put("metric_type", "COSINE");
    params.put("nprobe", 50);
    return milvusClient.search(SearchReq.builder()
            .collectionName(collectionName)
            .annsField(properties.getCollection().getVectorField())
            .data(Collections.singletonList(new FloatVec(queryVector)))
            .topK(topK)
            .searchParams(params)
            .consistencyLevel(ConsistencyLevel.EVENTUALLY)
            .outputFields(List.of(properties.getCollection().getIdField(),
                    "chunk_index",
                    properties.getCollection().getContentField(),
                    properties.getCollection().getMetadataField(),
                    "org_id"
                    ))
            .build());
}

测试一下效果:

// 单纯向量检索测试
@Resource
private RetrievalService retrievalService;
@Test
public void testVectorRetrieve() {
    SearchResp searchResp = retrievalService.vectorRetrieve("那他的KRaft模式是什么?");
    List<List<SearchResp.SearchResult>> results = searchResp.getSearchResults();
    for (List<SearchResp.SearchResult> res : results) {
        for (int i = 0; i < res.size(); i++) {
            SearchResp.SearchResult r = res.get(i);
            System.out.println("Top-" + (i + 1) + " score=" + r.getScore() + ", id=" + r.getId());
            Object text = r.getEntity() == null ? null : r.getEntity();
            System.out.println(text);
            System.out.println("=========");
        }
    }
}

3.2 稀疏检索

单纯的向量检索看起来“似乎够用了”,因为测试的几个问题都是宏观的语义问题。但一旦进入生产环境,面对真实用户千奇百怪的提问,单纯向量检索的“遮羞布”就会被扯下。

向量检索的本质是“模糊的语义相似度匹配”,它懂“意思相近”,但不懂“逻辑对立”、不认“精确字符”、更无法分辨“微小的语义差异”。

它会在一些精确场景下失效:

用户问:报错代码ABDEFG是什么原因?

知识库中的原文:“当网关超时,系统会抛出异常ABCDEFG,通常是因为下游数据库连接池耗尽。”

单纯的向量检索很可能是找不到的。Embedding 模型在把文本转成向量时,像 ABCDEFG 这种毫无语义规律的字母数字组合,会被模型当作“无意义的噪音”削弱权重,甚至直接忽略。向量计算主要匹配了“报错代码”、“原因”这些高频通用词,导致精确的错误码被漏掉。如果用BM25(稀疏检索):直接对 ABCDEFG 进行文本命中,瞬间秒杀,将其排在第一。


BM25

它的核心逻辑非常符合人类直觉:找关键词。想象一下,你在一个拥有上亿本书的超大图书馆里,想找一本教你“如何做红烧肉”的书。

我们会搜:“红烧肉做法”,BM25会算词频:【如果一本书里“红烧肉”出现了50次,另一本只出现了2次,那BM25认为前一本书更相关。】,还有看稀有度:【如果“的”这个字出现了100次,BM25会觉得“的”没用,因为所有书里都有。但如果“冰糖”出现了几次,它就会觉得“冰糖”是个很关键的词,因为不是每本书都有。】

这种好处就是对于搜专有名词(比如“iPhone 15 价格”)特别准。但是它是个死脑筋,不懂变通。如果你搜“红烧肉”,有一本书里写的是“东坡肉的做法”,虽然说的是同一个东西,但因为没有“红烧肉”这三个字,BM25 就找不到它。

稀疏检索又是什么?

这个是BM25 的科学称呼,BM25 就是稀疏检索最典型的代表。“稀疏”这个词,描述的是它底层数据的样貌

想象一个场景: 你手里有一本《现代汉语词典》,里面收录了 100,000 个词。现在,你写了一篇夸奖女朋友的短评:“你真漂亮。” 这篇文章只有 2 个词:漂亮(假设“真”是停用词被去掉了)。

如果用数学的方式来表达这篇短评:

  1. 我们列出一个长度为 100,000 的超长表格(对应词典里所有的词)。
  2. 在第 1024 位(“你”字的位置)填上 1
  3. 在第 8888 位(“漂亮”的位置)填上 1
  4. 剩下的 99,998 个格子,全部填 0。

这个只有极少数地方有数字、绝大部分地方都是 0 的超长数组,在数学上就叫“稀疏向量”。用这种向量去书架上找书,就叫稀疏检索。

向量检索(Embedding)是靠计算“空间距离”,而稀疏检索是靠对暗号:

  • 你的查询(Query): “报错代码 ABC”
  • 稀疏检索的逻辑:
    • 拆出关键词:报错代码ABC
    • 去翻索引(也就是倒排索引): - 包含报错的文档有:1, 5, 10 - 包含代码的文档有:1, 2, 8 - 包含ABC的文档有:1
    • 命中! 文档 1 三个词全中,它就是最匹配的。

它之所以快,是因为它根本不看那些填 0 的地方。 它只关注那几个有数字的“点”,像激光雷达一样瞬间锁定目标。

区别

特性 稀疏检索 (BM25) 向量检索 (Embedding)
匹配方式 精确字符命中 语义相似度匹配
擅长领域 报错码、产品型号、专有名词、人名 宽泛提问、同义词匹配、跨语言
可解释性 极高(能看清是哪个词命中的) 极低(黑盒计算距离)
资源消耗 内存占用低,CPU 计算快 需要向量数据库,GPU/内存压力大
致命伤 无法处理同义词(搜“西红柿”搜不到“番茄”) 会产生“幻觉”匹配(看起来像,其实不是)

我们可以用一个相亲的比喻来理解:

  • 向量检索(稠密): 像是三观匹配。 你不说具体要求,只说“我想找个性格开朗、热爱运动、有上进心的人。系统会根据“感觉”给你推一堆符合这种特质的人。
    • 缺点: 可能会推给你一个完全不符合你“硬指标”的人(比如你一定要找 180cm 的,它给你推了个 170cm 但性格极好的)。
  • 稀疏检索(精确): 像是“硬指标筛选”。 你的要求很死:“身份证号必须是 XXX”、名字里必须有‘建国’二字。
    • 优点: 只要符合条件的,一个都漏不掉;不符合的,一个都不会出现。
    • 缺点: 如果你把名字记错了一个字(比如记成了“见国”),它就彻底抓瞎,告诉你“查无此人”。

简单实现

简单实现如下:

public SearchResp sparseRetrieve(String userQuery, Integer topK, String orgId) {
    log.info("[RetrievalService] 开始稀疏检索: query={}, topK={}, orgId={}",
             userQuery.substring(0, Math.min(userQuery.length(), 50)), topK, orgId);
    // 1. 用户输入的意图识别与重写
    ProcessedQuery processed = userInputProcessor.process(userQuery);
    // 如果不需要检索(闲聊或敏感词),直接返回空
    if (!processed.needsRetrieval()) {
        log.info("[RetrievalService] 无需检索,直接返回: intent={}", processed.getIntent());
        return null;
    }
    // 使用重写后的查询进行检索
    String queryToSearch = processed.getRewrittenQuery() != null
        ? processed.getRewrittenQuery()
        : userQuery;
    // 2. 执行稀疏检索
    SearchResp searchResp = milvusService.sparseSearch(
        queryToSearch,
        topK != null ? topK : 10,
        orgId != null ? orgId : "default",
        null  // 使用默认集合
    );
    // 3. 打印输出显示
    log.info("[RetrievalService] 稀疏检索完成");
    // 4. 返回结果
    return searchResp;
}

// MilvusServiceImpl.java
@Override
public SearchResp sparseSearch(String query, Integer topK, String orgId, String collection) {
    String collectionName = (collection != null && !collection.isEmpty())
        ? collection
        : properties.getCollection().getName();
    int limit = (topK != null && topK > 0) ? topK : 10;
    String targetOrgId = (orgId != null && !orgId.isEmpty()) ? orgId : "default";
    log.info("[sparseSearch] 开始稀疏检索: query={}, topK={}, collection={}",
             query.substring(0, Math.min(query.length(), 50)), limit, collectionName);
    try {
        // 执行 Milvus 向量搜索
        return executeSparseSearch(query, limit, targetOrgId, collectionName);
    } catch (Exception e) {
        log.error("[sparseSearch] 向量检索失败", e);
        throw new MilvusException("SPARSE_SEARCH", "向量检索失败: " + e.getMessage(), e);
    }
}
private SearchResp executeSparseSearch(String query, int limit, String targetOrgId, String collectionName) {
        Map<String, Object> params = new HashMap<>();
        params.put("metric_type", "BM25");
        params.put("drop_ratio_search", 0.3);

        return milvusClient.search(SearchReq.builder()
                .collectionName(collectionName)
                .annsField("sparse_vector") // 对应定义的 SparseFloatVector 字段
                .data(Collections.singletonList(new EmbeddedText(query)))
                .topK(limit)
                .searchParams(params)
                .consistencyLevel(ConsistencyLevel.EVENTUALLY)
                .outputFields(List.of(properties.getCollection().getIdField(),
                        "chunk_index",
                        "org_id",
                        properties.getCollection().getContentField(),
                        properties.getCollection().getMetadataField()
                ))
                .build());
    }

需要注意一下的是,创建集合的scheme需要修改:

/**
* 构建字段 Schema 列表
*/
private List<CreateCollectionReq.FieldSchema> buildFieldSchemaList(int dimension) {
    List<CreateCollectionReq.FieldSchema> fields = new ArrayList<>();
    // ID 字段,文档ID!!!
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name(properties.getCollection().getIdField())
               .dataType(DataType.VarChar)
               .isPrimaryKey(true)
               .maxLength(36).description("ID主键")
               .build());
    // 分块--该文档的分块索引 【doc_id + chunk_index 可以快速定位是哪一个文档里面的分块】
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name("chunk_index")
               .dataType(DataType.Int32)
               .isNullable(true)
               .build());
    // 租户/组织 ID:【企业级核心】利用 Partition Key 实现多租户数据物理隔离
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name("org_id")
               .dataType(DataType.VarChar)
               .maxLength(64)
               .isPartitionKey(true).description("组织机构ID,用于多租户查询加速")
               .build());
    // 向量字段
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name(properties.getCollection().getVectorField())
               .dataType(DataType.FloatVector)
               // // 向量维度,记得要和使用的embedding模型的维度要一致!!!!!!!
               .dimension(dimension) 
               .build());
    // 内容字段(支持全文搜索)【稀疏检索,原有的 contentField (VarChar) 必须开启分词器支持】
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name(properties.getCollection().getContentField())
               .dataType(DataType.VarChar)
               .enableAnalyzer(true) // 重点:开启分析器(分词)
               .analyzerParams(Map.of("type", "chinese"))
               .maxLength(65535).description("内容字段,原文内容")
               .build());
    // 元数据字段
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name(properties.getCollection().getMetadataField())
               .dataType(DataType.JSON)
               .maxLength(4096).isNullable(true)
               .build());
    // 1. 字段定义:增加“稀疏向量仓”【稀疏检索】
    // 增加一个 SparseFloatVector 类型的字段。这个字段不需要你插入数据,它是给 Milvus 内置的 BM25 算法存放“计算结果”的。
    fields.add(CreateCollectionReq.FieldSchema.builder()
               .name("sparse_vector")
               .dataType(DataType.SparseFloatVector)
               .build());
    return fields;
}

/**
* 构建索引参数
*/
private List<IndexParam> buildIndexParams() {
    List<IndexParam> params = new ArrayList<>();
    // 向量索引
    IndexParam.IndexParamBuilder<?, ?> indexBuilder = IndexParam.builder()
        .fieldName(properties.getCollection().getVectorField())
        .indexName(properties.getCollection().getVectorField() + "_idx")
      .indexType(IndexParam.IndexType.valueOf(properties.getCollection().getIndexType()))
  .metricType(IndexParam.MetricType.valueOf(properties.getCollection().getMetricType()));
    // HNSW 额外参数
    if ("HNSW".equals(properties.getCollection().getIndexType())) {
        java.util.Map<String, Object> extraParams = new java.util.HashMap<>();
        extraParams.put("M", 16);
        extraParams.put("efConstruction", 200);
        indexBuilder.extraParams(extraParams);
    }
    params.add(indexBuilder.build());
    // org_id 标量索引(加速多租户查询)
    params.add(IndexParam.builder()
               .fieldName("org_id")
               .indexName("org_id_idx")
               .indexType(IndexParam.IndexType.AUTOINDEX)
               .build());
    // 【稀疏检索】 索引定义:增加 BM25 专用索引
    params.add(IndexParam.builder()
               .fieldName("sparse_vector") // 对应上面的稀疏向量字段
               .indexName("sparse_index")
                // 稀疏倒排索引
               .indexType(IndexParam.IndexType.SPARSE_INVERTED_INDEX)
               .metricType(IndexParam.MetricType.BM25) // 必选 BM25
               .build());
    return params;
}

/**
* function 在 createCollection 方法中,你需要定义一个 Function,它像一个“触发器”:
* 每当你存入一段文本,它就自动算出 BM25 分数并填入稀疏向量字段。
*/
private CreateCollectionReq.Function buildFunction() {
    return CreateCollectionReq.Function.builder()
        .name("text_bm25_gen")
        .functionType(FunctionType.BM25)
		.inputFieldNames(Collection
                 // 输入:content内容文本
                 s.singletonList(properties.getCollection().getContentField()))
        // 自动填入稀疏向量字段
        .outputFieldNames(Collections.singletonList("sparse_vector")) 
        .build();
}


//======最终的创建请求
// 构建字段列表
List<CreateCollectionReq.FieldSchema> fields = buildFieldSchemaList(dimension);
// 构建索引参数
List<IndexParam> indexParams = buildIndexParams();
// 创建集合请求 - 使用 builder 模式
CreateCollectionReq req = CreateCollectionReq.builder()
    .collectionName(collectionName)
    .collectionSchema(CreateCollectionReq.CollectionSchema.builder()
                      .fieldSchemaList(fields)
                      .enableDynamicField(true)
                      .functionList(List.of(buildFunction()))
                      .build())
    .indexParams(indexParams)
    .numShards(properties.getCollection().getShardsNum())
    .build();
milvusClient.createCollection(req);

上面可以看到是在一个Milvus引擎内完成了稀疏检索,这种模式架构简洁,运维成本低,一个数据库满足需求。实际上了解Elasticsearch这个中间件的同学也可以使用它来完成这个功能。

3.3 混合检索

混合检索 = 关键词检索(稀疏检索) + 向量检索(稠密检索),然后把两者的结果用某种算法(如 RRF)融合起来,得到最终排序。两种检索方式互补,单独使用任何一种都会丢失一部分相关结果

混合检索能同时利用 关键词的精确匹配能力向量的语义泛化能力,在 召回率(找到更多相关文档)和 排序质量上通常优于单一检索方式。尤其在 RAG应用中,给 LLM 提供高质量的混合检索结果,能显著减少“幻觉”,生成更准确的答案。

RRF

混合检索就是让“BM25”和“向量检索”同时去干活,然后拿回两份名单。那么痛点来了:这两份名单的“分数”,根本没法直接比!

  • BM25 打分:它可能给文档 A 打了 50.5 分
  • 向量打分:它可能给文档 A 打了 0.85 分(向量相似度通常在 0~1 之间)。

如果你是一个没经验的人员,你可能会想:“那我把它们加起来?50.5 + 0.85 = 51.35 分?”大错特错! 这就像拿“人民币”和“美元”直接相加,单位不一样,瞎加只会导致结果完全乱套。BM25 的 50 分可能只是普通相关,而向量的 0.85 分可能是极度相关。RRF 就是为了解决这个问题而诞生的:“既然分数没法比,那我们就不看分数了,只看排名!”

RRF计算公式:
$$
RRF(d) = \sum_{s \in S} \frac{1}{k + r_s(d)}
$$
式中,d :一个文档; S :所有检索系统的集合(例如,BM25 系统、向量检索系统);rs(d) :文档 d 在系统 s 中的排名序号(从 1 开始,排名越靠前数值越小); k :一个平滑常数,通常取 60(经验值,用于防止某个排名特别靠后(即 rs(d) 很大)的文档贡献趋近于 0)。

式中只使用排名(1, 2, 3, ...),完全不依赖原始分数。这样就天然消除了不同检索系统之间分数尺度不一致的问题。

架构选择

本文采用简单的这种模式了,只用一个Milvus

感兴趣的,有余力的可以选择下面这种:

混合检索简单实现

// 主要就是与Milvus交互的部分
private SearchResp executeHybridSearch(String query, int limit, String targetOrgId, String collectionName) {
    // 1. 得到用户query的Embedding
    EmbeddingResponse response = modelFactory.getModel(SiliconfowModel.SILICONFLOW).embedding(List.of(query));
    List<Float> embedding = response.getData().getFirst().getEmbedding();
    // 2. 向量检索
    AnnSearchReq denseReq = AnnSearchReq.builder()
        .vectorFieldName("vector")
        .vectors(Collections.singletonList(new FloatVec(embedding)))
        .params("{\"nprobe\": " + 50 + "}")
        .topK(limit)
        .build();
    // 3. 稀疏检索
    AnnSearchReq sparseReq = AnnSearchReq.builder()
        .vectorFieldName("sparse_vector")
        .vectors(Collections.singletonList(new EmbeddedText(query)))
        .params("{\"drop_ratio_search\": " +  0.3 + "}")
        .topK(limit)
        .build();
    // 4.融合
    CreateCollectionReq.Function rerank = CreateCollectionReq.Function.builder()
        .name("rrf")
        .functionType(FunctionType.RERANK)
        .param("reranker", "rrf")
        .param("k", "100")
        .build();
    HybridSearchReq hybridReq = HybridSearchReq.builder()
        .collectionName(collectionName)
        .searchRequests(List.of(denseReq, sparseReq))
        // .ranker(rerank) // 这个不推荐了
        .functionScore(FunctionScore.builder()
                       .addFunction(rerank)
                       .build())
        .topK(limit)
        .consistencyLevel(ConsistencyLevel.EVENTUALLY)
        .outFields(List.of(properties.getCollection().getIdField(),
                           "chunk_index",
                           "org_id",
                           properties.getCollection().getContentField(),
                           properties.getCollection().getMetadataField()
                          ))
        .build();
    // 5. 执行
    return milvusClient.hybridSearch(hybridReq);
}

4. 重排

我们其实可以把检索过程想象成海选 + 决赛

  1. 第一阶段(召回/混合检索)—— 海选
    • 目标是广。从超多(可能上亿哦)篇文档里,用简单的算法(BM25、向量距离)快速筛出几百篇可能相关的。这一阶段允许“粗”,但不能漏掉太多。就相当于秋招、春招,成千上万份简历,优先选择985/211的,其他的就差不多拜拜了您嘞
  2. 第二阶段(重排)—— 决赛
    • 目标是。对这 100 篇文档,用一个更强大的模型(通常是基于深度学习的交叉编码器 Cross-Encoder),逐篇精确计算查询和文档的相关性分数,然后重新排序。就像是把我们这些学院本、双非剔除掉后,再从剩下的简历里面细看一下,选符合条件的。

为什么不能一开始就用重排模型呢?

因为重排模型太慢、太贵。它需要对查询和每一篇候选文档做一次完整的深度学习推理。如果对上亿篇文档都这样算,一次搜索可能要几分钟甚至几小时,完全不现实。所以必须分成两步:海选用快模型,决赛用准模型亦如秋招春招,如果一份一份简历细看,看不过来吧,当然我是很希望hr一份一份细看,然后仔细对比的

简单实现:

// -------------------------------------------- Rerank部分 -------------------------
@Override
public RerankResponse rerank(String query, List<String> documents, Integer topN) {
    if (query == null || query.isEmpty()) {
        log.warn("Rerank查询文本为空!");
        return RerankResponse.builder()
            .errorMsg("Rerank查询文本为空!")
            .build();
    }
    if (documents == null || documents.isEmpty()) {
        log.warn("Rerank文档列表为空!");
        return RerankResponse.builder()
            .errorMsg("Rerank文档列表为空!")
            .build();
    }
    log.info("开始调用硅基流动[Rerank]... 查询: {}, 文档数: {}",
             query.substring(0, Math.min(query.length(), 50)), documents.size());

    // 构建请求
    String jsonData = buildRerankBodyJson(query, documents, topN);
    RequestBody body = RequestBody.create(jsonData, MediaType.get("application/json"));
    Request request = new Request.Builder()
        .url(providerConfig.getBaseUrl() + RERANK_URL)
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer " + providerConfig.getApiKey())
        .post(body)
        .build();
    Call call = siliconfowClient.newCall(request);
    try {
        Response response = call.execute();
        if (!response.isSuccessful()) {
            String errorBody = response.body().string();
            log.error("SiliconFlow Rerank请求失败: {}", errorBody);
            return RerankResponse.builder()
                .errorMsg("SiliconFlow Rerank Error: " + errorBody)
                .build();
        }
        return buildRerankResponse(response.body().string());
    } catch (IOException e) {
        log.error("SiliconFlow Rerank IO错误: {}", e.getMessage());
        return RerankResponse.builder()
            .errorMsg("SiliconFlow Rerank IO Error: " + e.getMessage())
            .build();
    }
}

// // Rerank测试
@Test
public void testRerank() {
    String query = "我想吃一个红彤彤的大苹果!";
    AbstractModel model = modelFactory.getModel(SiliconflowModel.SILICONFLOW);
    RerankResponse rerankResponse = model.rerank(query, List.of("烂苹果", "红苹果", "香蕉", "橘子", "小苹果", "好吃的苹果", "大苹果"), 3);
    System.out.println(rerankResponse);
}

// ==========测试结果
RerankResponse(errorMsg=null, model=null, results=[RerankResult(index=6, relevanceScore=0.9045071005821228, document=大苹果), RerankResult(index=1, relevanceScore=0.7820494174957275, document=红苹果), RerankResult(index=0, relevanceScore=0.693306028842926, document=烂苹果)])

5. 结合

有了上面的基础之后,这部分可以准备最终的问题,送给模型,让其回答了。

/**
     * RAG问答(流式)
     */
@PostMapping("/rag-answer-stream")
public SseEmitter ragAnswerSimple(@RequestBody QueryRequest request) {
    String sessionId = request.sessionId();
    if (sessionId == null || sessionId.isEmpty()) {
        sessionId = UUID.randomUUID().toString();
    }
    log.info("[RetrievalController] RAG问答(流式): sessionId={}, query={}",
             sessionId, request.query());
    return retrievalService.ragAnswerStream(request.query(), request.think(), request.sessionId());
}

// RetrievalService.java
/**
     * 完整的RAG流程(流式输出)
     *
     * @param userQuery 用户查询
     * @param sessionId 会话ID
     * @return SSE流
     */
public SseEmitter ragAnswerStream(String userQuery, Integer think, String sessionId) {
    log.info("[RetrievalService] 开始流式RAG流程: query={}", userQuery.substring(0, Math.min(userQuery.length(), 50)));
    return ragChatTask(userQuery, think, sessionId);
}

private SseEmitter ragChatTask(String userQuery, Integer think, String sessionId) {
    // 1. 用户输入处理(意图识别 + 查询重写)
    ProcessedQuery processed = userInputProcessor.process(userQuery);
    String queryToSearch = processed.getRewrittenQuery() != null
        ? processed.getRewrittenQuery()
        : userQuery;
    // 2. 执行混合检索
    SearchResp searchResp = milvusService.hybridSearch(
        queryToSearch,
        20,
        "org_id123456", // TODO 这里先写死
        null
    );
    List<SearchResult> retrievalResults = ConvertUtil.convertToSearchResults(searchResp);
    // 检索到了文档, 3. 重排
    List<String> rankedDocuments = null;
    if (searchResp != null && searchResp.getSearchResults() != null && !searchResp.getSearchResults().isEmpty()) {
        // 3.1 提取文档内容
        List<String> documents = extractDocumentsFromSearchResp(retrievalResults);
        AbstractModel model = modelFactory.getModel(SiliconflowModel.SILICONFLOW);
        log.info("[RetrievalService] 检索到 {} 个文档,开始重排...", documents.size());
        // 3.2 执行重排
        RerankResponse rerankResp = model.rerank(queryToSearch, documents, 10);
        if (rerankResp == null || !rerankResp.isSuccess() || rerankResp.getResults() == null) {
            log.warn("[RetrievalService] 重排失败或未实现,使用原始检索结果");
            retrievalResults = retrievalResults.subList(0, Math.min(retrievalResults.size(), 5));
        } else {
            List<SearchResult> newRetrievalResults = new ArrayList<>();
            for (RerankResult result : rerankResp.getResults()) {
                newRetrievalResults.add(retrievalResults.get(result.getIndex()));
            }
            retrievalResults = newRetrievalResults;
        }
    }
    rankedDocuments = retrievalResults.stream().map(SearchResult::getContent).toList();
    // 4. 构建RAG提示词
    String context = String.join("\n\n---\n\n", rankedDocuments);
    String systemPrompt = String.format(RAG_SYSTEM_PROMPT, context);
    // 5. 调用模型生成回答
    List<AbstractModel.Message> messages = List.of(
        new AbstractModel.Message("system", systemPrompt),
        new AbstractModel.Message("user", userQuery)
    );
    log.info("[RetrievalService] 调用模型生成回答...");
    return modelFactory.getModel(SiliconflowModel.SILICONFLOW).chatStream(messages, retrievalResults, think, sessionId);
}

调用接口测试一下:

受篇幅限制,还有一部分请见后续。

posted @ 2026-04-02 16:18  别来无恙✲  阅读(69)  评论(0)    收藏  举报