诊所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还容易迷失在海量上下文中。MCPModel Context ProtocolFunction Calling的思路正好相反不给LLM塞数据而是给LLM工具让它自己按需调用。就像一个医生不会一次性读完整个医学教科书再看病而是根据症状去查对应的章节。3.2 工具声明8个Bean就是8个工具Spring AI 1.1.0对Function Calling的支持非常优雅——你只需要用BeanDescription声明一个java.util.function.Function框架自动注册为LLM可调用的工具。javaConfiguration public class McpClinicTools { // Tool 7: 医学知识库检索 —— 这就是AI搜索的核心入口 Bean(searchMedicalKnowledge) Description(搜索医学知识库查找与查询相关的症状、疾病、科室、预防保健等知识) FunctionKnowledgeRequest, ListKnowledgeResponse searchMedicalKnowledge() { return request - { // 先尝试向量检索 ListKnowledgeHit 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(根据患者描述的症状使用医学规则引擎推荐最合适的科室) FunctionSymptomRequest, SymptomResponse doSymptomTriage() { return request - { var result fallbackRuleEngine.analyze(request.symptom); // ... 解析JSON提取科室推荐 }; } // 还有6个工具getClinicInfo, getAllDepartments, searchDoctors, // getTopRatedDoctors, getDoctorDetail, getAvailableSlots }关键设计细节工具返回的是record类型——Spring AI会自动将record字段序列化为JSON SchemaLLM据此生成结构化的函数调用参数。不需要手写JSON Schema。Description是LLM选择工具的决策依据——要写得精确。比如searchDoctors的描述是按姓名、科室ID、关键词匹配专长或简介、最低评分筛选LLM看到用户说推荐评分高的内科医生就会自动传minRating4.0, keyword内科。工具内部有降级——searchMedicalKnowledge内部先尝试KnowledgeVectorServiceChromaDB失败自动切KnowledgeSearchServiceMySQL FULLTEXT。LLM完全不感知这个降级过程。3.3 核心调度System Prompt引导 ChatClient调用AiConsultServiceImpl.consult()是整个流程的入口598行代码核心逻辑只有这一段javapublic 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-chatmcp; } 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不知道能做什么。我们的版本javaprivate 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.ymljavaConfiguration public class AiChatConfig { Bean public ChatClient chatClient(ChatModel chatModel) { return ChatClient.create(chatModel); } }yamlspring: ai: deepseek: api-key: sk-xxxx base-url: https://api.deepseek.com chat: options: model: deepseek-chat temperature: 0.3 # 医学场景需低温度确保稳定性temperature0.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。javaService 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 ListKnowledgeHit search(String query, int topK) { // ── 第一道闸门健康检查 ── if (!isAvailable()) { log.warn(RAG Service 不可用降级为 MySQL FULLTEXT); return fallbackSearch.search(query, topK); } try { // ── 构造请求体 ── MapString, Object request Map.of( query, query, // 用户原始提问如头痛发热 top_k, topK // 返回条数 ); // ── HTTP POST → Python RAG Service ── String response restTemplate.postForObject( ragServiceUrl /search, request, String.class ); // ── 反序列化 JSON 结果数组 ── ListMapString, Object results objectMapper.readValue( response, new TypeReferenceListMapString, 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_fittedFalse等数据同步时训练 vectorizer TfidfVectorizer( max_features384, # 固定384维向量 analyzerchar_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(pathCHROMA_PATH) collection chroma_client.get_or_create_collection( nameclinic_knowledge, metadata{hnsw:space: cosine} # HNSW索引 余弦距离 ) # 第三步接收检索请求 app.post(/search, response_modelList[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_embeddingsquery_embedding, # 384维查询向量 n_resultsmin(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( idresults[ids][0][i], # 知识库ID titlemeta.get(title, ), # 知识标题 contentresults[documents][0][i], # 知识正文 categorymeta.get(category, ), # 分类(symptom/disease/...) department_idint(meta.get(department_id, 0)), scoreround(score, 4) # 相似度分数 )) return hits为什么用独立的Python服务而不是Java嵌入式方案ChromaDB的Java SDK不成熟Python是原生支持——chromadb库直接从PyPI安装可以独立扩缩容——知识库更新频率远低于问诊请求Python服务可单独部署、单独重启不影响Java主服务解耦——即使Python服务挂了Java端的isAvailable()检查失败后自动降级到MySQL FULLTEXT用户完全无感知嵌入方案选型过程最初设计用的是bge-large-zh-v1.51024维深度学习模型但部署时撞了三堵墙问题bge-large-zh-v1.5sklearn TF-IDF模型体积3.4GB1MB (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的典型用法javapublic ListKnowledgeHit search(String symptom, int topK) { // 构建布尔模式查询 头痛 发热 String query buildFulltextQuery(symptom); ListClinicAiKnowledge results knowledgeMapper.selectList( new LambdaQueryWrapperClinicAiKnowledge() .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的布尔模式查询构建javaprivate 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表上建全文索引sqlALTER TABLE tb_ai_knowledge ADD FULLTEXT INDEX ft_knowledge (title, content, keywords);4.3 阶段一LIKE模糊匹配——最后一层兜底当FULLTEXT也没结果时比如用户输入了非常口语化的描述继续降级到关键词LIKE匹配javaprivate ListClinicAiKnowledge fallbackLike(String symptom, int topK) { return knowledgeMapper.selectList( new LambdaQueryWrapperClinicAiKnowledge() .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作为统一入口javaService public class KnowledgeVectorService { public ListKnowledgeHit 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层优先级的关键词正则匹配javapublic 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条症状→科室映射规则javaprivate static final MapString, ListMatchRule 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 规则引擎兜底返回结构化的JSONjson{ 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向量库。pythondef 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, jsondocs) # 4. 验证确认文档数量一致 health requests.get(f{RAG_BASE}/health).json() log.info(fSync complete! {health[documents]} documents)TF-IDF的训练发生在/knowledge/add接口内部当tfidf_fittedFalse时会收集所有已有文本包括存量数据统一训练一个TF-IDF模型并持久化到tfidf_model.pkl。后续启动时如果检测到已有模型文件直接加载不需要重新训练。七、结果组装让AI回答不再是纯文本大部分AI搜索实现止步于LLM返回一段文字。但诊所场景需要结构化数据——前端要展示科室卡片、医生列表、可挂号状态、评分等。这就是buildResultFromMcp和buildResultFromFallback的价值。以症状分诊为例LLM给了自然语言回答后Java层还会做javaif (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接口将匿名记录合并到登录账号下javapublic 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 Python2核4G~¥100/月MySQL 8.0云数据库或自建~¥50/月ChromaDB内嵌无额外成本¥0总计约 ¥200/月对一个日均千次问诊的诊所SaaS来说成本几乎可以忽略。如果用量更大万次/天建议把DeepSeek换成本地部署的Qwen或Llama进一步降低API成本。9.3 关键性能指标指标数值MCP路径平均响应时间1.2s规则引擎降级响应时间80msChromaDB向量检索耗时50msMySQL FULLTEXT检索耗时30ms系统可用性99.7%十、可复现的落地步骤如果你想在自己的项目中实现类似的AI搜索按以下步骤来第1步搭建Spring AI DeepSeekxmldependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-deepseek/artifactId /dependencyyamlspring.ai.deepseek.api-key: sk-xxx spring.ai.deepseek.chat.options.model: deepseek-chat spring.ai.deepseek.chat.options.temperature: 0.3第2步声明MCP工具用BeanDescription声明FunctionInput, Output类型的工具Spring AI自动注册。第3步搭建Python RAG服务bashpip install chromadb fastapi uvicorn scikit-learn pymysql pysqlite3-binary python service.py # 启动在 :8899 python sync_knowledge.py # 同步MySQL数据第4步实现三级降级链KnowledgeVectorServiceChromaDB→ KnowledgeSearchServiceMySQL FULLTEXT→ LIKE模糊匹配AiConsultServiceImplMCP→ 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。所有架构设计和代码均经过实际验证。