为什么需要“智能 RAG”?

传统的 RAG 系统通常直接使用用户原始问题进行向量检索。这种方式看似简单,实则存在两大痛点:

  1. 噪声干扰严重:用户提问中常含无关词、语气词、口语化表达,导致检索结果不精准。
  2. 上下文缺失:仅返回原始文本片段,大模型难以理解文档背景,容易“断章取义”。

为解决这些问题,本文将带你构建一个高精度、可扩展的智能 RAG 系统,融合以下四大核心能力:

  • 关键词提取 + 元数据过滤 → 提升检索精度
  • 增强型上下文构造(摘要 + 内容)→ 提升回答准确性
  • 工具调用(Function Calling)→ 支持实时数据查询
  • 上下文记忆(Chat Memory)→ 实现多轮对话

整个系统基于 Spring AI 实现,代码简洁、可维护性强,适合生产环境部署。


核心流程设计

我们对传统 RAG 流程进行了升级,整体执行链路如下:

用户提问
   ↓
[LLM] 提取关键词 → 减少噪声
   ↓
构造元数据过滤条件(keywords in [...])
   ↓
向量检索 + 元数据过滤 → 精准召回
   ↓
拼接【摘要 + 内容】作为增强上下文
   ↓
注入 Prompt 模板,调用 ChatClient
   ↓
支持工具调用 & 上下文记忆
   ↓
流式返回最终回答

优势:通过“关键词提取 + 元数据过滤”,我们实现了语义+结构双重过滤,显著提升检索相关性。

关键词提取:让 LLM 帮你“提炼问题本质”

我们使用一个轻量级 Prompt,让 LLM 从用户问题中提取 3-5 个核心关键词:

private static final String KEYWORD_EXTRACTION_PROMPT = """
        请从以下用户问题中提取出最相关的 3-5 个关键词。
        只返回关键词列表,用英文逗号分隔,不要解释。
        
        问题:%s
        """;

例如:

  • 输入:“Spring Bean 的生命周期有哪些阶段?”
  • 输出:Spring, Bean, 生命周期

这些关键词将用于后续的元数据过滤,确保只检索包含这些关键词的文档。

注意:此步骤本身也是一次 LLM 调用,适用于对精度要求高的场景。若性能敏感,可考虑使用 NLP 库(如 HanLP)做本地关键词提取。


元数据过滤:精准召回,减少“无效检索”

我们假设文档在 ETL 阶段已提取关键词并存入元数据(如 keywords 字段):

Filter.Expression expression = new FilterExpressionBuilder()
    .in("keywords", keywords)
    .build();

结合向量相似度检索:

List<Document> retrievedDocs = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query(prompt)
        .filterExpression(expression)           // 元数据过滤
        .similarityThreshold(0.7)               // 相似度阈值
        .topK(6)                                // 最多返回 6 篇
        .build()
);

效果:避免召回大量“语义相似但主题无关”的文档,提升 RAG 准确率。


增强型上下文构造:不只是“文本片段”

传统 RAG 仅拼接 Document.getText(),信息量有限。我们引入文档摘要元数据,构造更丰富的上下文:

private String buildEnrichedContext(List<Document> docs) {
    if (docs.isEmpty()) {
        return "无相关参考资料。";
    }

    return docs.stream()
        .map(doc -> {
            String summary = doc.getMetadata().getOrDefault("summary", "无摘要").toString();
            String content = doc.getText();
            return "--- 摘要 ---\n" + summary + "\n--- 内容 ---\n" + content;
        })
        .collect(Collectors.joining("\n\n"));
}

这样,LLM 在生成回答时,不仅能“看到”原文,还能“理解”文档背景,回答更自信、更准确。


自定义 Prompt 模板:引导模型行为

我们设计了一个结构化 Prompt,明确告诉 LLM 如何决策:

private static final String ENHANCED_RAG_TEMPLATE = """
        请严格根据以下参考资料回答问题。参考资料在三横线之间。
        
        1. 如果问题涉及实时数据(如天气、时间),优先调用相应工具。
        2. 否则,请参考以下资料作答:
        
        ---------------------
        {question_answer_context}
        ---------------------
        
        问题:{query}
        
        要求:
        - 必须基于参考资料回答
        - 如果参考资料中没有相关信息,请回答:“抱歉,我无法根据参考资料回答这个问题。”
        - 不要编造或推测答案
        - 回答时不要提及“上下文”或“参考资料”
        """;

关键设计

  • 明确优先级:工具 > 参考资料 > 拒绝回答
  • 防止幻觉(Hallucination)
  • 输出格式干净,适合前端展示

工具调用(Function Calling):支持实时数据

我们整合了天气查询工具,支持动态调用:

FunctionToolCallback<WeatherService.WeatherRequest, WeatherService.WeatherResponse> weatherTool =
    FunctionToolCallback.builder("currentWeather", new WeatherService())
        .description("Get the weather in location")
        .inputType(WeatherService.WeatherRequest.class)
        .build();

ChatClient 中启用:

.toolCallbacks(weatherTool)
.toolNames(WeatherTools.CURRENT_WEATHER_TOOL)

当用户问“今天北京天气如何?”时,系统会自动调用 currentWeather 工具,而非依赖静态知识库。


上下文记忆(Chat Memory):支持多轮对话

通过 advisors 注入会话 ID,实现对话记忆:

.advisors(memoryAdvisor -> 
    memoryAdvisor.param(ChatMemory.CONVERSATION_ID, chatId))
  • chatId 来自请求头,可用于区分不同用户或会话
  • 支持多轮问答,如:
    • Q: “Spring Bean 是什么?”
    • Q: “它有哪些作用域?”

实现真正的“对话式 AI”,而非单轮问答。


完整代码实现

@RestController
@RequestMapping("/rag")
public class RAGController {

    @Autowired
    private ChatClient chatClient;

    @Autowired
    private VectorStore vectorStore;

    // 提取关键词 Prompt
    private static final String KEYWORD_EXTRACTION_PROMPT = """
        请从以下用户问题中提取出最相关的 3-5 个关键词。
        只返回关键词列表,用英文逗号分隔,不要解释。
        
        问题:%s
        """;

    // 增强型 RAG Prompt 模板
    private static final String ENHANCED_RAG_TEMPLATE = """
        请严格根据以下参考资料回答问题。参考资料在三横线之间。
        
        1. 如果问题涉及实时数据(如天气、时间),优先调用相应工具。
        2. 否则,请参考以下资料作答:
        
        ---------------------
        {question_answer_context}
        ---------------------
        
        问题:{query}
        
        要求:
        - 必须基于参考资料回答
        - 如果参考资料中没有相关信息,请回答:“抱歉,我无法根据参考资料回答这个问题。”
        - 不要编造或推测答案
        - 回答时不要提及“上下文”或“参考资料”
        """;

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> rag(
            HttpServletResponse response,
            @Validated @RequestParam("prompt") String prompt,
            @RequestHeader(value = "chatId", required = false, defaultValue = "rag") String chatId) {

        response.setCharacterEncoding("UTF-8");

        return Flux.defer(() -> {
            try {
                // Step 1: 提取关键词
                List<String> keywords = extractKeywordsFromPrompt(prompt);

                // Step 2: 构造元数据过滤表达式
                Filter.Expression expression = new FilterExpressionBuilder()
                    .in("keywords", keywords)
                    .build();

                // Step 3: 向量检索 + 元数据过滤
                List<Document> retrievedDocs = vectorStore.similaritySearch(
                    SearchRequest.builder()
                        .query(prompt)
                        .filterExpression(expression)
                        .similarityThreshold(0.7)
                        .topK(6)
                        .build()
                );

                // Step 4: 构造增强上下文(摘要 + 内容)
                String context = buildEnrichedContext(retrievedDocs);

                // Step 5: 渲染最终 Prompt
                String finalPrompt = new PromptTemplate(ENHANCED_RAG_TEMPLATE).render(
                    Map.of("question_answer_context", context, "query", prompt)
                );

                // Step 6: 注册工具回调
                FunctionToolCallback<WeatherService.WeatherRequest, WeatherService.WeatherResponse> weatherTool =
                    FunctionToolCallback.builder("currentWeather", new WeatherService())
                        .description("Get the weather in location")
                        .inputType(WeatherService.WeatherRequest.class)
                        .build();

                // Step 7: 调用 ChatClient 流式响应
                return chatClient.prompt()
                    .user(finalPrompt)
                    .toolCallbacks(weatherTool)
                    .toolNames(WeatherTools.CURRENT_WEATHER_TOOL)
                    .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, chatId))
                    .stream()
                    .content();

            } catch (Exception e) {
                return Flux.just("抱歉,处理请求时发生错误:" + e.getMessage());
            }
        });
    }

    /**
     * 使用 LLM 提取关键词
     */
    private List<String> extractKeywordsFromPrompt(String prompt) {
        String keywordPrompt = String.format(KEYWORD_EXTRACTION_PROMPT, prompt);
        String result = chatClient.prompt(keywordPrompt).call().content();
        return Arrays.stream(result.split(","))
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .limit(5)
            .collect(Collectors.toList());
    }

    /**
     * 构造增强上下文:包含摘要和内容
     */
    private String buildEnrichedContext(List<Document> docs) {
        if (docs.isEmpty()) {
            return "无相关参考资料。";
        }

        return docs.stream()
            .map(doc -> {
                String summary = doc.getMetadata().getOrDefault("summary", "无摘要").toString();
                String content = doc.getText();
                return "--- 摘要 ---\n" + summary + "\n--- 内容 ---\n" + content;
            })
            .collect(Collectors.joining("\n\n"));
    }
}

结语

通过本次实战,我们不仅实现了一个功能完整的 RAG 系统,更重要的是掌握了 Spring AI 的整合能力——它让 Java 开发者也能轻松构建 AI 原生应用。

AI 不是替代开发者,而是赋予我们更强的创造力。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐