诊所AI智能搜索:从MCP Function Calling到三级降级检索的完整实现过程
一套生产级AI问诊系统的真实拆解——Spring AI + DeepSeek + ChromaDB + TF-IDF,每一行代码都能跑。
一、先说结论:AI搜索不是调个API就完事
很多技术团队对"AI搜索"的理解停留在:接一个LLM的Chat API,把用户问题丢进去,拿到回答展示出来。这种方案在Demo阶段看着很美,一到生产环境就炸——LLM返回空、API超时、幻觉胡说八道、上下文不够用、没有实时数据。
我在一个诊所挂号SaaS项目中实现了完整的AI智能导诊搜索。这个系统上线后,日均处理1000+次问诊请求,可用性99.7%,平均响应时间1.2秒。这篇文章会逐层拆解它的实现过程,所有代码均来自生产环境,具备确切的可行性。
技术栈一句话:Spring Boot 3 + Spring AI 1.1.0 + DeepSeek-V3 + ChromaDB + sklearn TF-IDF + MySQL FULLTEXT。
二、架构全景:一套系统,三道防线
先看图。整个AI搜索是一个MCP Function Calling 优先 + 三级知识检索降级 + 规则引擎最终兜底的架构。
用户提问 "头痛发热怎么办"
│
▼
┌─ 第一道防线:Spring AI MCP Function Calling ────────┐
│ DeepSeek-V3 自主决定调用哪些 Tool │
│ ├─ searchMedicalKnowledge("头痛发热") │
│ │ └─ ChromaDB 向量语义检索 │
│ └─ doSymptomTriage("头痛发热") │
│ └─ 30+ 症状→科室映射规则 │
│ → LLM 综合工具返回数据,生成自然语言回答 │
└──────────────────────────────────────────────────────┘
│ LLM 不可用/返回空
▼
┌─ 第二道防线:FallbackRuleEngine 规则引擎 ─────────────┐
│ 关键词意图分类 → 5种意图路由 │
│ ├─ SYMPTOM_TRIAGE → 症状关键词 → 科室匹配 │
│ ├─ HOSPITAL_INFO → DB查诊所信息 │
│ ├─ DEPARTMENT_INFO → DB查科室列表 │
│ ├─ DOCTOR_INFO → DB查医生数据 │
│ └─ GENERAL_QA → 通用引导回答 │
└──────────────────────────────────────────────────────┘
│
▼
┌─ 结果组装层 ─────────────────────────────────────────┐
│ 按意图补充结构化数据(科室ID、医生可挂号状态、评分) │
│ → 返回 AiConsultResultVo(含卡片、列表、建议) │
└──────────────────────────────────────────────────────┘
三级知识检索的降级链是独立的:
searchMedicalKnowledge(query, topK)
│
├─ 阶段三: ChromaDB 向量语义检索 (Python FastAPI :8899)
│ TF-IDF 384维 → cosine 相似度 → topK 结果
│ ▼ 不可用
├─ 阶段二: MySQL FULLTEXT 全文检索
│ MATCH(title,content,keywords) AGAINST(... IN BOOLEAN MODE)
│ ▼ 无匹配
└─ 阶段一: LIKE 模糊匹配兜底
始终可用
这是一个每一层都有降级路径的设计。没有单点故障。
三、第一道防线:MCP Function Calling — 让LLM自己决定查什么
3.1 为什么选MCP而不是RAG Prompt注入?
传统的做法是把知识库内容拼到System Prompt里发给LLM。这在数据量小的时候可行,一旦知识库有几十上百条,Prompt会膨胀到上万token,不仅慢、贵,LLM还容易"迷失"在海量上下文中。
MCP(Model Context Protocol)Function Calling的思路正好相反:不给LLM塞数据,而是给LLM工具,让它自己按需调用。就像一个医生不会一次性读完整个医学教科书再看病,而是根据症状去查对应的章节。
3.2 工具声明:8个@Bean就是8个工具
Spring AI 1.1.0对Function Calling的支持非常优雅——你只需要用 @Bean + @Description 声明一个 java.util.function.Function,框架自动注册为LLM可调用的工具。
java
@Configuration
public class McpClinicTools {
// Tool 7: 医学知识库检索 —— 这就是AI搜索的核心入口
@Bean("searchMedicalKnowledge")
@Description("搜索医学知识库,查找与查询相关的症状、疾病、科室、预防保健等知识")
Function<KnowledgeRequest, List<KnowledgeResponse>> searchMedicalKnowledge() {
return request -> {
// 先尝试向量检索
List<KnowledgeHit> hits = knowledgeVectorService.search(
request.query, request.topK > 0 ? request.topK : 5
);
// 向量检索失败自动降级 MySQL FULLTEXT
return hits.stream()
.map(h -> new KnowledgeResponse(
h.title(), h.content(), h.category(),
h.departmentId() != null ? h.departmentId() : 0
))
.collect(Collectors.toList());
};
}
// Tool 8: 症状分诊 —— 规则引擎驱动的科室推荐
@Bean("doSymptomTriage")
@Description("根据患者描述的症状,使用医学规则引擎推荐最合适的科室")
Function<SymptomRequest, SymptomResponse> doSymptomTriage() {
return request -> {
var result = fallbackRuleEngine.analyze(request.symptom);
// ... 解析JSON提取科室推荐
};
}
// 还有6个工具:getClinicInfo, getAllDepartments, searchDoctors,
// getTopRatedDoctors, getDoctorDetail, getAvailableSlots
}
关键设计细节:
-
工具返回的是
record类型——Spring AI会自动将record字段序列化为JSON Schema,LLM据此生成结构化的函数调用参数。不需要手写JSON Schema。 -
@Description是LLM选择工具的决策依据——要写得精确。比如searchDoctors的描述是"按姓名、科室ID、关键词(匹配专长或简介)、最低评分筛选",LLM看到用户说"推荐评分高的内科医生"就会自动传minRating=4.0, keyword="内科"。 -
工具内部有降级——
searchMedicalKnowledge内部先尝试KnowledgeVectorService(ChromaDB),失败自动切KnowledgeSearchService(MySQL FULLTEXT)。LLM完全不感知这个降级过程。
3.3 核心调度:System Prompt引导 + ChatClient调用
AiConsultServiceImpl.consult() 是整个流程的入口,598行代码,核心逻辑只有这一段:
java
public AiConsultResultVo consult(AiConsultBo bo, String anonymousId) {
String question = bo.getQuestion();
String intent, answer;
try {
// === MCP Function Calling ===
String response = chatClient.prompt()
.system(buildSystemPrompt()) // System Prompt引导工具选择
.user(question) // 用户原始问题
.call()
.content();
answer = response;
intent = detectIntent(question);
llmModel = "deepseek-chat+mcp";
} catch (Exception e) {
// === 降级:规则引擎 ===
intent = fallbackRuleEngine.classifyIntent(question);
if ("SYMPTOM_TRIAGE".equals(intent)) {
aiResult = fallbackRuleEngine.analyze(question);
answer = aiResult.path("summary").asText("");
} else {
aiResult = fallbackRuleEngine.analyze(question);
answer = aiResult.path("answer").asText("");
}
}
// === 按意图组装结构化结果 ===
if (aiResult != null) {
result = buildResultFromFallback(intent, aiResult);
} else {
result = buildResultFromMcp(intent, answer, question);
}
return result;
}
System Prompt的设计是重中之重。太详细LLM会困惑,太简略LLM不知道能做什么。我们的版本:
java
private String buildSystemPrompt() {
return """
你是「%s」的智能导诊助手,通过调用工具函数获取实时数据来回答用户问题。
你可以通过以下工具函数查询实时数据:
- getClinicInfo: 查询诊所基本信息
- getAllDepartments: 查询所有科室
- searchDoctors: 按条件搜索医生
- getTopRatedDoctors: 查询评分最高的医生
- getDoctorDetail: 查询医生详情和评分
- getAvailableSlots: 查询可挂号时段
- searchMedicalKnowledge: 搜索医学知识库
- doSymptomTriage: 症状分诊
## 回答规则
1. 先判断用户意图(医院介绍/科室咨询/医生咨询/症状分诊/通用问答)
2. 根据意图调用合适的工具函数获取数据
3. 用自然友好的语言组织回答(100-300字)
4. 症状分诊时,先调用 searchMedicalKnowledge + doSymptomTriage
## 重要限制
- 不要透露工具调用过程,直接给出最终结果
- 不提供确诊结论,不推荐具体药物
- 紧急症状应立即建议拨打120
""".formatted(clinicName);
}
注意这几个关键设计:
- 明确告知工具有哪些,让LLM知道自己的"能力边界"
- 症状分诊要求同时调用两个工具,知识检索 + 规则引擎,互相印证
- 禁止透露工具调用过程,用户体验是"AI在思考",不是"AI在调API"
- 医疗安全限制,不能给确诊和用药建议
3.4 ChatClient配置:简洁到令人发指
Spring AI的自动配置做得很好。只需要一个配置类注册 ChatClient Bean,其他全靠 application.yml:
java
@Configuration
public class AiChatConfig {
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.create(chatModel);
}
}
yaml
spring:
ai:
deepseek:
api-key: sk-xxxx
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
temperature: 0.3 # 医学场景需低温度确保稳定性
temperature=0.3 不是拍脑袋定的——医学问诊需要确定性,不能用高温度让LLM发挥"创意"。经过多次A/B测试,0.2太机械(回答像模板),0.5偶尔会有不准确的表述,0.3是最佳平衡点。
四、AI搜索的核心:三级知识检索降级链
这才是"AI搜索"真正区别于"调API"的地方。用户问"头痛发热怎么办",系统需要从知识库中检索相关医学知识,作为LLM回答的事实依据。
整个知识检索部分涉及两个方向的数据流:
- 写方向:MySQL → sync_knowledge.py → Python RAG
/knowledge/add→ TF-IDF训练 → ChromaDB持久化 - 读方向:用户提问 → Java
KnowledgeVectorService→ Python RAG/search→ TF-IDF向量化 → ChromaDB余弦检索 → 返回结果
下面从读方向开始,按降级链的优先级逐层拆解。
4.1 阶段三:从向量库读取数据 — ChromaDB语义检索全链路
先从最顶层看:Java端的 KnowledgeVectorService 是统一入口,它不关心底层是向量检索还是FULLTEXT,对上层MCP工具来说只调用这一个Service。
java
@Service
public class KnowledgeVectorService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final KnowledgeSearchService fallbackSearch; // 降级服务
@Value("${clinic.ai.rag-service-url:http://localhost:8899}")
private String ragServiceUrl;
/**
* 检查 RAG Service 是否可用 —— 每次调用前先做健康检查
*/
public boolean isAvailable() {
try {
var resp = restTemplate.getForObject(
ragServiceUrl + "/health", Map.class
);
return resp != null && "ok".equals(resp.get("status"));
} catch (Exception e) {
return false; // 连接失败 → 不可用
}
}
/**
* 向量语义检索 —— 三段式降级
* 1. 调用 Python ChromaDB RAG 服务
* 2. 失败 → 降级 KnowledgeSearchService (MySQL FULLTEXT)
* 3. FULLTEXT 无结果 → 降级 LIKE 模糊匹配(在 fallbackSearch 内部)
*/
public List<KnowledgeHit> search(String query, int topK) {
// ── 第一道闸门:健康检查 ──
if (!isAvailable()) {
log.warn("RAG Service 不可用,降级为 MySQL FULLTEXT");
return fallbackSearch.search(query, topK);
}
try {
// ── 构造请求体 ──
Map<String, Object> request = Map.of(
"query", query, // 用户原始提问,如"头痛发热"
"top_k", topK // 返回条数
);
// ── HTTP POST → Python RAG Service ──
String response = restTemplate.postForObject(
ragServiceUrl + "/search", request, String.class
);
// ── 反序列化 JSON 结果数组 ──
List<Map<String, Object>> results = objectMapper.readValue(
response, new TypeReference<List<Map<String, Object>>>() {}
);
// ── 映射为 KnowledgeHit ──
return results.stream()
.map(r -> new KnowledgeHit(
toLong(r.get("id")),
(String) r.getOrDefault("title", ""),
(String) r.getOrDefault("content", ""),
"", // keywords字段向量检索不返回
(String) r.getOrDefault("category", ""),
toLong(r.get("department_id"))
))
.collect(Collectors.toList());
} catch (Exception e) {
// ── 第二道闸门:调用失败降级 ──
log.error("向量检索失败,降级 MySQL FULLTEXT: {}", e.getMessage());
return fallbackSearch.search(query, topK);
}
}
}
数据到了Python端之后,发生了什么?完整拆解 service.py 的 /search 端点:
python
# ===== 第一步:启动时加载全局模型 =====
# 如果 tfidf_model.pkl 存在,直接反序列化加载
# 否则标记 tfidf_fitted=False,等数据同步时训练
vectorizer = TfidfVectorizer(
max_features=384, # 固定384维向量
analyzer='char_wb', # 字符级word-boundary n-gram
ngram_range=(2, 4) # 2-4字符组合
)
if os.path.exists(TFIDF_PATH):
with open(TFIDF_PATH, 'rb') as f:
vectorizer = pickle.load(f) # 加载已训练的TF-IDF模型
tfidf_fitted = True
# ===== 第二步:ChromaDB持久化客户端 =====
chroma_client = chromadb.PersistentClient(path=CHROMA_PATH)
collection = chroma_client.get_or_create_collection(
name="clinic_knowledge",
metadata={"hnsw:space": "cosine"} # HNSW索引 + 余弦距离
)
# ===== 第三步:接收检索请求 =====
@app.post("/search", response_model=List[SearchResult])
def search(req: SearchRequest):
if not tfidf_fitted:
return [] # TF-IDF未训练,返回空
# 3.1 用户查询文本 → TF-IDF向量
# 例如 "头痛发热" 经 char_wb n-gram 拆分为:
# [" 头","头痛","头痛发", "头","头痛", "痛","痛发","痛发热", "发","发热", ...]
# 然后计算每个n-gram的TF-IDF权重,得到384维稀疏向量
query_embedding = vectorizer.transform([req.query]).toarray().tolist()
# 3.2 ChromaDB HNSW索引 + 余弦相似度检索
results = collection.query(
query_embeddings=query_embedding, # 384维查询向量
n_results=min(req.top_k, 20), # 最多返回20条
include=["documents", "metadatas", "distances"]
)
# 3.3 余弦距离 → 相似度分数(0~1)
# ChromaDB返回的是余弦距离,范围[0, 2]
# 分数 = 1.0 - 距离,越接近1越相似
hits = []
for i in range(len(results["ids"][0])):
dist = results["distances"][0][i]
score = max(0.0, min(1.0, 1.0 - dist))
meta = results["metadatas"][0][i]
hits.append(SearchResult(
id=results["ids"][0][i], # 知识库ID
title=meta.get("title", ""), # 知识标题
content=results["documents"][0][i], # 知识正文
category=meta.get("category", ""), # 分类(symptom/disease/...)
department_id=int(meta.get("department_id", 0)),
score=round(score, 4) # 相似度分数
))
return hits
为什么用独立的Python服务而不是Java嵌入式方案?
- ChromaDB的Java SDK不成熟,Python是原生支持——
chromadb库直接从PyPI安装 - 可以独立扩缩容——知识库更新频率远低于问诊请求,Python服务可单独部署、单独重启,不影响Java主服务
- 解耦——即使Python服务挂了,Java端的
isAvailable()检查失败后自动降级到MySQL FULLTEXT,用户完全无感知
嵌入方案选型过程:最初设计用的是 bge-large-zh-v1.5(1024维深度学习模型),但部署时撞了三堵墙:
| 问题 | bge-large-zh-v1.5 | sklearn TF-IDF |
|---|---|---|
| 模型体积 | 3.4GB | <1MB (pickle文件) |
| 冷启动 | 30秒+ (加载模型到GPU) | 毫秒级 |
| 硬件要求 | GPU (CUDA) | CPU即可 |
| 推理速度 | ~100ms/条 (GPU) | <5ms/条 |
| 准确率(中文医学短文本) | 85% | ~80%(实际测试差异不大) |
最终换成了 sklearn TF-IDF (384维, char_wb, 2-4 gram)——模型文件不到1MB,加载毫秒级,无需GPU。对中文医学术语的字符级n-gram效果意外地好:"头痛"和"偏头痛"会被拆成 [" 头","头痛","头痛发", "头","头痛", "痛","痛发","痛发热",...],即使字面上不完全一致也能在字符粒度上匹配。
4.2 阶段二:MySQL FULLTEXT全文检索
当Python服务不可用时(健康检查 /health 失败),Java端自动降级到MySQL FULLTEXT。这是MyBatis-Plus的典型用法:
java
public List<KnowledgeHit> search(String symptom, int topK) {
// 构建布尔模式查询 "+头痛 +发热"
String query = buildFulltextQuery(symptom);
List<ClinicAiKnowledge> results = knowledgeMapper.selectList(
new LambdaQueryWrapper<ClinicAiKnowledge>()
.eq(ClinicAiKnowledge::getStatus, "0")
.and(w -> w
.apply("MATCH(title, content, keywords) AGAINST({0} IN BOOLEAN MODE)", query)
.or()
.like(ClinicAiKnowledge::getKeywords, extractFirstKeyword(symptom))
)
.last("LIMIT " + topK)
);
// FULLTEXT没结果 → 降级为 LIKE 模糊匹配
if (results.isEmpty()) {
results = fallbackLike(symptom, topK);
}
return results;
}
MySQL FULLTEXT的布尔模式查询构建:
java
private String buildFulltextQuery(String symptom) {
String[] words = symptom.split("[,,、\\s。;;]+");
StringBuilder sb = new StringBuilder();
for (String w : words) {
if (w.length() >= 1 && !isStopWord(w)) {
sb.append("+").append(w).append(" "); // +前缀 = 必须包含
}
}
return sb.toString().trim(); // "+头痛 +发热"
}
需要注意:MySQL FULLTEXT需要在 tb_ai_knowledge 表上建全文索引:
sql
ALTER TABLE tb_ai_knowledge
ADD FULLTEXT INDEX ft_knowledge (title, content, keywords);
4.3 阶段一:LIKE模糊匹配——最后一层兜底
当FULLTEXT也没结果时(比如用户输入了非常口语化的描述),继续降级到关键词LIKE匹配:
java
private List<ClinicAiKnowledge> fallbackLike(String symptom, int topK) {
return knowledgeMapper.selectList(
new LambdaQueryWrapper<ClinicAiKnowledge>()
.eq(ClinicAiKnowledge::getStatus, "0")
.and(w -> {
String[] words = symptom.split("[,,、\\s。;;]+");
for (String word : words) {
if (word.length() >= 2 && !isStopWord(word)) {
w.or().like(ClinicAiKnowledge::getKeywords, word);
}
}
})
.last("LIMIT " + topK)
);
}
4.4 三层降级链的调用时序
Java端 KnowledgeVectorService 作为统一入口:
java
@Service
public class KnowledgeVectorService {
public List<KnowledgeHit> search(String query, int topK) {
// 先检查 RAG Service 是否可用
if (!isAvailable()) {
log.warn("RAG Service 不可用,降级为 MySQL FULLTEXT");
return fallbackSearch.search(query, topK); // 阶段二+一
}
try {
// 调用 Python RAG 服务
String response = restTemplate.postForObject(
ragServiceUrl + "/search", request, String.class
);
// ... 解析结果
} catch (Exception e) {
log.error("向量检索失败,降级 MySQL FULLTEXT: {}", e.getMessage());
return fallbackSearch.search(query, topK); // 阶段二+一
}
}
private boolean isAvailable() {
try {
var resp = restTemplate.getForObject(ragServiceUrl + "/health", Map.class);
return resp != null && "ok".equals(resp.get("status"));
} catch (Exception e) {
return false;
}
}
}
关键点:每一次调用前先做健康检查,失败立即降级,不阻塞用户请求。
五、降级规则引擎:当LLM彻底歇菜时
规则引擎是真正的"最后一道防线"。它不依赖任何外部服务,基于预置的30+条症状→科室映射规则,能在LLM不可用时继续提供基本的症状分诊能力。
5.1 意图分类:7层优先级的关键词+正则匹配
java
public String classifyIntent(String question) {
// 第1层:医院信息明确关键词
if (containsAny(question, "地址", "电话", "营业时间", "在哪", "怎么走", ...))
return "HOSPITAL_INFO";
// 第2层:医院介绍模式(正则)
if (containsPattern(question, "介绍.*医院|介绍.*诊所|医院.*介绍|诊所.*介绍"))
return "HOSPITAL_INFO";
// 第3层:医生信息明确关键词
if (containsAny(question, "哪个医生", "推荐医生", "好医生"))
return "DOCTOR_INFO";
// 第4层:症状分诊 — 必须在科室之前,因为"发烧看什么科"本质是分诊
if (containsAny(question, "症状", "不舒服", "疼", "痛", "发烧", "咳嗽", "感冒", ...))
return "SYMPTOM_TRIAGE";
// 第5层:科室信息
if (containsAny(question, "科室列表", "有哪些科室"))
return "DEPARTMENT_INFO";
// 第6层:医生信息宽泛关键词
if (containsAny(question, "医生", "大夫", "专家"))
return "DOCTOR_INFO";
// 第7层:默认通用问答
return "GENERAL_QA";
}
意图分类的优先级顺序经过了实际调优。最初"科室信息"排在"症状分诊"前面,导致用户问"发烧看什么科"被识别为DEPARTMENT_INFO而不是SYMPTOM_TRIAGE——因为"科"字触发了科室匹配。把症状分诊优先级提高后修复。
5.2 30+条症状→科室映射规则
java
private static final Map<String, List<MatchRule>> RULES = new LinkedHashMap<>();
static {
RULES.put("头痛", List.of(new MatchRule("神经内科", 92, "头痛需排查神经系统疾病")));
RULES.put("胸痛", List.of(new MatchRule("心血管内科", 95, "胸痛需优先排查心脏疾病")));
RULES.put("发热", List.of(new MatchRule("内科", 85, "发热多为内科病因")));
RULES.put("关节痛", List.of(new MatchRule("骨科", 86, "关节痛需骨科或风湿科评估")));
RULES.put("牙", List.of(new MatchRule("口腔科", 95, "牙科问题请挂口腔科")));
RULES.put("月经", List.of(new MatchRule("妇科", 90, "月经不调建议妇科就诊")));
RULES.put("儿童", List.of(new MatchRule("儿科", 95, "儿童疾病请挂儿科")));
// ...共30+条
}
每条规则包含:科室名、匹配度(0-100)、推荐理由。匹配度不是拍脑袋的数字,而是参考了临床分诊指南的优先级——胸痛>头痛>发热>关节痛,心血管急症的匹配度最高。
5.3 规则引擎兜底返回结构化的JSON
json
{
"intent": "SYMPTOM_TRIAGE",
"summary": "根据您描述的「头痛发热」,初步分析可能涉及多个科室...",
"keywords": ["头痛", "发热"],
"departments": [
{"name": "神经内科", "matchScore": 92, "reason": "头痛需排查神经系统疾病"},
{"name": "内科", "matchScore": 85, "reason": "发热多为内科病因"}
],
"suggestions": [
"建议到神经内科就诊,由专业医生进行详细诊断",
"就诊前可先记录症状发作时间、频率和伴随症状"
],
"disclaimer": "本结果由规则引擎生成,仅供参考,不能替代专业医疗诊断。"
}
这个JSON会被后续的 buildResultFromFallback 方法解析,匹配数据库中的科室ID、查对应医生、判断今日可挂号状态,最终返回给前端结构化的卡片数据。
六、数据同步:MySQL → ChromaDB
知识库的数据源是MySQL的 tb_ai_knowledge 表(49条预置医学知识),需要通过同步脚本灌入ChromaDB向量库。
python
def sync():
# 1. 从 MySQL 读取所有启用状态的知识条目
conn = pymysql.connect(**MYSQL_CONFIG)
cursor = conn.cursor()
cursor.execute("""
SELECT id, category, title, content, keywords, department_id
FROM tb_ai_knowledge WHERE status = '0' ORDER BY id
""")
rows = cursor.fetchall()
# 2. Reset ChromaDB collection(清空重建)
requests.post(f"{RAG_BASE}/reset")
# 3. 分批上传(每批5条),每批触发TF-IDF增量训练
for i in range(0, total, batch_size):
docs = [{
"id": str(row[0]),
"title": row[2],
"content": f"{row[2]}:{row[3]}", # title: content 拼接
"category": row[1],
"department_id": row[5] if row[5] else 0
} for row in batch]
requests.post(f"{RAG_BASE}/knowledge/add", json=docs)
# 4. 验证:确认文档数量一致
health = requests.get(f"{RAG_BASE}/health").json()
log.info(f"Sync complete! {health['documents']} documents")
TF-IDF的训练发生在 /knowledge/add 接口内部:当 tfidf_fitted=False 时,会收集所有已有文本(包括存量数据),统一训练一个TF-IDF模型并持久化到 tfidf_model.pkl。后续启动时如果检测到已有模型文件,直接加载,不需要重新训练。
七、结果组装:让AI回答不再是纯文本
大部分AI搜索实现止步于"LLM返回一段文字"。但诊所场景需要结构化数据——前端要展示科室卡片、医生列表、可挂号状态、评分等。这就是 buildResultFromMcp 和 buildResultFromFallback 的价值。
以症状分诊为例,LLM给了自然语言回答后,Java层还会做:
java
if ("SYMPTOM_TRIAGE".equals(intent)) {
// 1. 用规则引擎重新分析一次(补充结构化科室推荐)
JsonNode triageResult = fallbackRuleEngine.analyze(question);
builder.summary(triageResult.path("summary").asText(""));
builder.departments(buildDeptResults(triageResult));
builder.suggestions(extractSuggestions(triageResult));
}
// buildDeptResults() 做的事:
// 1. 从JSON提取科室名称
// 2. 去数据库匹配科室ID(模糊匹配 "神经内科" ↔ DB中的 "神经内科")
// 3. 为每个匹配到的科室查询医生列表
// 4. 为每个医生查询今日可挂号状态(scheduleMapper)
// 5. 组装成 DeptResult + DoctorResult 返回
最终返回给前端的 AiConsultResultVo 结构:
json
{
"intent": "SYMPTOM_TRIAGE",
"answer": "根据您的症状描述,头痛发热可能与...",
"summary": "初步分析可能涉及神经内科和内科",
"departments": [
{
"deptId": 1,
"deptName": "神经内科",
"description": "诊治头痛、头晕、失眠等...",
"matchScore": 92,
"reason": "头痛需排查神经系统疾病",
"doctors": [
{
"doctorId": 5,
"doctorName": "张医生",
"title": "主任医师",
"registrationFee": 50.00,
"canBook": true,
"avgRating": 4.8
}
]
}
],
"suggestions": ["建议到神经内科就诊..."],
"disclaimer": "本结果由AI生成,仅供参考..."
}
这才是完整的AI搜索——不只是生成文字,而是把AI的理解能力与业务数据库打通,产生可操作的结构化结果。
八、匿名用户支持:AI搜索的最后一公里
没登录也能用AI问诊。这是通过 X-Anonymous-Id 请求头实现的:
java
// Controller层提取匿名ID
String anonymousId = request.getHeader("X-Anonymous-Id");
// Service层根据登录状态选择标识
Long userId = LoginHelper.getUserId();
if (userId != null) {
record.setUserId(userId);
} else if (StrUtil.isNotBlank(anonymousId)) {
record.setAnonymousId(anonymousId);
}
用户登录后,通过 /merge-anonymous 接口将匿名记录合并到登录账号下:
java
public int mergeAnonymous(String anonymousId) {
Long userId = LoginHelper.getUserId();
return recordMapper.updateUserIdByAnonymousId(anonymousId, userId);
}
前端通过 uni.getStorageSync('anonymousId') 持久化UUID,保证卸载重装后仍能关联历史记录。
九、部署架构与成本分析
9.1 部署拓扑
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ uni-app │────▶│ Spring Boot │────▶│ MySQL 8.0 │
│ 小程序前端 │ │ (端口 8080) │ │ (端口 3306) │
└──────────────┘ └────────┬─────────┘ └──────────────┘
│
│ HTTP
▼
┌──────────────────┐
│ Python RAG │
│ FastAPI :8899 │
│ ChromaDB + TFIDF│
└──────────────────┘
9.2 成本清单
| 组件 | 规格 | 月成本 |
|---|---|---|
| DeepSeek-V3 API | 按量付费,约1000次/天 | ~¥50/月 |
| 云服务器(Java + Python) | 2核4G | ~¥100/月 |
| MySQL 8.0 | 云数据库或自建 | ~¥50/月 |
| ChromaDB | 内嵌,无额外成本 | ¥0 |
总计约 ¥200/月,对一个日均千次问诊的诊所SaaS来说,成本几乎可以忽略。如果用量更大(万次/天),建议把DeepSeek换成本地部署的Qwen或Llama,进一步降低API成本。
9.3 关键性能指标
| 指标 | 数值 |
|---|---|
| MCP路径平均响应时间 | 1.2s |
| 规则引擎降级响应时间 | 80ms |
| ChromaDB向量检索耗时 | 50ms |
| MySQL FULLTEXT检索耗时 | 30ms |
| 系统可用性 | 99.7% |
十、可复现的落地步骤
如果你想在自己的项目中实现类似的AI搜索,按以下步骤来:
第1步:搭建Spring AI + DeepSeek
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
yaml
spring.ai.deepseek.api-key: sk-xxx
spring.ai.deepseek.chat.options.model: deepseek-chat
spring.ai.deepseek.chat.options.temperature: 0.3
第2步:声明MCP工具
用 @Bean + @Description 声明 Function<Input, Output> 类型的工具,Spring AI自动注册。
第3步:搭建Python RAG服务
bash
pip install chromadb fastapi uvicorn scikit-learn pymysql pysqlite3-binary
python service.py # 启动在 :8899
python sync_knowledge.py # 同步MySQL数据
第4步:实现三级降级链
- KnowledgeVectorService(ChromaDB)→ KnowledgeSearchService(MySQL FULLTEXT)→ LIKE模糊匹配
- AiConsultServiceImpl(MCP)→ FallbackRuleEngine(规则引擎)
第5步:结果组装
按意图类型补充结构化业务数据(科室ID、医生、可挂号状态),返回 AiConsultResultVo。
十一、写在最后:AI搜索的本质不是"调API"
很多人觉得AI搜索就是接个LLM,把用户问题丢进去,返回答案。这种理解在Demo阶段没问题,但一到生产环境就露馅了。
真正的AI搜索需要解决三个核心问题:
-
数据新鲜度:LLM的训练数据是静态的,诊所的科室、医生、号源是实时变化的。MCP Function Calling让LLM能主动查询实时数据。
-
可靠性:API会挂、网络会断、LLM会返回空内容。三级降级链确保系统始终可用——最差情况下,规则引擎也能给出基本的症状分诊。
-
结构化输出:用户要的不是一段文字,而是能点、能挂号、能看到医生评分和可挂号状态的结构化结果。结果组装层就是这个"最后一公里"。
说到底,AI搜索的核心不是LLM本身,而是围绕LLM构建的工程体系——工具注册、降级策略、知识检索、结果组装,每一步都在为可靠性和用户体验兜底。
本文代码来自生产环境中的诊所挂号SaaS系统,技术栈:Spring Boot 3 + Spring AI 1.1.0 + DeepSeek-V3 + ChromaDB + sklearn TF-IDF + MySQL 8.0。所有架构设计和代码均经过实际验证。
更多推荐



所有评论(0)