Agent 开发之项目 AI Native 化:通过大模型与 RAG 赋予产品智能能力

Agent 开发之项目 AI Native 化:通过大模型与 RAG 赋予产品智能能力

文章目录

AI Native化架构设计,如何给项目集成一个AI旅行咨询服务助手?

两种 AI:辅助开发 vs 原生产品

AI 辅助开发(以前)
维度 内容
使用者 开发者(你和团队)
运行环境 IDE / 开发工具
核心价值 开发提效
技术栈 Qoder / MCP / Skill / CLI
AI 原生产品(本章主角)
维度 内容
使用者 终端用户(旅行者)
运行环境 生产环境(产品页面)
核心价值 产品体验升级
技术栈 Spring AI / 向量库 / RAG

以前你请了个 AI 实习生;本章你的产品要雇一个 AI 员工

AI Native 四大优势

定义:AI Native(AI 原生)= 产品从设计起就把 AI 作为核心能力,而不是作为附加功能。

优势 说明
意图驱动 用户用自然语言表达需求;AI 理解意图并直接执行;交互从 "点按钮" 转变为 "说句话"
个性化 千人千面智能体验;基于用户画像动态适配;不同用户得到不一样的产品内容
知识整合 打通散落的信息孤岛;向量检索 + RAG 实时召回;赋予 AI 记忆与知识库能力
持续进化 用户反馈驱动模型迭代;形成数据飞轮,越用效果越好;产品随着使用自动变聪明

案例:WanderChina 以此四点作为升级方向,实现从 AI 增强 → AI 原生 的产品过渡。

WanderChina 三层 AI 架构

应用层(Application Layer)

AI 助手 / 智能推荐 / 语义搜索 / AI 辅助发帖

知识层(Knowledge Layer)

向量数据库 + Embedding 模型 + RAG Pipeline + 元数据

接入层(Access Layer)

Spring AI 统一抽象 + 百炼 / 通义千问 / DeepSeek 等大模型

Trust Layer 信任层(贯穿三层)
  • 引用溯源
  • 置信度提示
  • 兜底路径
  • 内容审核
施工顺序:自下而上

L2 选模型(接入层) → L4 建知识库(知识层) → L3/L5/L6 做应用(应用层)

AI旅行咨询服务助手背后的大模型将如何进行选择?

选型四维框架

选大模型看哪 4 个维度?

四维框架不只选模型 ------ 以后评估任何 AI API 都能用

6 款主流模型对比 + 百炼选型

模型 中文 成本 速度 FC
通义千问 3‑Max(百炼) ★★★★★ ★★★★★
DeepSeek‑V4(深度求索) ★★★★★ 极低 ★★★★★
GPT‑5.5(OpenAI) ★★★★ ★★★★★
Claude Opus 4(Anthropic) ★★★★ ★★★★★
Gemini 2.5 Pro(Google) ★★★★ ★★★★
Llama 4(Meta・开源自部署) ★★★ 免费 ★★★
WanderChina 选型

百炼 + 通义千问 3‑Max

不选DeepSeek‑V4,因为该平台没有提供向量化模型,还需要单独找个平台提供向量化,这样两个账号的协作成本比较高

百炼 + 通义千问 3‑Max能实现对话、向量化、监控三件事

选型优势:

  1. 国内直连:免翻墙、不需要海外信用卡
  2. 统一管理:Key、用量、账单一站式管理
  3. 兼容 OpenAI 协议:Spring AI 可以直接对接

Spring AI = 翻译官,一行配置切换模型

API Key = 程序的 "密码" 证明 "我是谁、费从我扣",百炼控制台生成

Token = AI 的流量计费 ≈1‑2 个中文字 / 上行 + 下行都算 / 像手机流量

业务代码AiAssistantService.java
java 复制代码
// 业务代码: 永远长这样
@Autowired ChatClient chatClient;

String reply = chatClient
    .prompt()
    .user("推荐北京三天行程")
    .call()
    .content();

// 切换模型? 业务代码 0 改动 ✔
application.yml
yml 复制代码
# application.yml - 切百炼
spring:
  ai:
    openai:
      base‑url: dashscope.aliyuncs.com
      api‑key: ${DASHSCOPE_API_KEY}
      chat.options.model: qwen3‑max

# 换 DeepSeek? 改三行:
#   base‑url → api.deepseek.com
#   api‑key → ${DEEPSEEK_KEY}
#   model   → deepseek‑chat

面向接口编程 ------JDBC 如此、JPA 如此、现在 AI 也如此

工程化封装 + Model Portfolio

生产环境三件事
  1. 异常处理 + Fallback
  • API 会超时 / 不可用 / 余额不足
  • Spring AI 内置重试 + 配降级策略
  • 主模型挂了 → fallback 到备用模型
  1. 限流防刷
  • 用户 1 秒发 100 条 → 费用爆炸
  • 每分钟 N 次调用,超出排队或拒绝
  • Redis + RateLimitService 实现
  1. API Key 安全
  • 敏感密钥不能暴露给前端!
  • 所有 LLM 调用走后端代理
  • yml 用 ${ENV_VAR},不硬编码
WanderChina Model Portfolio
任务类型 选用模型 理由
AI 助手对话 qwen3‑max 体验最好
标签 / 标题生成 qwen3‑turbo 省 80% 成本
Embedding 向量化 text‑embed‑v3 中文最优
代码 / 翻译 deepseek‑v4 性价比高
兜底 fallback qwen3‑plus 主链路挂了顶上

一个 AI 产品背后不是一个模型,是一组模型

AI智能助手实战,如何使用集成对话式AI实现用户实时问答与辅助功能?

提示词

tex 复制代码
/opsx:explore
我要给 WanderChina 加一个 AI 智能助手功能:
- 首页右下角 "Plan with AI" 按钮目前显示 404,需要升级为真正的聊天窗口
- 后端需要 SSE 流式输出 + 多轮对话上下文管理
- 请分析项目现状,生成 Spec

相关问题答案:
1.需要登录,上下文存储DB
2.使用通义千问模型
3.让 AI 能调用汇率/天气 MCP 工具
4.聊天界面做成符合现有 homepage-ai-entry spec 契约,3 个入口无需改动,含会话历史侧栏
另外,前端和后端实现拆分为两个change

依次实现两个change
/opsx:apply ai-chat-backend
/opsx:apply ai-chat-frontend

为什么需要流式输出?

AI 生成回答需要 3‑5 秒 ------ 让用户干等 = 体验灾难,逐字蹦出 = ChatGPT 既视感

SSE vs WebSocket:流式协议怎么选?

维度 SSE(Server‑Sent Events) WebSocket
通信方向 单向:服务器 → 客户端 双向:服务器 ↔ 客户端
底层协议 基于 HTTP(text/event‑stream) 独立协议(ws:/// wss://)
自动重连 浏览器原生支持 需自己写重连逻辑
防火墙穿透 普通 HTTP,天然穿透 可能被企业防火墙拦截
服务端复杂度 低 ------Spring Boot 原生支持 高 ------ 需 WebSocket 框架
类比 广播:电台播你听 电话:双方都能说
适合场景 AI 回答 / 通知推送 / 行情 聊天室 / 协作编辑 / 游戏
🌀 AI 聊天为什么选 SSE?

AI 聊天 = "用户问 + AI 答" 一问一答模式 ------ 用户不会在 AI 输出过程中 "插嘴"。SSE 单向推送已足够,且原生 HTTP 简单可靠

SSE = 广播(够用),WebSocket = 电话(杀鸡用牛刀)------AI 聊天首选 SSE

具体编码

配置文件
yaml 复制代码
spring:
  ai:
    mcp:
      server:
        name: wanderchina-travel-services
        version: 1.0.0
        type: SYNC
      client:
        sse:
          uri: http://localhost:8080/sse
    dashscope:
      # 未配置真实 key 时启动不失败,AiChatService 检测后返回 503 llm_not_configured
      api-key: ${DASHSCOPE_API_KEY:not-configured}
      chat:
        options:
          model: qwen-plus
ChatClient配置类
java 复制代码
package com.mooc.app.config;

import com.mooc.app.service.SpotQueryTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

    public static final String SYSTEM_PROMPT = """
            You are WanderChina, a helpful travel assistant specialized in helping travelers \
            plan trips to China. You can provide recommendations on cities, attractions, \
            local cuisine, transportation, and cultural etiquette. \
            Always respond in English unless the user writes in Chinese. \
            Be concise, practical, and encouraging.

            When reference knowledge is provided below, use it to answer the user's question. \
            Cite sources inline using (Source: <entity_type>: <name>) format. \
            At the end of your response, add a "References:" section listing all sources used. \
            If the provided knowledge does not contain relevant information, answer based on \
            your general knowledge and do not fabricate sources.

            You have access to tools for querying tourist spots. Use them when the user asks \
            about attractions, spot details, or top rated spots in any city.""";

    @Bean
    @ConditionalOnProperty(name = "spring.ai.model.chat", havingValue = "dashscope", matchIfMissing = true)
    public ChatClient chatClient(ChatClient.Builder builder,
                                 @Autowired(required = false) SpotQueryTool spotQueryTool) {
        if (spotQueryTool != null) {
            builder.defaultTools(spotQueryTool);
        }
        return builder
                .defaultSystem(SYSTEM_PROMPT)
                .build();
    }
}
会话实体
java 复制代码
package com.mooc.app.entity;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "ai_conversations", indexes = {
    @Index(name = "idx_ai_conversations_user_id", columnList = "user_id")
})
public class AiConversation extends BaseEntity {

    @Column(name = "user_id")
    private UUID userId;

    @Column(length = 100)
    private String title;

    @Column(name = "last_message_at")
    private Instant lastMessageAt;

    public UUID getUserId() { return userId; }
    public void setUserId(UUID userId) { this.userId = userId; }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public Instant getLastMessageAt() { return lastMessageAt; }
    public void setLastMessageAt(Instant lastMessageAt) { this.lastMessageAt = lastMessageAt; }
}
消息实体
java 复制代码
package com.mooc.app.entity;

import jakarta.persistence.*;
import java.util.UUID;

@Entity
@Table(name = "ai_messages", indexes = {
    @Index(name = "idx_ai_messages_conv_created", columnList = "conversation_id, created_at")
})
public class AiMessage extends BaseEntity {

    @Column(name = "conversation_id", nullable = false)
    private UUID conversationId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private AiMessageRole role;

    @Lob
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    public UUID getConversationId() { return conversationId; }
    public void setConversationId(UUID conversationId) { this.conversationId = conversationId; }

    public AiMessageRole getRole() { return role; }
    public void setRole(AiMessageRole role) { this.role = role; }

    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
}
聊天业务实现类
java 复制代码
package com.framework.service;

import java.util.UUID;

/**
 * AI 聊天服务 --- SSE 流式对话(会话管理 + LLM 流式调用 + 消息持久化)
 */
public interface AiChatService {

    /**
     * 流式事件回调 --- 由 Web 层适配到 SseEmitter,保持 service 层不感知 HTTP
     */
    interface StreamHandler {
        void onConversation(UUID conversationId);
        void onMessage(String content);
        void onDone(UUID messageId);
        void onError(String errorCode, String message);
    }

    /**
     * 发起一轮对话。同步段校验失败抛 BusinessException(走全局异常处理);
     * 流式段异常通过 handler.onError 通知,不抛出。
     */
    void chat(UUID userId, UUID conversationId, String message, StreamHandler handler);
}
java 复制代码
package com.mooc.app.service;

import com.mooc.app.dto.response.AiConversationResponse;
import com.mooc.app.entity.AiConversation;
import com.mooc.app.entity.AiMessage;
import com.mooc.app.entity.AiMessageRole;
import com.mooc.app.exception.AiChatException;
import com.mooc.app.repository.AiConversationRepository;
import com.mooc.app.repository.AiMessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.time.Instant;
import java.util.UUID;

@Service
public class AiChatService {

    private static final Logger log = LoggerFactory.getLogger(AiChatService.class);
    
    private static final long SSE_TIMEOUT_MS = 60_000L;
    private static final int TITLE_MAX_LENGTH = 100;

    private final AiConversationRepository conversationRepository;
    private final AiMessageRepository messageRepository;
    private final ChatClient chatClient;

    public AiChatService(AiConversationRepository conversationRepository,
                        AiMessageRepository messageRepository,
                        ChatClient chatClient) {
        this.conversationRepository = conversationRepository;
        this.messageRepository = messageRepository;
        this.chatClient = chatClient;
    }

    public AiConversationResponse createConversation(UUID userId, String requestId) {
        AiConversation conversation = new AiConversation();
        conversation.setUserId(userId);
        conversation.setLastMessageAt(Instant.now());
        conversationRepository.save(conversation);

        return new AiConversationResponse(
                requestId,
                conversation.getId().toString(),
                conversation.getCreatedAt().toString());
    }

    public SseEmitter sendMessage(UUID conversationId, String message) {
        AiConversation conversation = conversationRepository.findById(conversationId)
                .orElseThrow(() -> new AiChatException(HttpStatus.NOT_FOUND, "not_found",
                        "Conversation not found"));

        AiMessage userMessage = new AiMessage();
        userMessage.setConversationId(conversationId);
        userMessage.setRole(AiMessageRole.USER);
        userMessage.setContent(message);
        messageRepository.save(userMessage);

        if (conversation.getTitle() == null) {
            String title = message.length() > TITLE_MAX_LENGTH
                    ? message.substring(0, TITLE_MAX_LENGTH) : message;
            conversation.setTitle(title);
        }
        conversation.setLastMessageAt(Instant.now());
        conversationRepository.save(conversation);

        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
        StringBuilder fullResponse = new StringBuilder();

        chatClient.prompt()
                .stream()
                .content()
                .subscribe(
                        token -> {
                            fullResponse.append(token);
                            try {
                                emitter.send(SseEmitter.event().name("token").data(token));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE token event [conversationId={}]", conversationId, e);
                            }
                        },
                        error -> {
                            log.error("AI chat stream error [conversationId={}]", conversationId, error);
                            try {
                                emitter.send(SseEmitter.event().name("error").data(error.getMessage()));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE error event", e);
                            }
                            emitter.complete();
                        },
                        () -> {
                            AiMessage assistantMessage = new AiMessage();
                            assistantMessage.setConversationId(conversationId);
                            assistantMessage.setRole(AiMessageRole.ASSISTANT);
                            assistantMessage.setContent(fullResponse.toString());
                            messageRepository.save(assistantMessage);

                            try {
                                emitter.send(SseEmitter.event().name("done").data(""));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE done event", e);
                            }
                            emitter.complete();
                        }
                );

        return emitter;
    }
}
接口层
java 复制代码
package com.mooc.app.controller;

import com.mooc.app.dto.AiChatRequest;
import com.mooc.app.dto.response.AiConversationResponse;
import com.mooc.app.exception.AiChatException;
import com.mooc.app.service.AiChatService;
import com.mooc.app.service.JwtService;
import com.mooc.app.service.RateLimitService;
import com.mooc.app.util.AuthUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.Optional;
import java.util.UUID;

@RestController
public class AiChatController {

    private final AiChatService aiChatService;
    private final JwtService jwtService;
    private final RateLimitService rateLimitService;

    public AiChatController(AiChatService aiChatService, JwtService jwtService, RateLimitService rateLimitService) {
        this.aiChatService = aiChatService;
        this.jwtService = jwtService;
        this.rateLimitService = rateLimitService;
    }

    @PostMapping("/api/ai/conversations")
    public ResponseEntity<AiConversationResponse> createConversation(HttpServletRequest httpRequest) {
        Optional<UUID> userId = AuthUtil.optionalUserId(httpRequest, jwtService);
        checkAnonymousRateLimit(userId, httpRequest);
        String requestId = AuthUtil.getRequestId(httpRequest);
        AiConversationResponse response = aiChatService.createConversation(userId.orElse(null), requestId);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PostMapping("/api/ai/chat")
    public SseEmitter chat(@Valid @RequestBody AiChatRequest request, HttpServletRequest httpRequest) {
        Optional<UUID> userId = AuthUtil.optionalUserId(httpRequest, jwtService);
        checkAnonymousRateLimit(userId, httpRequest);
        return aiChatService.sendMessage(request.conversation_id(), request.message());
    }

    private void checkAnonymousRateLimit(Optional<UUID> userId, HttpServletRequest httpRequest) {
        if (userId.isEmpty()) {
            String ip = httpRequest.getRemoteAddr();
            if (rateLimitService.isAiChatIpRateLimited(ip)) {
                throw new AiChatException(HttpStatus.TOO_MANY_REQUESTS, "rate_limited",
                        "Anonymous users are limited to 20 AI chat requests per day");
            }
        }
    }
}
前端流式返回用户
ts 复制代码
/**
 * 流式聊天:fetch + ReadableStream 按行解析 SSE(event:/data:)
 */
export async function streamAiChat(
  input: { message: string; conversationId?: string },
  opts: { onEvent: (event: AiChatEvent) => void; signal: AbortSignal | null }
): Promise<void> {
  const { onEvent, signal } = opts

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    // 后端 SensitiveFieldFilter 据此跳过响应缓存,保证 SSE 不被提前终结
    Accept: 'text/event-stream',
  }
  const token = localStorage.getItem('access_token')
  if (token) headers.Authorization = `Bearer ${token}`

  let response: Response
  try {
    response = await fetch('/api/ai/chat', {
      method: 'POST',
      headers,
      body: JSON.stringify({
        message: input.message,
        ...(input.conversationId ? { conversation_id: input.conversationId } : {}),
      }),
      signal: signal ?? undefined,
    })
  } catch {
    // 用户主动 abort 不视为错误
    if (signal?.aborted) return
    onEvent({
      type: 'error',
      error_code: 'network_error',
      message: 'Network request failed. Please check your connection and try again.',
    })
    return
  }

  if (!response.ok || !response.body) {
    let error: { error_code?: string; message?: string } = {}
    try {
      error = await response.json()
    } catch {
      // 非 JSON 错误体兜底
    }
    onEvent({
      type: 'error',
      error_code: error.error_code ?? 'internal_error',
      message: error.message ?? 'Unexpected error',
    })
    return
  }

  const reader = response.body.getReader()
  if (signal) {
    signal.addEventListener('abort', () => reader.cancel(), { once: true })
  }

  const decoder = new TextDecoder()
  let buffer = ''
  let currentEvent = ''

  try {
    for (;;) {
      const { done, value } = await reader.read()
      if (done) break
      buffer += decoder.decode(value, { stream: true })

      let newlineIdx: number
      while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
        const line = buffer.slice(0, newlineIdx).trim()
        buffer = buffer.slice(newlineIdx + 1)

        if (line === '') {
          currentEvent = ''
        } else if (line.startsWith('event:')) {
          currentEvent = line.slice(6).trim()
        } else if (line.startsWith('data:')) {
          dispatchSseEvent(currentEvent, line.slice(5).trim(), onEvent)
          currentEvent = ''
        }
      }
    }
  } catch {
    // abort 取消 reader 后静默结束
  }
}

function dispatchSseEvent(event: string, data: string, onEvent: (e: AiChatEvent) => void) {
  let parsed: Record<string, string>
  try {
    parsed = JSON.parse(data)
  } catch {
    return
  }

  switch (event) {
    case 'conversation':
      onEvent({ type: 'conversation', data: { conversation_id: parsed.conversation_id } })
      break
    case 'message':
      onEvent({ type: 'message', data: { content: parsed.content } })
      break
    case 'done':
      onEvent({ type: 'done', data: { message_id: parsed.message_id } })
      break
    case 'error':
      onEvent({
        type: 'error',
        error_code: parsed.error_code ?? 'internal_error',
        message: parsed.message ?? 'Unexpected error',
      })
      break
  }
}
ts 复制代码
export const useChatStore = create<ChatState>((set, get) => ({
  conversations: [],
  conversationsState: 'idle',
  activeConversationId: null,
  messages: [],
  messagesState: 'idle',
  streamingContent: '',
  streamError: null,
  isStreaming: false,

  loadConversations: async () => {
    set({ conversationsState: 'loading' })
    const result = await getConversations()
    if (result.status === 'success') {
      set({ conversations: result.data.items, conversationsState: 'ready' })
    } else {
      set({ conversationsState: 'error' })
    }
  },

  selectConversation: async (id) => {
    if (get().isStreaming) get().stopStreaming()

    if (id === null) {
      set({ activeConversationId: null, messages: [], messagesState: 'idle' })
      return
    }

    set({ activeConversationId: id, messagesState: 'loading' })
    const result = await getMessages(id)
    // 切换过程中又选了别的会话,丢弃过期响应
    if (get().activeConversationId !== id) return

    if (result.status === 'success') {
      set({
        messages: result.data.items.map((m) => ({ id: m.id, role: m.role, content: m.content })),
        messagesState: 'ready',
      })
    } else {
      set({ messagesState: 'error' })
    }
  },

  sendMessage: async (text) => {
    if (get().isStreaming) return

    set((s) => ({
      messages: [...s.messages, { id: null, role: 'user', content: text }],
      isStreaming: true,
      streamingContent: '',
      streamError: null,
    }))

    const controller = new AbortController()
    abortController = controller

    const handleEvent = (event: AiChatEvent) => {
      switch (event.type) {
        case 'conversation':
          // 新会话:后端确定归属后插入侧栏
          if (get().activeConversationId === null) {
            const now = new Date().toISOString()
            set((s) => ({
              activeConversationId: event.data.conversation_id,
              conversations: [
                {
                  id: event.data.conversation_id,
                  title: text.slice(0, 50),
                  last_message_at: now,
                  created_at: now,
                },
                ...s.conversations,
              ],
            }))
          }
          break
        case 'message':
          // 增量只更新 streamingContent,不重建 messages
          set((s) => ({ streamingContent: s.streamingContent + event.data.content }))
          break
        case 'done':
          set((s) => ({
            messages: [
              ...s.messages,
              { id: event.data.message_id, role: 'assistant', content: s.streamingContent },
            ],
            streamingContent: '',
            isStreaming: false,
          }))
          break
        case 'error':
          set({ streamError: { error_code: event.error_code, message: event.message }, streamingContent: '', isStreaming: false })
          break
      }
    }

    await streamAiChat(
      { message: text, conversationId: get().activeConversationId ?? undefined },
      { onEvent: handleEvent, signal: controller.signal }
    )

    // 流自然结束但未收到 done/error 的兜底(如上游连接被重置):已流出的部分内容定稿保留
    if (abortController === controller) {
      abortController = null
      if (get().isStreaming) {
        const partial = get().streamingContent
        set((s) => ({
          isStreaming: false,
          streamingContent: '',
          ...(partial
            ? { messages: [...s.messages, { id: null, role: 'assistant' as const, content: partial }] }
            : {}),
        }))
      }
    }
  },

  stopStreaming: () => {
    abortController?.abort()
    abortController = null
    // spec:已流出的部分内容保留为定稿消息,不静默丢弃(无内容时不追加空消息)
    const partial = get().streamingContent
    set((s) => ({
      isStreaming: false,
      streamingContent: '',
      ...(partial
        ? { messages: [...s.messages, { id: null, role: 'assistant' as const, content: partial }] }
        : {}),
    }))
  },

  deleteConversation: async (id) => {
    const result = await apiDeleteConversation(id)
    if (result.status !== 'success') return

    if (get().activeConversationId === id && get().isStreaming) get().stopStreaming()

    set((s) => ({
      conversations: s.conversations.filter((c) => c.id !== id),
      ...(s.activeConversationId === id
        ? { activeConversationId: null, messages: [], messagesState: 'idle' as ListState }
        : {}),
    }))
  },
}))

RAG知识增强,如何让AI助手回答的更准确?

AI 的致命缺陷:一本正经地胡说八道

真实场景

问 AI: "WanderChina 上评分最高的景点是什么?" → AI 自信回答了一个不存在的景点 → 评分也是编的

局限 说明 WanderChina 的表现
知识截止 训练数据静态,有截止日期 不知道最近新增的景点和帖子
幻觉 没答案时自信编造虚假信息 编造一个不存在的景点 + 评分
无私有数据 无法访问公司数据库 不知道平台上有哪些景点
AWS 经典类比

LLM 像一个 "过于热情的新员工"------ 拒绝学习新知识,但永远绝对自信地回答每一个问题。

解决方案:给他配一本资料库,让他回答前先查资料 = RAG(检索增强生成)

RAG 是什么?先搜后答

RAG = Retrieval‑Augmented Generation(检索增强生成):让 AI 在生成前先检索外部知识库,回答更准、可追溯

不用 RAG

凭记忆回答

问 "北京有什么好玩的"

→ 管理员凭印象回答

→ 可能漏、可能错

→ 用户不信任

用 RAG

查资料后回答

① 检索:去书架找旅游指南

② 取上下文:翻开相关页

③ 生成:基于书的内容回答 → 准确、可验证

用户提问 → ① 检索(Retrieval)→ ② 增强(Augmentation)→ ③ 生成(Generation)→ ④ 溯源(Citation)

一句话:RAG = 先搜后答 ------ 给 "过于热情的新员工" 配资料库,让他不再编故事

RAG 五大优势:业界公认最高性价比方案

优势 说明 WanderChina 场景
消除幻觉 AI 基于真实数据回答 景点 / 评分 / 帖子全部来自 DB
实时信息 知识库可随时更新 新帖子立刻可被检索
来源可追溯 回答附带引用来源 "根据《故宫一日游攻略》..."
私有数据接入 无需重训模型 景点 / 帖子 / 城市数据全部可用
成本高效 比微调便宜 100 倍 不需训练专用模型

数据来源: AWS《什么是检索增强生成》、Google Cloud RAG 白皮书 2025

💡关键认知:RAG 是性价比最高的 AI 升级方案 ------ 不需要重训模型(数百万美元 + 数月时间),只需把数据灌入向量库(数小时 + 几乎零成本)

开始实现

tex 复制代码
/opsx:explore
目前已实现 AI 智能助手。
现在要给 AI 助手加上
RAG--知识库能力,让它基于平台真实数据回答。
请分析当前 代码结构,生成 RAG Spec。

相关问题答案
方案 A:检索增强生成 (RAG) - Embedding + Milvus
配置如下
spring:
  ## Milvus向量数据库配置
    vectorstore:
      milvus:
        client:
          host: "localhost" # default: localhost
          port: 19530 # default: 19530
          username: "" # default: root
          password: "" # default: milvus
        databaseName: "default"
        collectionName: "vector_store" # default: vector_store
        embeddingDimension: 1536 # default: 1536
        indexType: IVF_FLAT # default: IVF_FLAT
        metricType: COSINE # default: COSINE
        initializeSchema: true # 自动创建集合
可以新创建一个集合名collectionName

/opsx:propose rag-knowledgebase

/opsx:apply rag-knowledgebase

RAG 五步 Pipeline:从数据到回答

离线阶段:建知识库(一次性)
  1. 文档切片 Chunking 景点 / 帖子按实体切 每段 500‑1000 字
  2. 向量化 Embedding 每段 → 1536 维向量 语义指纹
  3. 存入 Vector DB 文本 + 向量 + 元数据 Chroma 嵌入式
在线阶段:用户提问时(每次)
  1. 语义检索 Retrieval 问题向量化 → Top‑K 最近邻
  2. 注入 + 生成 检索结果拼到 Prompt LLM 基于真数据答

AI 不再编故事

基于真实数据回答

附带来源引用

Embedding + Vector DB 选型

tex 复制代码
Embedding:文字 → 语义指纹

"北京旅游" → [0.23, -0.15, 0.87, ...]
"首都景点" → [0.21, -0.12, 0.85, ...] ← 距离近!
"Python编程" → [0.91, 0.33, -0.45, ...] ← 距离远!

推荐:通义千问 text‑embedding‑v3 / BGE
向量数据库对比
  • Chroma ------ 轻量嵌入式,pip install 即用
    • 安装:pip install
    • 门槛:极低|适合:原型→中等
  • Milvus ------ 分布式集群,百万级数据
    • 安装:集群部署
    • 门槛:中高|适合:大规模生产
  • Pinecone ------ 云端托管,零运维
    • 安装:云服务
    • 门槛:低|适合:大规模生产
  • pgvector ------ PG 扩展,无需新组件
    • 安装:PostgreSQL 扩展,数据库执行CREATE EXTENSION vector;;需要数据库版本支持 pgvector 插件
    • 门槛:低,复用现有 PG,不用部署独立服务;需要掌握少量向量索引调优

WanderChina 选 Chroma: 轻量 + SpringAI 原生支持

核心三层代码架构
具体实现
RAG数据源配置层
java 复制代码
package com.mooc.app.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chroma.vectorstore.ChromaApi;
import org.springframework.ai.chroma.vectorstore.ChromaVectorStore;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

import java.net.http.HttpClient;
import java.time.Duration;

@Configuration
public class RagConfig {

    private static final Logger log = LoggerFactory.getLogger(RagConfig.class);
    private static final String DEFAULT_TENANT = "default_tenant";
    private static final String DEFAULT_DATABASE = "default_database";

    @Bean
    @ConditionalOnProperty(name = "app.rag.enabled", havingValue = "true", matchIfMissing = true)
    public VectorStore chromaVectorStore(EmbeddingModel embeddingModel,
                                         @Value("${app.chroma.host:http://localhost}") String host,
                                         @Value("${app.chroma.port:8000}") int port,
                                         @Value("${app.chroma.collection-name:wanderchina-knowledge}") String collectionName) {
        
        // 配置自定义 HttpClient 增加超时
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(30))
                .build();
        
        JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
        requestFactory.setReadTimeout(Duration.ofSeconds(60));
        
        RestClient.Builder restClientBuilder = RestClient.builder()
                .requestFactory(requestFactory);
        
        ChromaApi chromaApi = ChromaApi.builder()
                .baseUrl(host + ":" + port)
                .restClientBuilder(restClientBuilder)
                .build();

        ensureCollectionExists(chromaApi, collectionName);

        return ChromaVectorStore.builder(chromaApi, embeddingModel)
                .tenantName(DEFAULT_TENANT)
                .databaseName(DEFAULT_DATABASE)
                .collectionName(collectionName)
                .initializeSchema(true)
                .build();
    }

    private void ensureCollectionExists(ChromaApi chromaApi, String collectionName) {
        try {
            ChromaApi.Collection existing = chromaApi.getCollection(DEFAULT_TENANT, DEFAULT_DATABASE, collectionName);
            if (existing != null) {
                log.info("Chroma collection '{}' already exists", collectionName);
                return;
            }
        } catch (Exception e) {
            log.debug("Collection '{}' not found, will create: {}", collectionName, e.getMessage());
        }

        try {
            ensureTenantExists(chromaApi, DEFAULT_TENANT);
            ensureDatabaseExists(chromaApi, DEFAULT_TENANT, DEFAULT_DATABASE);
            chromaApi.createCollection(DEFAULT_TENANT, DEFAULT_DATABASE,
                    new ChromaApi.CreateCollectionRequest(collectionName));
            log.info("Created Chroma collection '{}'", collectionName);
        } catch (Exception e) {
            log.warn("Failed to create Chroma collection '{}': {}", collectionName, e.getMessage());
        }
    }

    private void ensureTenantExists(ChromaApi chromaApi, String tenantName) {
        try {
            chromaApi.getTenant(tenantName);
        } catch (Exception e) {
            chromaApi.createTenant(tenantName);
        }
    }

    private void ensureDatabaseExists(ChromaApi chromaApi, String tenantName, String databaseName) {
        try {
            chromaApi.getDatabase(tenantName, databaseName);
        } catch (Exception e) {
            chromaApi.createDatabase(databaseName, tenantName);
        }
    }

    @Bean
    @ConditionalOnProperty(name = "app.rag.enabled", havingValue = "false")
    public VectorStore inMemoryVectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}
配置文件
yaml 复制代码
spring:
  ai:
    dashscope:
      embedding:
        options:
          model: text-embedding-v3
app:
  chroma:
    host: ${CHROMA_HOST:http://localhost}
      port: ${CHROMA_PORT:8000}
      collection-name: wanderchina-knowledge
  rag:
    enabled: true
    top-k: 5
    similarity-threshold: 0.3
    rebuild-cron: "0 0 */6 * * *"
    max-chunk-size: 800
    chunk-overlap: 100
  search:
    rrf-k: 60
    vector-top-k: 10
    keyword-top-k: 10
    suggest-top-k: 5
    similarity-threshold: 0.3
  enrichment:
    cron: "0 0 2 * * *"
    stale-days: 7
    critical-days: 30
RAG知识库知识层
java 复制代码
package com.mooc.app.service;

import com.mooc.app.entity.CityEntity;
import com.mooc.app.entity.PostEntity;
import com.mooc.app.entity.PostStatus;
import com.mooc.app.entity.SpotEntity;
import com.mooc.app.entity.SpotStatus;
import com.mooc.app.repository.CityRepository;
import com.mooc.app.repository.PostRepository;
import com.mooc.app.repository.SpotRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class KnowledgeBuilderService {

    private static final Logger log = LoggerFactory.getLogger(KnowledgeBuilderService.class);
    private static final int POST_LONG_CONTENT_THRESHOLD = 1000;
    private static final int EMBEDDING_BATCH_SIZE = 2;

    private final CityRepository cityRepository;
    private final SpotRepository spotRepository;
    private final PostRepository postRepository;
    private final VectorStore vectorStore;
    private final int maxChunkSize;
    private final int chunkOverlap;

    public KnowledgeBuilderService(CityRepository cityRepository,
                                    SpotRepository spotRepository,
                                    PostRepository postRepository,
                                    VectorStore vectorStore,
                                    @Value("${app.rag.max-chunk-size:800}") int maxChunkSize,
                                    @Value("${app.rag.chunk-overlap:100}") int chunkOverlap) {
        this.cityRepository = cityRepository;
        this.spotRepository = spotRepository;
        this.postRepository = postRepository;
        this.vectorStore = vectorStore;
        this.maxChunkSize = maxChunkSize;
        this.chunkOverlap = chunkOverlap;
    }

    @Async
    public void rebuildAllAsync() {
        rebuildAll();
    }

    public void rebuildAll() {
        Instant start = Instant.now();

        List<Document> allDocuments = new ArrayList<>();
        allDocuments.addAll(buildCityDocuments());
        allDocuments.addAll(buildSpotDocuments());
        allDocuments.addAll(buildPostDocuments());

        if (!allDocuments.isEmpty()) {
            for (int i = 0; i < allDocuments.size(); i += EMBEDDING_BATCH_SIZE) {
                List<Document> batch = allDocuments.subList(i, Math.min(i + EMBEDDING_BATCH_SIZE, allDocuments.size()));
                vectorStore.add(batch);
            }
        }

        Duration elapsed = Duration.between(start, Instant.now());
        log.info("Knowledge base rebuilt: {} documents indexed in {}ms",
                allDocuments.size(), elapsed.toMillis());
    }

    List<Document> buildCityDocuments() {
        return cityRepository.findAll().stream()
                .filter(city -> !city.isDeleted())
                .map(this::toCityDocument)
                .collect(Collectors.toList());
    }

    List<Document> buildSpotDocuments() {
        return spotRepository.findAll().stream()
                .filter(spot -> !spot.isDeleted() && spot.getStatus() == SpotStatus.PUBLISHED)
                .map(this::toSpotDocument)
                .collect(Collectors.toList());
    }

    List<Document> buildPostDocuments() {
        return postRepository.findAll().stream()
                .filter(post -> !post.isDeleted() && post.getStatus() == PostStatus.PUBLISHED)
                .flatMap(post -> toPostDocuments(post).stream())
                .collect(Collectors.toList());
    }

    private Document toCityDocument(CityEntity city) {
        String text = String.format("City: %s (%s)\nDescription: %s\nBest Season: %s",
                city.getName(),
                city.getNameZh() != null ? city.getNameZh() : "",
                city.getDescription() != null ? city.getDescription() : "",
                city.getBestSeason() != null ? city.getBestSeason() : "");

        Map<String, Object> metadata = new HashMap<>();
        metadata.put("entity_type", "city");
        metadata.put("slug", city.getSlug());
        metadata.put("name", city.getName());
        if (city.getNameZh() != null) {
            metadata.put("name_zh", city.getNameZh());
        }

        return new Document(text, metadata);
    }

    private Document toSpotDocument(SpotEntity spot) {
        String tags = spot.getTags() != null ? String.join(", ", spot.getTags()) : "";
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Spot: %s (%s)\nCity: %s\nTags: %s\nRating: %s",
                spot.getName(),
                spot.getNameZh() != null ? spot.getNameZh() : "",
                spot.getCityName() != null ? spot.getCityName() : "",
                tags,
                spot.getRating()));
        if (spot.getTicketPrice() != null) {
            sb.append("\nTicket Price: ").append(spot.getTicketPrice());
        }
        if (spot.getOpeningHours() != null) {
            sb.append("\nOpening Hours: ").append(spot.getOpeningHours());
        }
        if (spot.getAddress() != null) {
            sb.append("\nAddress: ").append(spot.getAddress());
        }
        if (spot.getDescription() != null) {
            sb.append("\nDescription: ").append(spot.getDescription());
        }

        Map<String, Object> metadata = new HashMap<>();
        metadata.put("entity_type", "spot");
        metadata.put("slug", spot.getSlug());
        metadata.put("name", spot.getName());
        if (spot.getNameZh() != null) {
            metadata.put("name_zh", spot.getNameZh());
        }
        if (spot.getCityName() != null) {
            metadata.put("city_name", spot.getCityName());
        }
        if (!tags.isEmpty()) {
            metadata.put("tags", String.join(",", spot.getTags()));
        }

        return new Document(sb.toString(), metadata);
    }

    private List<Document> toPostDocuments(PostEntity post) {
        String tags = post.getTags() != null ? String.join(", ", post.getTags()) : "";
        String text = String.format("Post: %s\nTags: %s\nContent: %s",
                post.getTitle(),
                tags,
                post.getContent());

        Map<String, Object> metadata = new HashMap<>();
        metadata.put("entity_type", "post");
        metadata.put("slug", post.getSlug());
        metadata.put("title", post.getTitle());
        if (!tags.isEmpty()) {
            metadata.put("tags", String.join(",", post.getTags()));
        }

        Document doc = new Document(text, metadata);

        if (post.getContent().length() > POST_LONG_CONTENT_THRESHOLD) {
            TokenTextSplitter splitter = TokenTextSplitter.builder()
                    .withChunkSize(maxChunkSize)
                    .withMinChunkSizeChars(chunkOverlap)
                    .build();
            List<Document> chunks = splitter.apply(List.of(doc));
            chunks.forEach(chunk -> chunk.getMetadata().putAll(metadata));
            return chunks;
        }

        return List.of(doc);
    }

    /**
     * Incrementally refresh a single spot's document in the vector store.
     * Deletes the old document (by document ID built from slug) and writes a new one.
     */
    public void refreshSpotDocument(SpotEntity spot) {
        String documentId = "spot-" + spot.getSlug();
        try {
            vectorStore.delete(List.of(documentId));
            log.debug("Deleted old spot document: {}", documentId);
        } catch (Exception e) {
            log.warn("Failed to delete old spot document {}: {}", documentId, e.getMessage());
        }
        Document newDoc = toSpotDocument(spot);
        // Override the auto-generated ID with our stable slug-based ID
        Document stableDoc = new Document(documentId, newDoc.getText(), newDoc.getMetadata());
        vectorStore.add(List.of(stableDoc));
        log.info("Refreshed spot document: {} ({})", spot.getName(), spot.getSlug());
    }
}
RAG知识库定时重建
java 复制代码
package com.mooc.app.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class KnowledgeIndexScheduler {

    private static final Logger log = LoggerFactory.getLogger(KnowledgeIndexScheduler.class);

    private final KnowledgeBuilderService knowledgeBuilderService;

    public KnowledgeIndexScheduler(KnowledgeBuilderService knowledgeBuilderService) {
        this.knowledgeBuilderService = knowledgeBuilderService;
    }

    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady() {
        log.info("Application ready, triggering initial knowledge base build");
        knowledgeBuilderService.rebuildAllAsync();
    }

    @Scheduled(cron = "${app.rag.rebuild-cron:0 0 */6 * * *}")
    public void scheduledRebuild() {
        log.info("Scheduled knowledge base rebuild triggered");
        knowledgeBuilderService.rebuildAll();
    }
}
RAG检索知识层
java 复制代码
package com.mooc.app.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class KnowledgeSearchService {

    private static final Logger log = LoggerFactory.getLogger(KnowledgeSearchService.class);

    private final VectorStore vectorStore;
    private final int topK;
    private final double similarityThreshold;

    public KnowledgeSearchService(VectorStore vectorStore,
                                   @Value("${app.rag.top-k:5}") int topK,
                                   @Value("${app.rag.similarity-threshold:0.3}") double similarityThreshold) {
        this.vectorStore = vectorStore;
        this.topK = topK;
        this.similarityThreshold = similarityThreshold;
    }

    public List<Document> search(String query) {
        return search(query, null);
    }

    public List<Document> search(String query, String cityName) {
        SearchRequest.Builder builder = SearchRequest.builder()
                .query(query)
                .topK(topK)
                .similarityThreshold(similarityThreshold);

        if (cityName != null && !cityName.isBlank()) {
            builder.filterExpression("city_name == '" + cityName + "'");
            log.debug("Knowledge search with city filter: {}", cityName);
        }

        List<Document> results = vectorStore.similaritySearch(builder.build());
        log.debug("Knowledge search returned {} results for query: {}", results.size(), query);
        return results;
    }
}
集成层聊天实现类
java 复制代码
package com.mooc.app.service;

import com.mooc.app.dto.response.AiConversationResponse;
import com.mooc.app.entity.AiConversation;
import com.mooc.app.entity.AiMessage;
import com.mooc.app.entity.AiMessageRole;
import com.mooc.app.exception.AiChatException;
import com.mooc.app.repository.AiConversationRepository;
import com.mooc.app.repository.AiMessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.document.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
public class AiChatService {

    private static final Logger log = LoggerFactory.getLogger(AiChatService.class);

    private static final int CONTEXT_WINDOW_ROUNDS = 10;
    private static final long SSE_TIMEOUT_MS = 60_000L;
    private static final int TITLE_MAX_LENGTH = 100;

    private final AiConversationRepository conversationRepository;
    private final AiMessageRepository messageRepository;
    private final ChatClient chatClient;
    private final KnowledgeSearchService knowledgeSearchService;

    public AiChatService(AiConversationRepository conversationRepository,
                        AiMessageRepository messageRepository,
                        ChatClient chatClient,
                        @Autowired(required = false) KnowledgeSearchService knowledgeSearchService) {
        this.conversationRepository = conversationRepository;
        this.messageRepository = messageRepository;
        this.chatClient = chatClient;
        this.knowledgeSearchService = knowledgeSearchService;
    }

    public AiConversationResponse createConversation(UUID userId, String requestId) {
        AiConversation conversation = new AiConversation();
        conversation.setUserId(userId);
        conversation.setLastMessageAt(Instant.now());
        conversationRepository.save(conversation);

        return new AiConversationResponse(
                requestId,
                conversation.getId().toString(),
                conversation.getCreatedAt().toString());
    }

    public SseEmitter sendMessage(UUID conversationId, String message) {
        AiConversation conversation = conversationRepository.findById(conversationId)
                .orElseThrow(() -> new AiChatException(HttpStatus.NOT_FOUND, "not_found",
                        "Conversation not found"));

        AiMessage userMessage = new AiMessage();
        userMessage.setConversationId(conversationId);
        userMessage.setRole(AiMessageRole.USER);
        userMessage.setContent(message);
        messageRepository.save(userMessage);

        if (conversation.getTitle() == null) {
            String title = message.length() > TITLE_MAX_LENGTH
                    ? message.substring(0, TITLE_MAX_LENGTH) : message;
            conversation.setTitle(title);
        }
        conversation.setLastMessageAt(Instant.now());
        conversationRepository.save(conversation);

        List<Document> knowledgeResults = searchKnowledge(message);
        List<Message> contextMessages = buildContextMessages(conversationId, knowledgeResults);

        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
        StringBuilder fullResponse = new StringBuilder();

        chatClient.prompt()
                .messages(contextMessages)
                .stream()
                .content()
                .subscribe(
                        token -> {
                            fullResponse.append(token);
                            try {
                                emitter.send(SseEmitter.event().name("token").data(token));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE token event [conversationId={}]", conversationId, e);
                            }
                        },
                        error -> {
                            log.error("AI chat stream error [conversationId={}]", conversationId, error);
                            try {
                                emitter.send(SseEmitter.event().name("error").data(error.getMessage()));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE error event", e);
                            }
                            emitter.complete();
                        },
                        () -> {
                            AiMessage assistantMessage = new AiMessage();
                            assistantMessage.setConversationId(conversationId);
                            assistantMessage.setRole(AiMessageRole.ASSISTANT);
                            assistantMessage.setContent(fullResponse.toString());
                            messageRepository.save(assistantMessage);

                            try {
                                emitter.send(SseEmitter.event().name("done").data(""));
                            } catch (Exception e) {
                                log.warn("Failed to send SSE done event", e);
                            }
                            emitter.complete();
                        }
                );

        return emitter;
    }

    private List<Document> searchKnowledge(String query) {
        if (knowledgeSearchService == null) {
            log.debug("Knowledge search service not available, skipping RAG retrieval");
            return List.of();
        }
        try {
            List<Document> results = knowledgeSearchService.search(query);
            log.debug("RAG retrieval returned {} documents for query: {}", results.size(), query);
            return results;
        } catch (Exception e) {
            log.warn("RAG retrieval failed, proceeding without knowledge context", e);
            return List.of();
        }
    }

    List<Message> buildContextMessages(UUID conversationId, List<Document> knowledgeResults) {
        List<AiMessage> allMessages = messageRepository
                .findByConversationIdAndDeletedFalseOrderByCreatedAtAsc(conversationId);

        int maxMessages = CONTEXT_WINDOW_ROUNDS * 2 + 1;
        List<AiMessage> windowedMessages;
        if (allMessages.size() > maxMessages) {
            windowedMessages = allMessages.subList(allMessages.size() - maxMessages, allMessages.size());
        } else {
            windowedMessages = allMessages;
        }

        List<Message> contextMessages = new ArrayList<>();

        if (!knowledgeResults.isEmpty()) {
            contextMessages.add(buildKnowledgeSystemMessage(knowledgeResults));
        }

        for (AiMessage msg : windowedMessages) {
            if (msg.getRole() == AiMessageRole.USER) {
                contextMessages.add(new UserMessage(msg.getContent()));
            } else {
                contextMessages.add(new AssistantMessage(msg.getContent()));
            }
        }
        return contextMessages;
    }

    private SystemMessage buildKnowledgeSystemMessage(List<Document> documents) {
        String knowledgeContext = documents.stream()
                .map(doc -> {
                    String entityType = (String) doc.getMetadata().getOrDefault("entity_type", "unknown");
                    String name = (String) doc.getMetadata().getOrDefault("name",
                            doc.getMetadata().getOrDefault("title", "unknown"));
                    return String.format("[%s: %s]\n%s", entityType, name, doc.getText());
                })
                .collect(Collectors.joining("\n\n---\n\n"));

        return new SystemMessage(
                "Reference knowledge (use this to answer the user's question and cite sources):\n\n"
                        + knowledgeContext);
    }
}

RAG 调优 + 业界进化路径

业界基准(arXiv:2406.04744):LLM 直答 34% → Naive RAG 44% → 最先进 RAG 63%

调优旋钮
旋钮① Chunk 大小
  • 太小→丢上下文
  • 太大→检索不准
  • 经验值:500‑1000 字
旋钮② Top‑K
  • K=1 漏信息
  • K=20 噪声多
  • 经验值:K=5
旋钮③ Prompt 模板
  • 强约束:"基于资料"
  • "无内容请说不知道"
  • 要求引用来源
业界 RAG 进化路径(2024‑2025)
  1. Naive RAG:基础版:切片→向量化→检索→生成
  2. Advanced RAG:+ Hybrid Search + Reranking(混合检索 + 重排序)
  3. GraphRAG:+ 知识图谱多跳推理
  4. Agentic RAG:AI 自主决策何时检索

WanderChina 当前 = Naive RAG;下一步可叠加 Hybrid Search + Reranking(性价比最高)

AIGC实践:Function Calling + 智能推荐

Function Calling vs MCP:两种 "AI 调工具"

维度 MCP(Model Context Protocol) Function Calling
本质 开放标准协议(Anthropic + 社区) 模型内置能力(各家 LLM 原生)
工作方式 AI 通过 JSON‑RPC 连外部 MCP Server LLM 直接调用应用内注册的函数
集成深度 松耦合:Client / Server 独立运行 紧耦合:函数注册在应用进程内
跨平台 标准协议,任何 MCP Client 都能连任何 Server 依赖具体框架(如 Spring AI 的 @Bean 注册)
适用场景 跨系统 / 跨工具共享能力 应用内调用自己的业务 API
课程对照 ch12:Qoder 调汇率 / 天气 ch13:WanderChina AI 调景点 / 帖子

📌 WanderChina 为什么选 Function Calling? AI 要调的是项目自己的业务 API(查景点 / 查帖子)------ 这些函数就跑在 WanderChina 后端进程里, 用 Spring AI 的 @Bean 注册一下就行,不需要额外跑 MCP Server。

如果未来要把 "查景点" 开放给其他 AI 应用复用 ------ 那时再升级为 MCP Server。

同一思路,不同层次:MCP = 协议层标准(USB),Function Calling = 应用层集成(内置模块)

开始实现

tex 复制代码
/opsx:explore 给 WanderChina 发帖加 AI 辅助(生成标题 / 推荐标签 / 润色),
同时注册 Function Calling Bean 让 AI 助手能查景点;
请分析现状产出 Spec。

具体代码

工具层
java 复制代码
package com.mooc.app.service;

import com.mooc.app.dto.response.SpotRankingResponse;
import com.mooc.app.dto.response.SpotResponse;
import com.mooc.app.entity.SpotEntity;
import com.mooc.app.repository.SpotRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

@Component
public class SpotQueryTool {

    private static final int MAX_RESULTS_PER_QUERY = 10;

    private final SpotRepository spotRepository;
    private final SpotService spotService;

    public SpotQueryTool(SpotRepository spotRepository, SpotService spotService) {
        this.spotRepository = spotRepository;
        this.spotService = spotService;
    }

    @Tool(description = "Search for tourist spots in a specific city. Returns a list of spots with name, rating, and tags.")
    public String searchSpotsByCity(
            @ToolParam(description = "City slug, e.g. 'Hangzhou', 'Beijing', 'Shanghai'") String citySlug) {
        Page<SpotEntity> page = spotRepository.findByCitySlugAndDeletedFalse(
                citySlug,
                PageRequest.of(0, MAX_RESULTS_PER_QUERY, Sort.by(Sort.Direction.DESC, "rating")));

        if (page.isEmpty()) {
            return "No spots found in " + citySlug;
        }

        return page.getContent().stream()
                .map(spot -> String.format("- %s (%s) | Rating: %s | Tags: %s",
                        spot.getName(),
                        spot.getNameZh() != null ? spot.getNameZh() : "",
                        spot.getRating() != null ? spot.getRating().toPlainString() : "N/A",
                        String.join(", ", spot.getTags())))
                .collect(Collectors.joining("\n"));
    }

    @Tool(description = "Get detailed information about a specific tourist spot including description, tags, rating, and gallery.")
    public String getSpotDetails(
            @ToolParam(description = "Spot slug or name identifier, e.g. 'lingyin-temple', 'west-lake'") String nameOrSlug) {
        Optional<SpotEntity> optionalSpot = spotRepository.findBySlugAndDeletedFalse(nameOrSlug);
        if (optionalSpot.isEmpty()) {
            return "Spot not found: " + nameOrSlug;
        }

        SpotEntity spot = optionalSpot.get();
        StringBuilder sb = new StringBuilder();
        sb.append("Name: ").append(spot.getName());
        if (spot.getNameZh() != null) {
            sb.append(" (").append(spot.getNameZh()).append(")");
        }
        sb.append("\nCity: ").append(spot.getCityName() != null ? spot.getCityName() : "Unknown");
        sb.append("\nRating: ").append(spot.getRating() != null ? spot.getRating().toPlainString() : "N/A");
        sb.append("\nTags: ").append(String.join(", ", spot.getTags()));
        if (spot.getTicketPrice() != null) {
            sb.append("\nTicket Price: ").append(spot.getTicketPrice());
        }
        if (spot.getOpeningHours() != null) {
            sb.append("\nOpening Hours: ").append(spot.getOpeningHours());
        }
        if (spot.getAddress() != null) {
            sb.append("\nAddress: ").append(spot.getAddress());
        }
        if (spot.getDescription() != null) {
            sb.append("\nDescription: ").append(spot.getDescription());
        }
        if (spot.getDescriptionZh() != null) {
            sb.append("\nDescription (Chinese): ").append(spot.getDescriptionZh());
        }
        if (spot.getGallery() != null && !spot.getGallery().isEmpty()) {
            sb.append("\nGallery: ").append(String.join(", ", spot.getGallery()));
        }
        return sb.toString();
    }

    @Tool(description = "Get the top rated tourist spots across all cities. Returns spots sorted by rating.")
    public String getTopRatedSpots(
            @ToolParam(description = "Number of top spots to return, between 1 and 10") int limit) {
        int effectiveLimit = Math.min(Math.max(limit, 1), MAX_RESULTS_PER_QUERY);
        SpotRankingResponse response = spotService.getRanking(
                "rating", effectiveLimit, UUID.randomUUID().toString());

        if (response.getItems().isEmpty()) {
            return "No top rated spots available";
        }

        return response.getItems().stream()
                .map(spot -> String.format("- %s (%s) | Rating: %s | City: %s",
                        spot.getName(),
                        spot.getNameZh() != null ? spot.getNameZh() : "",
                        spot.getRating(),
                        spot.getCityName() != null ? spot.getCityName() : ""))
                .collect(Collectors.joining("\n"));
    }
}
ChatClient配置类告诉Agent有哪些工具可用

AI辅助发帖

页面效果
具体代码
会话层
java 复制代码
package com.mooc.app.controller;

import com.mooc.app.dto.AiPostAssistRequest;
import com.mooc.app.dto.response.AiPostAssistResponse;
import com.mooc.app.service.AiPostAssistService;
import com.mooc.app.service.JwtService;
import com.mooc.app.util.AuthUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AiPostAssistController {

    private final AiPostAssistService aiPostAssistService;
    private final JwtService jwtService;

    public AiPostAssistController(AiPostAssistService aiPostAssistService, JwtService jwtService) {
        this.aiPostAssistService = aiPostAssistService;
        this.jwtService = jwtService;
    }

    @PostMapping("/api/ai/post-assist")
    public ResponseEntity<AiPostAssistResponse> assist(
            @Valid @RequestBody AiPostAssistRequest request,
            HttpServletRequest httpRequest) {
        AuthUtil.requireUserId(httpRequest, jwtService);
        String requestId = AuthUtil.getRequestId(httpRequest);
        AiPostAssistResponse response = aiPostAssistService.assist(request, requestId);
        return ResponseEntity.ok(response);
    }
}
请求实体
java 复制代码
package com.mooc.app.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record AiPostAssistRequest(
        @NotBlank(message = "Action must not be blank")
        @Pattern(regexp = "generate_title|recommend_tags|polish", message = "Invalid action")
        String action,

        @NotBlank(message = "Content must not be blank")
        @Size(max = 50000, message = "Content must not exceed 50000 characters")
        String content,

        @Size(max = 200, message = "Title must not exceed 200 characters")
        String title
) {}
业务层
java 复制代码
package com.mooc.app.service;

import com.mooc.app.dto.AiPostAssistRequest;
import com.mooc.app.dto.response.AiPostAssistResponse;
import com.mooc.app.exception.AiPostAssistException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

@Service
public class AiPostAssistService {

    private static final Logger log = LoggerFactory.getLogger(AiPostAssistService.class);

    private static final String GENERATE_TITLE_SYSTEM_PROMPT = """
            You are a travel content editor. Generate a compelling, SEO-friendly title \
            (max 200 characters) for a travel blog post based on its content. \
            Return ONLY the title, no quotes, no extra explanation.""";

    private static final String RECOMMEND_TAGS_SYSTEM_PROMPT = """
            You are a travel content editor. Recommend 3-8 relevant tags for a travel blog post. \
            Return ONLY a JSON array of strings, e.g. ["culture","food","history"]. \
            No extra explanation, no markdown fences.""";

    private static final String POLISH_SYSTEM_PROMPT = """
            You are a professional travel editor. Rewrite the following Markdown content to \
            improve readability, grammar, and engagement. Keep the Markdown formatting, \
            preserve core information, and use vivid but concise language. \
            Return ONLY the polished Markdown, no extra explanation.""";

    private static final int MAX_TITLE_LENGTH = 200;

    private final ChatClient chatClient;

    public AiPostAssistService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public AiPostAssistResponse assist(AiPostAssistRequest request, String requestId) {
        String result = switch (request.action()) {
            case "generate_title" -> generateTitle(request.content());
            case "recommend_tags" -> recommendTags(request.content(), request.title());
            case "polish" -> polish(request.content());
            default -> throw new AiPostAssistException(HttpStatus.BAD_REQUEST,
                    "invalid_action", "Unsupported action: " + request.action());
        };
        return new AiPostAssistResponse(requestId, result);
    }

    String generateTitle(String content) {
        log.debug("Generating title from content ({} chars)", content.length());
        String result = chatClient.prompt()
                .system(GENERATE_TITLE_SYSTEM_PROMPT)
                .user(content)
                .call()
                .content();
        if (result == null || result.isBlank()) {
            throw new AiPostAssistException(HttpStatus.INTERNAL_SERVER_ERROR,
                    "ai_generation_failed", "AI returned empty title");
        }
        result = result.trim();
        if (result.length() > MAX_TITLE_LENGTH) {
            result = result.substring(0, MAX_TITLE_LENGTH);
        }
        return result;
    }

    String recommendTags(String content, String title) {
        log.debug("Recommending tags from content ({} chars)", content.length());
        String userPrompt = title != null && !title.isBlank()
                ? "Title: " + title + "\n\nContent:\n" + content
                : content;
        String result = chatClient.prompt()
                .system(RECOMMEND_TAGS_SYSTEM_PROMPT)
                .user(userPrompt)
                .call()
                .content();
        if (result == null || result.isBlank()) {
            throw new AiPostAssistException(HttpStatus.INTERNAL_SERVER_ERROR,
                    "ai_generation_failed", "AI returned empty tags");
        }
        // Strip markdown fences if present
        result = result.trim();
        if (result.startsWith("```")) {
            result = result.replaceAll("^```[a-zA-Z]*\\n?", "").replaceAll("```$", "").trim();
        }
        return result;
    }

    String polish(String content) {
        log.debug("Polishing content ({} chars)", content.length());
        String result = chatClient.prompt()
                .system(POLISH_SYSTEM_PROMPT)
                .user(content)
                .call()
                .content();
        if (result == null || result.isBlank()) {
            throw new AiPostAssistException(HttpStatus.INTERNAL_SERVER_ERROR,
                    "ai_generation_failed", "AI returned empty polished content");
        }
        return result;
    }
}

将网站的传统搜索升级为AI全栈搜索,如何通过语义搜索替代关键词搜索提升用户体验?

关键词搜索的三大致命缺陷

根因

WHERE content LIKE '% 暖和 %' -- 字面有就匹配,没有就没结果。关键词搜索 = 翻字典,不懂意思

我们需要的不是 "找字",而是 "懂意思" -- 语义搜索就是解决方案

语义搜索原理:向量空间的 "意思匹配"

关键词管理员

你说 "暖和的地方"

他翻遍每本书的目录

找有没有 "暖和" 两个字

没找到 → 摇头说 "没有"

语义管理员

你说 "暖和的地方"

他想了想 "这人想去温暖的旅游地"

走到旅游区,抽出《三亚指南》

递给你说 "你要的在这"

向量空间原理

"暖和的地方" → 0.31, -0.08, 0.72, ...

三亚帖子  → 0.29, -0.10, 0.69, ...

余弦相似度 > 0.9 → 匹配成功!

Embedding 模型把文本变成向量

意思相近的文本 → 向量距离近

L4 已把所有数据向量化 → 直接复用

Hybrid Search:为什么两路比一路好?

纯语义搜索的问题

用户搜 "故宫" -- 精确意图,就要故宫

语义搜索可能带出 "颐和园""长城"

因为它们语义相近,但用户要的是精确匹配

关键词检索 → 精确匹配 "故宫"

语义检索 → 理解 "暖和地方"

RRF 融合 → 两路结果取长补短


混合检索四步流水线

关键词检索 LIKE / BM25 精确匹配

语义检索 向量相似度 意图理解

RRF 融合 排名倒数相加 两路取长补短

返回 Top‑K 按融合分数排序 输出最终结果

实现语义搜索的提示词

tex 复制代码
/opsx:explore
给 WanderChina 加语义搜索:复用向量数据库,
支持自然语言查询 + 混合检索(向量 + 关键词);
请分析现有搜索代码生成 Spec。

具体实现

会话层
java 复制代码
package com.mooc.app.controller;

import com.mooc.app.dto.response.SearchResponse;
import com.mooc.app.dto.response.SearchSuggestItem;
import com.mooc.app.dto.response.SearchSuggestResponse;
import com.mooc.app.service.HybridSearchService;
import com.mooc.app.service.KeywordResult;
import com.mooc.app.service.KeywordSearchService;
import com.mooc.app.util.AuthUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/search")
@Validated
public class SearchController {

    private final HybridSearchService hybridSearchService;
    private final KeywordSearchService keywordSearchService;

    public SearchController(HybridSearchService hybridSearchService,
                            KeywordSearchService keywordSearchService) {
        this.hybridSearchService = hybridSearchService;
        this.keywordSearchService = keywordSearchService;
    }

    @GetMapping
    public ResponseEntity<SearchResponse> search(
            @RequestParam @NotBlank @Size(max = 200) String q,
            @RequestParam(required = false) String type,
            @RequestParam(required = false) String city,
            HttpServletRequest httpRequest) {
        String requestId = AuthUtil.getRequestId(httpRequest);
        SearchResponse response = hybridSearchService.search(q, type, city, requestId);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/suggest")
    public ResponseEntity<SearchSuggestResponse> suggest(
            @RequestParam @NotBlank @Size(max = 200) String q,
            HttpServletRequest httpRequest) {
        String requestId = AuthUtil.getRequestId(httpRequest);
        List<KeywordResult> results = keywordSearchService.suggest(q);
        List<SearchSuggestItem> items = results.stream()
                .map(r -> new SearchSuggestItem(r.type(), r.slug(), r.name(), null))
                .toList();
        return ResponseEntity.ok(new SearchSuggestResponse(requestId, items));
    }
}
核心业务层实现类
java 复制代码
package com.mooc.app.service;

import com.mooc.app.entity.CityEntity;
import com.mooc.app.entity.PostEntity;
import com.mooc.app.entity.SpotEntity;
import com.mooc.app.repository.CityRepository;
import com.mooc.app.repository.PostRepository;
import com.mooc.app.repository.SpotRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

@Service
public class KeywordSearchService {

    private final SpotRepository spotRepository;
    private final PostRepository postRepository;
    private final CityRepository cityRepository;

    @Value("${app.search.keyword-top-k:10}")
    private int keywordTopK;

    @Value("${app.search.suggest-top-k:5}")
    private int suggestTopK;

    public KeywordSearchService(SpotRepository spotRepository,
                                PostRepository postRepository,
                                CityRepository cityRepository) {
        this.spotRepository = spotRepository;
        this.postRepository = postRepository;
        this.cityRepository = cityRepository;
    }

    public List<KeywordResult> search(String query) {
        return doSearch(query, keywordTopK);
    }

    public List<KeywordResult> suggest(String query) {
        return doSearch(query, suggestTopK);
    }

    private List<KeywordResult> doSearch(String query, int limit) {
        if (query == null || query.isBlank()) {
            return List.of();
        }

        String q = query.toLowerCase().trim();
        List<KeywordResult> results = new ArrayList<>();

        for (SpotEntity spot : spotRepository.searchByKeyword(q)) {
            results.add(new KeywordResult(spot.getId(), spot.getName(), spot.getSlug(), "spot", scoreSpot(spot, q)));
        }

        for (PostEntity post : postRepository.searchByKeyword(q)) {
            results.add(new KeywordResult(post.getId(), post.getTitle(), post.getSlug(), "post", scorePost(post, q)));
        }

        for (CityEntity city : cityRepository.searchByKeyword(q)) {
            results.add(new KeywordResult(city.getId(), city.getName(), city.getSlug(), "city", scoreCity(city, q)));
        }

        return results.stream()
                .sorted(Comparator.comparingDouble(KeywordResult::score).reversed())
                .limit(limit)
                .toList();
    }

    private double scoreSpot(SpotEntity spot, String q) {
        double score = 0;
        if (containsQ(spot.getName(), q) || containsQ(spot.getNameZh(), q)) score += 3;
        if (spot.getTags() != null && spot.getTags().stream().anyMatch(t -> containsQ(t, q))) score += 2;
        if (containsQ(spot.getDescription(), q) || containsQ(spot.getDescriptionZh(), q)) score += 1;
        return score;
    }

    private double scorePost(PostEntity post, String q) {
        double score = 0;
        if (containsQ(post.getTitle(), q)) score += 3;
        if (post.getTags() != null && post.getTags().stream().anyMatch(t -> containsQ(t, q))) score += 2;
        if (containsQ(post.getContent(), q)) score += 1;
        return score;
    }

    private double scoreCity(CityEntity city, String q) {
        double score = 0;
        if (containsQ(city.getName(), q) || containsQ(city.getNameZh(), q)) score += 3;
        if (containsQ(city.getDescription(), q)) score += 1;
        return score;
    }

    private boolean containsQ(String text, String q) {
        return text != null && text.toLowerCase().contains(q);
    }
}
java 复制代码
package com.mooc.app.service;

import com.mooc.app.dto.response.SearchResponse;
import com.mooc.app.dto.response.SearchResultItem;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service
public class HybridSearchService {

    private static final Logger log = LoggerFactory.getLogger(HybridSearchService.class);

    private final KnowledgeSearchService knowledgeSearchService;
    private final KeywordSearchService keywordSearchService;

    @Value("${app.search.rrf-k:60}")
    private int rrfK;

    @Value("${app.search.vector-top-k:10}")
    private int vectorTopK;

    public HybridSearchService(KnowledgeSearchService knowledgeSearchService,
                               KeywordSearchService keywordSearchService) {
        this.knowledgeSearchService = knowledgeSearchService;
        this.keywordSearchService = keywordSearchService;
    }

    public SearchResponse search(String query, String type, String city, String requestId) {
        // Keyword results (always available)
        List<KeywordResult> keywordResults = keywordSearchService.search(query);

        // Vector results (may fail if Chroma is unavailable)
        List<Document> vectorResults;
        try {
            vectorResults = knowledgeSearchService.search(query, city);
        } catch (Exception e) {
            log.warn("Vector search unavailable, falling back to keyword-only: {}", e.getMessage());
            vectorResults = List.of();
        }

        // RRF merge
        Map<String, RrfEntry> merged = rrfMerge(vectorResults, keywordResults);

        // Convert to SearchResultItem list
        List<SearchResultItem> items = merged.values().stream()
                .sorted(Comparator.comparingDouble(e -> -e.score))
                .map(e -> toSearchResultItem(e))
                .toList();

        // Apply type filter
        if (type != null && !type.isBlank()) {
            items = items.stream().filter(i -> i.type().equals(type)).toList();
        }

        // Count by type
        int spotsCount = (int) items.stream().filter(i -> "spot".equals(i.type())).count();
        int postsCount = (int) items.stream().filter(i -> "post".equals(i.type())).count();
        int citiesCount = (int) items.stream().filter(i -> "city".equals(i.type())).count();

        return new SearchResponse(requestId, items, spotsCount, postsCount, citiesCount);
    }

    private Map<String, RrfEntry> rrfMerge(List<Document> vectorResults, List<KeywordResult> keywordResults) {
        Map<String, RrfEntry> merged = new LinkedHashMap<>();

        // Vector results: rank = position + 1
        for (int i = 0; i < vectorResults.size(); i++) {
            Document doc = vectorResults.get(i);
            String entityType = (String) doc.getMetadata().getOrDefault("entity_type", "unknown");
            String slug = (String) doc.getMetadata().getOrDefault("slug", "");
            String key = entityType + ":" + slug;
            String name = (String) doc.getMetadata().getOrDefault("name",
                    doc.getMetadata().getOrDefault("title", ""));
            String nameZh = (String) doc.getMetadata().get("name_zh");

            double rrfScore = 1.0 / (rrfK + i + 1);
            merged.merge(key, new RrfEntry(key, entityType, slug, name, nameZh, rrfScore),
                    (existing, entry) -> new RrfEntry(existing.key, existing.type, existing.slug,
                            existing.name, existing.nameZh, existing.score + entry.score));
        }

        // Keyword results: rank = position + 1
        for (int i = 0; i < keywordResults.size(); i++) {
            KeywordResult kr = keywordResults.get(i);
            String key = kr.type() + ":" + kr.slug();
            double rrfScore = 1.0 / (rrfK + i + 1);

            merged.merge(key, new RrfEntry(key, kr.type(), kr.slug(), kr.name(), null, rrfScore),
                    (existing, entry) -> new RrfEntry(existing.key, existing.type, existing.slug,
                            existing.name, existing.nameZh, existing.score + entry.score));
        }

        return merged;
    }

    private SearchResultItem toSearchResultItem(RrfEntry entry) {
        String summary = null; // summary can be populated later
        return new SearchResultItem(entry.type, null, entry.slug, entry.name, entry.nameZh, summary, entry.score);
    }

    private record RrfEntry(String key, String type, String slug, String name, String nameZh, double score) {}
}

RRF 融合排名 ------ 一张图看懂

类比:想象两个旅游博主各自排了一份 "必逛榜单"------ 同一景点被两个人同时推荐,说明更值得去

博主 A(语义搜索)|"品味相似度" 排名

  1. 故宫博物院 0.0164 分
  2. 颐和园  0.0161 分
  3. 天坛公园 0.0159 分

博主 B(关键词搜索)|"名字匹配度" 排名

  1. 故宫博物院 0.0164 分
  2. 故宫大酒店 0.0161 分
  3. 故宫 xx 景点 0.0159 分

RRF 合并|分数相加 ★ 0.033 ② 0.016 ③ 0.016 ④ 0.016


RRF 公式(超级简单) 分数 = 1 / (60 + 排名) → 两路分数直接相加 "故宫" 两路都排第 1 → 分数翻倍 → 稳居榜首

为什么用排名而不是原始分数? 向量分数 (0~1) 和 关键词分数 (0~6) 量纲不同,无法直接加 → RRF 统一成 "排名倒数" 解决。

RRF 中的 k=60:怎么来的?

类比:k 就是一个 "均衡旋钮"------k 越小,头部结果越突出;k 越大,所有结果越平等

k = 1|赢者通吃

第 1 名: 1/2 = 0.500

第 2 名: 1/3 = 0.333

第 10 名: 1/11 = 0.091

前后差距 5 倍!

头部结果压倒一切,尾部结果几乎没有机会

k = 60|默认值 ★

第 1 名: 1/61 = 0.0164

第 2 名: 1/62 = 0.0161

第 10 名: 1/70 = 0.0143

前后差距 1.15 倍

头部有优势但不是压倒,尾部好结果仍有机会被看到

k = 1000|大锅饭

第 1 名: 1/1001 = 0.00100

第 2 名: 1/1002 = 0.000998

第 10 名: 1/1010 = 0.000990

前后差距 1.01 倍

所有结果几乎平等,排名前后没什么区别


为什么是 60?

2009 年论文实验确定的经验值,非推导结果

Elasticsearch / Vespa / Azure 默认都用 k=60

WanderChina 配置与调优

app.search.rrf‑k: 60 ← 配置文件随时可改

搜不到调小 k|噪声太多?调大 k

阈值调优与配置参数

0.95|太高 只有几乎完全匹配才搜到 漏掉好内容

0.30|宽松 (当前) 召回率高,不漏结果 配合 RRF 排序筛选

0.70|推荐默认 平衡精准率和召回率 业界经验值


application.yml 配置
yaml 复制代码
app.search:
  rrf-k: 60                # RRF 平滑常数
  vector-top-k: 10         # 向量候选数
  keyword-top-k: 10        # 关键词候选数
  suggest-top-k: 5         # 建议返回数
  similarity‑threshold: 0.3 # 相似度阈值
调优策略

策略:从 0.30 开始,上线后看反馈微调

  • 搜不到结果?→ 阈值调低,Top‑K 增大
  • 搜出垃圾?→ 阈值调高

旅游场景用户查询经常很模糊 宁可多搜 + RRF 排序,不要漏好内容

效果度量 + 本节收口

Precision 精准率

搜出的结果中 "对的" 比例

10 条结果 8 条对 = 80%

提升:阈值 0.3 → 0.7

Recall 召回率

对的内容里 "被搜出来" 的比例

库里 20 条相关,搜出 8 条 = 40%

提升:阈值调低 + Top‑K 增大


A/B 测试验证策略

  • A 组 (对照):纯关键词搜索
  • B 组 (实验):Hybrid Search + RRF

对比指标:搜索成功率|点击率|停留|跳出率

灰度 10% → 稳定后全量切换

总结&相关面试题

关键概念速记卡(12 个高频术语)

术语 说明
LLM API AI 大脑・按 Token 计费
Spring AI 翻译官・一行换模型
SSE 边想边说・打字机效果
Embedding 语义指纹(向量)
Vector DB 按 "语义距离" 搜索
RAG 先搜后答・真实数据
Hybrid Search 向量 + 关键词合并
Reranking 二次精排・NDCG 提升
Function Calling AI 动手做事
Semantic Search 按 "意思" 而非 "字面"
Trust Layer 引用 + 置信 + 兜底
Model Portfolio 不同任务路由不同模型

一句话:从 LLM API → RAG → 语义搜索 → 信任层 ------ 让 AI 能理解 / 思考 / 行动 / 可信

面试高频 10 题 + AI 三次升级回顾


Q1 RAG 是什么?解决什么问题? 先搜后答・消除幻觉 + 实时 + 私有数据

Q2 Embedding 为何能 "理解" 语义? 语义几何・近义词向量距离近

Q3 Spring AI 一行换模型的原理 适配器模式 + 自动装配・改 yml 不改代码

Q4 RAG 三个调优旋钮 Chunk 大小 / Top‑K / Prompt 三约束

Q5 Function Calling vs 普通 API 声明式 vs 指令式・AI 自主决策

Q6 语义搜索 vs 关键词 / Hybrid 字面 + 语义并行・RRF+Reranking

Q7 AI Native vs AI Augmented 外挂功能 vs 围绕 AI 重设计产品

Q8 Naive→Advanced→GraphRAG 进化 +Hybrid+Rerank → + 图谱 → +Agent

Q9 AI‑Native 五大架构差异 数据 / 交互 / 信任 / 治理 / 运维

Q10 RAG 数据新鲜度增量方案 CDC + 定时全量 + 版本化管理

相关推荐
孫治AllenSun1 小时前
【LangChain4J-03】Springboot 项目搭建框架
java·spring boot·后端
君顾11 小时前
AI新零售线上商城系统实战:架构设计与开发全流程指南
java·开发语言·零售
智购科技自动贩卖机1 小时前
自动售货机嵌入式系统Go语言开发实践:从资源受限设备到RTOS协程调度的工程化之路
大数据·linux·数据库·人工智能·yolo·架构·golang
Sinclair1 小时前
从神经网络到文件加密:一次关于 SIREN、ARX 与密码安全性的实验探索
人工智能·机器学习
阿里云大数据AI技术1 小时前
从三个月到两周:DataWorks Data Agent 重构信飞科技多国数仓交付链路
人工智能·agent
2601_949499941 小时前
芯瑞科技 DT‑1414完全兼容HFBR‑1414TZ光模块国产化优选方案深度解析(工程师视角)
运维·网络·人工智能·科技·光模块
qq_485015211 小时前
MyBatis-Plus 3.x FieldStrategy 作用
java·数据库·mybatis
lhldsg1 小时前
课程排课系统实战指南:从数据库设计到算法调优全流程解析
java·数据库·算法·小程序
BSD_HY1 小时前
薄膜开关矩阵扫描电路设计中的防鬼键措施
人工智能·算法·矩阵·人机交互·薄膜开关·源头工厂·深圳工厂