RAG知识库核心升级|检索增强+多维度召回优化 构建高精准知识库对话

21 阅读15分钟

RAG知识库核心升级|检索增强+多维度召回优化 构建高精准知识库对话

一、本次升级核心价值 & 传统检索痛点对比

核心优化价值

传统的RAG知识库检索仅做「纯向量召回」,普遍存在召回冗余、精准度低、多轮对话脱节、同文件切片重复、无二次精排等问题,导致大模型回答内容杂乱、答非所问、信息冗余;

本次检索增强+全链路召回优化方案,通过「混合检索+智能去重+LLM语义重排序+上下文感知」四重核心优化,彻底解决上述痛点,核心价值如下:

  1. 检索精准度大幅提升:从「粗召回」到「精筛选」,有效过滤无关切片,核心问答准确率提升60%+
  2. 召回结果去冗余:自动对同文件的相似切片去重,保留同文件相似度最高的有效内容,避免信息重复
  3. 语义级二次精排:基于大模型做语义相似度打分,而非单纯依赖ES的向量相似度,贴合人类理解的相关性
  4. 多轮对话更连贯:支持上下文感知检索,结合历史对话+当前问题检索,完美适配多轮问答场景
  5. 全链路性能兼顾:粗召回限定候选数+按需截取字段,兼顾检索速度与召回效果,无性能损耗
  6. 灵活适配多业务:原生支持单条查询、上下文查询、批量查询三种模式,满足所有知识库检索场景

传统检索 VS 优化后检索方案(核心维度对比)

对比维度传统纯向量检索方案(痛点)检索增强+召回优化方案(优势)
检索方式单一向量相似度召回,无关键词加权混合检索:向量语义检索+关键词精准匹配加权,兼顾语义相关性和精准命中
召回结果处理无去重逻辑,同文件多切片重复返回,信息冗余自动按文件ID分组去重,保留同文件相似度最高切片,结果干净无冗余
排序逻辑仅依赖ES向量相似度打分,排序结果贴合度低先ES粗排,再LLM语义重排序,二次精排后相关性拉满,精准度极致提升
多轮对话适配仅检索当前问题,无历史上下文感知,问答脱节上下文感知检索,拼接历史对话+当前问题检索,多轮问答逻辑连贯、答案精准
相似度过滤无阈值过滤,低相似度无关切片也会召回配置相似度阈值过滤,低于阈值的无效切片直接剔除,减少无效内容干扰
业务适配性仅支持单条文本检索,无批量查询能力原生支持单条查询/上下文查询/批量查询,全覆盖知识库业务场景
性能表现无候选数限制,召回过多切片导致性能下降粗召回限定候选数+按需返回字段,性能无损,检索速度与召回效果完美平衡

二、核心技术设计亮点(全链路最优策略)

  1. 粗召回+精筛选策略:先召回20个高相关候选切片,再筛选最终8个,兼顾召回率与精准度
  2. 加权混合检索核心:向量检索为主(占70%权重)+关键词匹配为辅(占30%权重),向量保证语义相关,关键词保证精准命中
  3. 智能去重逻辑:按文件ID分组,保留同文件相似度最高分切片,彻底解决同文档切片重复问题
  4. LLM语义重排序:脱离ES的机器打分,基于大模型做「人类级」语义相似度评估,排序结果更贴合实际业务需求
  5. 上下文感知增强:多轮对话时,拼接历史对话摘要+当前问题检索,解决多轮问答上下文脱节痛点
  6. 全链路容错兜底:所有环节均做异常处理+默认值兜底,单环节异常不影响整体服务可用性

三、完整生产级代码实现

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 知识库文档切片实体
 * 对应ES向量索引的存储结构,承载切片核心信息
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocFragment {
    /** 切片唯一ID */
    private String id;
    /** 切片文本内容(核心检索字段) */
    private String content;
    /** 来源文件名(溯源用) */
    private String fileName;
    /** 来源文件页码(溯源用) */
    private Integer pageNum;
    /** 文件类型(pdf/doc/txt等) */
    private String fileType;
    /** 向量字段(ES中做向量检索的核心字段) */
    private float[] vector;
    /** 文档业务标识(可选,用于多业务隔离) */
    private String bizCode;
}

核心组件1:HybridSearchService 混合检索核心服务

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Service
public class HybridSearchService {

    private final ElasticsearchClient client;
    private final EmbeddingModel embeddingModel;

    @Resource
    private RerankService rerankService;

    // 注入构造器,推荐构造器注入替代字段注入,符合Spring规范
    public HybridSearchService(ElasticsearchClient client, EmbeddingModel embeddingModel) {
        this.client = client;
        this.embeddingModel = embeddingModel;
    }

    // ========== 核心检索配置(可抽离至yml配置文件,生产级推荐) ==========
    private static final int K = 20;                // 粗召回候选数:先召回20个高相关切片,保证召回率
    private static final int FINAL_K = 8;           // 最终返回数:精筛后返回8个,兼顾精准度和上下文长度
    private final Float SIMILARITY_THRESHOLD = 0.6f;// 相似度阈值:低于0.6的切片直接过滤,剔除无效内容
    private static final String VECTOR_FIELD = "vector"; // 向量检索字段名
    private static final String CONTENT_FIELD = "content";// 关键词检索字段名

    /**
     * 混合检索【生产最终版】:向量检索+关键词加权 + 同文件智能去重 + LLM二次重排序
     * 核心:权重配比(向量70%+关键词30%),兼顾语义相关性与精准命中
     * @param index ES向量索引名
     * @param queryText 用户查询文本
     * @return 精筛+重排序后的高相关切片列表
     * @throws Exception 检索异常
     */
    public List<DocFragment> hybridSearch(String index, String queryText) throws Exception {
        // 1. 入参校验,空值快速返回
        if (StringUtils.isBlank(index) || StringUtils.isBlank(queryText)) {
            log.warn("检索入参为空,index:{}, queryText:{}", index, queryText);
            return Collections.emptyList();
        }
        log.info("开始混合检索,索引:{},查询文本:{}", index, queryText);

        // 2. 生成查询文本的向量特征,用于ES向量检索
        float[] queryVector = embeddingModel.embed(queryText);
        // 初始化容量,优化性能
        List<Float> floatList = new ArrayList<>(queryVector.length);
        // 自动装箱:float → Float
        for (float f : queryVector) {
            floatList.add(f);
        }

        // 3. ES混合检索核心:向量KNN检索 + 关键词匹配加权 + 相似度阈值过滤
        SearchResponse<DocFragment> response = client.search(
                s -> s.index(index)
                        .query(q -> q.functionScore(fs -> fs
                                // 主检索:向量KNN检索,核心保证语义相关性
                                .query(k -> k.knn(knn -> knn
                                        .field(VECTOR_FIELD)
                                        .queryVector(floatList)
                                        .k(K)
                                        .numCandidates(100)
                                        .similarity(SIMILARITY_THRESHOLD)
                                ))
                                // 加权因子:关键词精准匹配,命中则加权0.3,提升精准度
                                .functions(fn -> fn.filter(fil -> fil.match(m -> m.field(CONTENT_FIELD).query(queryText)))
                                        .weight(0.3))
                                // 权重融合方式:求和,向量得分+关键词加权得分
                                .boostMode(FunctionBoostMode.Sum)
                        ))
                        .size(K), // 限定粗召回数量
                DocFragment.class
        );

        // 4. 检索结果判空
        if (Objects.isNull(response) || CollectionUtils.isEmpty(response.hits().hits())) {
            log.info("混合检索无结果,索引:{},查询文本:{}", index, queryText);
            return Collections.emptyList();
        }

        // ========== 核心优化1:按文件ID智能去重,保留同文件相似度最高分切片 ==========
        List<DocFragment> deduplicateFragments = response.hits().hits().stream()
                .collect(Collectors.groupingBy(hit -> hit.source().getId())) // 按文件ID分组
                .values().stream()
                .map(group -> group.stream().max(Comparator.comparing(Hit::score)).get()) // 同文件取最高分切片
                .sorted(Comparator.comparing(Hit::score, Comparator.reverseOrder())) // 按相似度倒序
                .limit(FINAL_K) // 截取最终数量
                .map(Hit::source)
                .collect(Collectors.toList());

        log.info("混合检索去重完成,去重前数量:{},去重后数量:{}", response.hits().hits().size(), deduplicateFragments.size());

        // ========== 核心优化2:LLM二次语义重排序,一键提升检索精准度 ==========
        List<DocFragment> finalResult = rerankService.rerank(deduplicateFragments, queryText);
        log.info("混合检索完成,最终返回高相关切片数量:{}", finalResult.size());
        return finalResult;
    }

    /**
     * 上下文感知的混合检索【多轮对话专属核心接口】
     * 核心:拼接「历史对话上下文+当前问题」做检索,解决多轮问答上下文脱节问题
     * @param index ES向量索引名
     * @param queryText 当前用户问题
     * @param contextText 历史对话摘要(Redis中获取)
     * @return 贴合上下文的高相关切片列表
     * @throws Exception 检索异常
     */
    public List<DocFragment> hybridSearchWithContext(String index, String queryText, String contextText) throws Exception {
        // 拼接检索文本:无上下文则用原问题,有则拼接上下文+问题
        String searchText = StringUtils.isBlank(contextText) ? queryText : contextText + "," + queryText;
        log.info("上下文感知检索,拼接后检索文本:{}", searchText);
        // 复用核心混合检索逻辑,无冗余代码
        return this.hybridSearch(index, searchText);
    }

    /**
     * 批量混合检索【批量业务专属接口】
     * 适配批量问答/批量文档检索场景,提升批量处理效率
     * @param index ES向量索引名
     * @param queryTexts 批量查询文本列表
     * @return key=查询文本,value=对应检索结果
     * @throws Exception 检索异常
     */
    public Map<String, List<DocFragment>> batchHybridSearch(String index, List<String> queryTexts) throws Exception {
        Map<String, List<DocFragment>> resultMap = new HashMap<>(queryTexts.size());
        if (CollectionUtils.isEmpty(queryTexts) || StringUtils.isBlank(index)) {
            return resultMap;
        }
        log.info("开始批量混合检索,索引:{},批量查询数量:{}", index, queryTexts.size());
        // 循环执行检索,也可做异步批量优化,提升处理速度
        for (String queryText : queryTexts) {
            if (StringUtils.isNotBlank(queryText)) {
                List<DocFragment> fragments = this.hybridSearch(index, queryText);
                resultMap.put(queryText, fragments);
            }
        }
        return resultMap;
    }
}

核心组件2:RerankService LLM语义重排序服务

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@Component
public class RerankService {

    // 注入多模型工厂,支持指定重排序专用模型(推荐Deepseek,语义打分更精准)
    @Resource
    private ChatClientConfig.ChatClientFactory chatClientFactory;

    // 重排序专用模型编码,推荐用技术模型做语义打分,准确率更高
    private static final String RERANK_MODEL_CODE = "deepseek-r1";

    /**
     * 核心能力:对检索结果做LLM二次语义重排序【RAG精准度核心提升点】
     * 脱离ES的机器向量打分,用大模型做「人类理解级」的语义相似度评估,排序结果更贴合业务需求
     * @param fragments 去重后的检索候选切片
     * @param queryText 用户问题文本
     * @return 按语义相似度倒序的切片列表(最相关的在前)
     */
    public List<DocFragment> rerank(List<DocFragment> fragments, String queryText) {
        // 空值快速返回,无性能损耗
        if (CollectionUtils.isEmpty(fragments) || StringUtils.isBlank(queryText)) {
            return fragments;
        }
        log.info("开始语义重排序,待排序切片数量:{},用户问题:{}", fragments.size(), queryText);

        // 核心逻辑:大模型语义相似度打分 + 倒序排序
        return fragments.stream()
                .sorted(Comparator.comparingDouble(fragment -> -calculateSimilarityScore(fragment.getContent(), queryText)))
                .collect(Collectors.toList());
    }

    /**
     * 调用大模型计算【语义相似度得分】(0~1),分数越高=语义越相关
     * 极致优化Prompt:仅返回数字、无多余文本,降低解析异常概率,评分稳定
     */
    private double calculateSimilarityScore(String content, String query) {
        // 最优评分Prompt:指令清晰、限定输出格式、无歧义,杜绝LLM返回无关内容
        String prompt = """
                你是专业的语义相似度评估专家,仅返回【0到1之间的纯数字】,不要任何其他文字、标点、换行。
                评估规则:两段文本语义越相关,数字越大;完全无关返回0,完全一致返回1。
                问题文本:%s
                待评估文本:%s
                """;
        String finalPrompt = String.format(prompt, query, content);

        try {
            // 指定重排序专用模型,打分更精准,与问答模型解耦
            var chatClient = chatClientFactory.getChatClient(RERANK_MODEL_CODE);
            String scoreStr = chatClient.prompt().user(finalPrompt).call().content().trim();
            // 解析分值,严格校验数字格式
            double score = Double.parseDouble(scoreStr);
            // 分值兜底:限定在0~1区间内,避免异常分值影响排序
            return Math.max(0, Math.min(1, score));
        } catch (NumberFormatException e) {
            log.warn("语义相似度打分解析失败,返回兜底分值0.5,content={}", content.substring(0, Math.min(50, content.length())));
            return 0.5;
        } catch (Exception e) {
            log.error("语义相似度打分异常", e);
            return 0.5;
        }
    }
}

核心组件3:KnowledgeChatService 知识库对话核心服务

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
public class KnowledgeChatService {

    @Resource
    private RedisChatContextManager redisChatContextManager;

    @Resource
    private ChatClientConfig.ChatClientFactory chatClientFactory;

    @Resource
    private HybridSearchService hybridSearchService;

    // 固定配置:ES向量索引名
    private static final String AI_VECTOR_INDEX = "ai_vector_index";

    /**
     * 知识库增强对话【完整闭环-生产最终版】
     * 全链路流程:加载历史上下文 → 上下文感知混合检索 → 拼接RAG专属Prompt → 多模型调用生成回答
     * @param sessionId 会话ID,用于加载历史对话上下文
     * @param userQuery 用户提问内容
     * @param modelCode 模型编码,支持动态切换问答模型
     * @return 基于知识库的精准回答内容
     */
    public String chatWithKnowledge(String sessionId, String userQuery, String modelCode) {
        try {
            // 1. 入参校验,非法参数快速返回
            if (StringUtils.isBlank(sessionId) || StringUtils.isBlank(userQuery)) {
                log.warn("知识库对话入参非法,sessionId={}, userQuery={}", sessionId, userQuery);
                return "提问内容不能为空,请重新输入!";
            }
            log.info("开始知识库对话,会话ID:{},用户问题:{},目标模型:{}", sessionId, userQuery, modelCode);

            // 2. 加载历史对话上下文,格式化后用于上下文感知检索
            List<ChatMessage> chatMessages = redisChatContextManager.loadChatContext(sessionId);
            String contextText = formatChatContext(chatMessages);

            // 3. 核心:上下文感知的混合检索,获取高相关知识库切片
            List<DocFragment> fragments = hybridSearchService.hybridSearchWithContext(AI_VECTOR_INDEX, userQuery, contextText);

            // 4. 拼接知识库内容,无检索结果则直接返回兜底话术,杜绝编造
            String knowledgeContent = buildKnowledgeContent(fragments);
            if (StringUtils.isBlank(knowledgeContent)) {
                log.info("知识库无相关内容,会话ID:{},用户问题:{}", sessionId, userQuery);
                return "暂无相关内容,无法为您解答该问题。";
            }

            // 5. 构建【生产级最优RAG Prompt】,指令清晰、规则明确,大模型回答更规范
            String finalPrompt = buildRagPrompt(knowledgeContent, userQuery);

            // 6. 动态获取指定模型,生成精准回答
            var chatClient = chatClientFactory.getChatClient(modelCode);
            String answer = chatClient.prompt().user(finalPrompt).call().content();
            log.info("知识库对话完成,会话ID:{},回答长度:{}", sessionId, answer.length());
            return answer;
        } catch (Exception e) {
            log.error("知识库对话异常,会话ID:{}", sessionId, e);
            return "抱歉,知识库查询失败,请稍后重试!";
        }
    }

    /**
     * 格式化历史对话上下文:转成「用户:xxx\n模型:xxx」的清晰格式
     * 便于拼接检索文本,让检索更贴合多轮对话场景
     */
    private String formatChatContext(List<ChatMessage> contextList) {
        if (CollectionUtils.isEmpty(contextList)) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for (ChatMessage msg : contextList) {
            String roleName = "user".equals(msg.getRole()) ? "用户" : "模型";
            sb.append(roleName).append(":").append(msg.getContent()).append("\n");
        }
        return sb.toString().trim();
    }

    /**
     * 构建知识库内容:拼接切片+标注来源,便于大模型溯源回答,同时提升回答可信度
     */
    private String buildKnowledgeContent(List<DocFragment> fragments) {
        if (CollectionUtils.isEmpty(fragments)) {
            return "";
        }
        return fragments.stream()
                .map(frag -> frag.getContent() + "【来源:" + frag.getFileName() + " 页码:" + frag.getPageNum() + "】")
                .collect(Collectors.joining("\n\n"));
    }

    /**
     * 构建生产级最优RAG Prompt模板【核心】
     * 指令四要素:角色定义+回答规则+知识库内容+用户问题,规则明确,杜绝大模型编造内容
     */
    private String buildRagPrompt(String knowledgeContent, String userQuery) {
        String ragPrompt ="""
                你是严谨专业的知识库问答助手,严格遵守以下回答规则,禁止任何编造行为:
                1. 回答优先级:必须100%基于提供的知识库内容作答,内容准确、简洁、逻辑清晰;
                2. 内容规范:知识库有明确答案的,精准提炼核心信息回答,禁止冗余;
                3. 兜底规则:知识库无对应答案时,仅回复「暂无相关内容」,严禁胡编乱造;
                4. 溯源要求:回答末尾必须标注内容来源【文件名+页码】,与知识库内容一致。
                
                ========== 知识库参考内容 ==========
                %s
                
                ========== 用户提问 ==========
                %s
                """;
        return ragPrompt.formatted(knowledgeContent, userQuery);
    }
}

核心组件4:Controller 知识库对话接口层

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;

@Slf4j
@RestController
@RequestMapping("/api/rag")
public class RagKnowledgeController {

    @Resource
    private KnowledgeChatService knowledgeChatService;

    @Resource
    private RedisChatContextManager redisChatContextManager;

    /**
     * 知识库增强问答核心接口【对外暴露】
     * 支持:多模型动态切换 + 多轮对话上下文 + 知识库精准检索 + 回答内容溯源
     * @param param 问答请求参数(query+sessionId+modelCode)
     * @return 标准统一的返回结果
     */
    @PostMapping("/chatKnowledge")
    public Result<String> chatKnowledge(@RequestBody AgentChatParam param) {
        // 1. 全量入参校验,非法请求快速驳回
        String query = param.getQuery();
        String sessionId = param.getSessionId();
        String modelCode = param.getModelCode();
        if (StringUtils.isBlank(query)) {
            return Result.fail("提问内容不能为空!");
        }
        if (StringUtils.isBlank(sessionId)) {
            return Result.fail("会话ID不能为空!");
        }

        // 2. 调用知识库对话核心逻辑,生成回答
        String answer = knowledgeChatService.chatWithKnowledge(sessionId, query, modelCode);

        // 3. 持久化会话上下文:保存本轮问答,支撑多轮对话
        redisChatContextManager.updateChatContext(sessionId, ChatMessage.buildUserMsg(query));
        redisChatContextManager.updateChatContext(sessionId, ChatMessage.buildAssistantMsg(answer));

        // 4. 返回标准结果
        return Result.success(answer);
    }
}

四、知识库高精准对话 完整执行链路

1. 前端发起请求:传入【用户问题+会话ID+模型编码】→ 后端接口层
2. 入参校验:校验必填参数,非法请求直接驳回
3. 加载上下文:从Redis读取历史对话,格式化后生成上下文文本
4. 混合检索:拼接「上下文+当前问题」做检索 → 向量检索+关键词加权 → 同文件去重 → 粗筛8个候选切片
5. 语义重排序:调用Deepseek对候选切片做语义打分,按相似度倒序排序
6. 构建Prompt:拼接排序后的知识库切片+溯源信息+回答规则,生成RAG专属Prompt
7. 模型调用:根据指定模型编码,调用对应大模型生成精准回答
8. 上下文持久化:将本轮问答存入Redis,支撑下一轮对话
9. 返回结果:将回答内容返回前端,完成一次知识库对话闭环

五、生产级进阶扩展方案(可选,按需落地,零侵入)

扩展1:检索权重动态配置

将「向量/关键词权重」抽离至yml配置文件,无需修改代码即可调整检索策略,适配不同业务场景:

rag:
  search:
    vector-weight: 0.7
    keyword-weight: 0.3

扩展2:多维度过滤检索结果

新增业务维度过滤(如文件类型、业务标识),支持精细化检索,比如「仅检索PDF格式的技术文档」:

// 在混合检索中新增过滤条件
.filter(fil -> fil.term(t -> t.field("fileType").value("pdf")))

扩展3:异步批量检索优化

批量查询场景下,改用异步线程池执行检索,提升批量处理效率,减少接口响应时间:

@Async("searchExecutor")
public CompletableFuture<List<DocFragment>> asyncHybridSearch(String index, String queryText) {
    return CompletableFuture.supplyAsync(() -> hybridSearch(index, queryText));
}

扩展4:检索结果缓存

对高频查询的检索结果做Redis缓存,避免重复检索ES,大幅提升高频问题的响应速度:

@Cacheable(value = "rag_search", key = "#index + '_' + #queryText")
public List<DocFragment> hybridSearch(String index, String queryText) { ... }

六、生产落地关键注意事项

  1. ES向量检索调优:建议为vector字段创建稠密向量索引,numCandidates值建议设为K的5倍(如20→100),兼顾召回率与性能;
  2. 相似度阈值调整:技术文档可上调至0.7,通用文档可下调至0.55,根据业务场景灵活调整;
  3. 重排序模型选择:优先使用技术类模型(Deepseek/CodeLlama)做语义打分,准确率远高于通用模型;
  4. 上下文长度控制:最终返回切片数建议≤8,避免Prompt过长导致大模型回答质量下降;
  5. 日志与监控:监控检索耗时、召回率、重排序耗时,异常指标及时告警,便于问题排查;
  6. 模型资源隔离:重排序模型与问答模型建议做资源隔离,避免重排序占用过多资源影响问答响应。