诊所AI智能搜索:从MCP Function Calling到三级降级检索的完整实现过程

诊所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 }

关键设计细节:

  1. 工具返回的是record类型——Spring AI会自动将record字段序列化为JSON Schema,LLM据此生成结构化的函数调用参数。不需要手写JSON Schema。

  2. @Description是LLM选择工具的决策依据——要写得精确。比如searchDoctors的描述是"按姓名、科室ID、关键词(匹配专长或简介)、最低评分筛选",LLM看到用户说"推荐评分高的内科医生"就会自动传minRating=4.0, keyword="内科"

  3. 工具内部有降级——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的自动配置做得很好。只需要一个配置类注册ChatClientBean,其他全靠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持久化
  • 读方向:用户提问 → JavaKnowledgeVectorService→ 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嵌入式方案?

  1. ChromaDB的Java SDK不成熟,Python是原生支持——chromadb库直接从PyPI安装
  2. 可以独立扩缩容——知识库更新频率远低于问诊请求,Python服务可单独部署、单独重启,不影响Java主服务
  3. 解耦——即使Python服务挂了,Java端的isAvailable()检查失败后自动降级到MySQL FULLTEXT,用户完全无感知

嵌入方案选型过程:最初设计用的是bge-large-zh-v1.5(1024维深度学习模型),但部署时撞了三堵墙:

问题bge-large-zh-v1.5sklearn 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返回一段文字"。但诊所场景需要结构化数据——前端要展示科室卡片、医生列表、可挂号状态、评分等。这就是buildResultFromMcpbuildResultFromFallback的价值。

以症状分诊为例,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搜索需要解决三个核心问题:

  1. 数据新鲜度:LLM的训练数据是静态的,诊所的科室、医生、号源是实时变化的。MCP Function Calling让LLM能主动查询实时数据。

  2. 可靠性:API会挂、网络会断、LLM会返回空内容。三级降级链确保系统始终可用——最差情况下,规则引擎也能给出基本的症状分诊。

  3. 结构化输出:用户要的不是一段文字,而是能点、能挂号、能看到医生评分和可挂号状态的结构化结果。结果组装层就是这个"最后一公里"。

说到底,AI搜索的核心不是LLM本身,而是围绕LLM构建的工程体系——工具注册、降级策略、知识检索、结果组装,每一步都在为可靠性和用户体验兜底。


本文代码来自生产环境中的诊所挂号SaaS系统,技术栈:Spring Boot 3 + Spring AI 1.1.0 + DeepSeek-V3 + ChromaDB + sklearn TF-IDF + MySQL 8.0。所有架构设计和代码均经过实际验证。