Agent 开发之项目 AI Native 化:通过大模型与 RAG 赋予产品智能能力
文章目录
- [Agent 开发之项目 AI Native 化:通过大模型与 RAG 赋予产品智能能力](#Agent 开发之项目 AI Native 化:通过大模型与 RAG 赋予产品智能能力)
-
- [AI Native化架构设计,如何给项目集成一个AI旅行咨询服务助手?](#AI Native化架构设计,如何给项目集成一个AI旅行咨询服务助手?)
-
- [两种 AI:辅助开发 vs 原生产品](#两种 AI:辅助开发 vs 原生产品)
-
- [AI 辅助开发(以前)](#AI 辅助开发(以前))
- [AI 原生产品(本章主角)](#AI 原生产品(本章主角))
- [AI Native 四大优势](#AI Native 四大优势)
- [WanderChina 三层 AI 架构](#WanderChina 三层 AI 架构)
-
- [应用层(Application Layer)](#应用层(Application Layer))
- [知识层(Knowledge Layer)](#知识层(Knowledge Layer))
- [接入层(Access Layer)](#接入层(Access Layer))
- [Trust Layer 信任层(贯穿三层)](#Trust Layer 信任层(贯穿三层))
- 施工顺序:自下而上
- AI旅行咨询服务助手背后的大模型将如何进行选择?
- AI智能助手实战,如何使用集成对话式AI实现用户实时问答与辅助功能?
-
- 提示词
- 为什么需要流式输出?
- [SSE vs WebSocket:流式协议怎么选?](#SSE vs WebSocket:流式协议怎么选?)
-
- [🌀 AI 聊天为什么选 SSE?](#🌀 AI 聊天为什么选 SSE?)
- 具体编码
- RAG知识增强,如何让AI助手回答的更准确?
-
- [AI 的致命缺陷:一本正经地胡说八道](#AI 的致命缺陷:一本正经地胡说八道)
-
- 真实场景
- [AWS 经典类比](#AWS 经典类比)
- [RAG 是什么?先搜后答](#RAG 是什么?先搜后答)
-
- [不用 RAG](#不用 RAG)
- [用 RAG](#用 RAG)
- [RAG 五大优势:业界公认最高性价比方案](#RAG 五大优势:业界公认最高性价比方案)
- 开始实现
- [RAG 五步 Pipeline:从数据到回答](#RAG 五步 Pipeline:从数据到回答)
- [Embedding + Vector DB 选型](#Embedding + Vector DB 选型)
- [RAG 调优 + 业界进化路径](#RAG 调优 + 业界进化路径)
-
- 调优旋钮
-
- [**旋钮① Chunk 大小**](#旋钮① Chunk 大小)
- [**旋钮② Top‑K**](#旋钮② Top‑K)
- [**旋钮③ Prompt 模板**](#旋钮③ Prompt 模板)
- [业界 RAG 进化路径(2024‑2025)](#业界 RAG 进化路径(2024‑2025))
- [AIGC实践:Function Calling + 智能推荐](#AIGC实践:Function Calling + 智能推荐)
- 将网站的传统搜索升级为AI全栈搜索,如何通过语义搜索替代关键词搜索提升用户体验?
-
- 关键词搜索的三大致命缺陷
- [语义搜索原理:向量空间的 "意思匹配"](#语义搜索原理:向量空间的 “意思匹配”)
- [Hybrid Search:为什么两路比一路好?](#Hybrid Search:为什么两路比一路好?)
- 实现语义搜索的提示词
- 具体实现
- [RRF 融合排名 ------ 一张图看懂](#RRF 融合排名 —— 一张图看懂)
- [RRF 中的 k=60:怎么来的?](#RRF 中的 k=60:怎么来的?)
-
- [k = 1|赢者通吃](#k = 1|赢者通吃)
- [k = 60|默认值 ★](#k = 60|默认值 ★)
- [k = 1000|大锅饭](#k = 1000|大锅饭)
- 阈值调优与配置参数
-
- [application.yml 配置](#application.yml 配置)
- 调优策略
- [效果度量 + 本节收口](#效果度量 + 本节收口)
-
- [Precision 精准率](#Precision 精准率)
- [Recall 召回率](#Recall 召回率)
- [A/B 测试验证策略](#A/B 测试验证策略)
- 总结&相关面试题
-
- [关键概念速记卡(12 个高频术语)](#关键概念速记卡(12 个高频术语))
- [面试高频 10 题 + AI 三次升级回顾](#面试高频 10 题 + AI 三次升级回顾)
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能实现对话、向量化、监控三件事
选型优势:
- 国内直连:免翻墙、不需要海外信用卡
- 统一管理:Key、用量、账单一站式管理
- 兼容 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
生产环境三件事
- 异常处理 + Fallback
- API 会超时 / 不可用 / 余额不足
- Spring AI 内置重试 + 配降级策略
- 主模型挂了 → fallback 到备用模型
- 限流防刷
- 用户 1 秒发 100 条 → 费用爆炸
- 每分钟 N 次调用,超出排队或拒绝
- Redis + RateLimitService 实现
- 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:从数据到回答
离线阶段:建知识库(一次性)
- 文档切片 Chunking 景点 / 帖子按实体切 每段 500‑1000 字
- 向量化 Embedding 每段 → 1536 维向量 语义指纹
- 存入 Vector DB 文本 + 向量 + 元数据 Chroma 嵌入式
在线阶段:用户提问时(每次)
- 语义检索 Retrieval 问题向量化 → Top‑K 最近邻
- 注入 + 生成 检索结果拼到 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,不用部署独立服务;需要掌握少量向量索引调优
- 安装:PostgreSQL 扩展,数据库执行
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)
- Naive RAG:基础版:切片→向量化→检索→生成
- Advanced RAG:+ Hybrid Search + Reranking(混合检索 + 重排序)
- GraphRAG:+ 知识图谱多跳推理
- 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:为什么两路比一路好?
纯语义搜索的问题
用户搜 "故宫" -- 精确意图,就要故宫
语义搜索可能带出 "颐和园""长城"
因为它们语义相近,但用户要的是精确匹配
解决方案: 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(语义搜索)|"品味相似度" 排名
- 故宫博物院 0.0164 分
- 颐和园 0.0161 分
- 天坛公园 0.0159 分
博主 B(关键词搜索)|"名字匹配度" 排名
- 故宫博物院 0.0164 分
- 故宫大酒店 0.0161 分
- 故宫 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 + 定时全量 + 版本化管理