一套生产级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的自动配置做得很好。只需要一个配置类注册 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嵌入式方案?

  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.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搜索需要解决三个核心问题:

  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。所有架构设计和代码均经过实际验证。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐