Bladeren bron

数据学习更新

liuchengsen 1 maand geleden
bovenliggende
commit
89f8b321c2

+ 1 - 0
.gitignore

@@ -63,3 +63,4 @@ backend-java/target/
 *.class
 *.idea
 *.idea/*
+project-analysis-report/*

+ 8 - 110
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -25,21 +25,7 @@ import java.util.stream.Collectors;
 @RequestMapping("/api/v1/chat")
 public class ChatController {
 
-    private static final Map<String, String> SECTION_DISPLAY = Map.ofEntries(
-            Map.entry("处方", "处方"), Map.entry("制法", "制法"), Map.entry("性状", "性状"),
-            Map.entry("鉴别", "鉴别"), Map.entry("检查", "检查"), Map.entry("浸出物", "浸出物"),
-            Map.entry("含量测定", "含量测定"), Map.entry("功能主治", "功能与主治"),
-            Map.entry("用法用量", "用法与用量"), Map.entry("注意事项", "注意事项"),
-            Map.entry("规格", "规格"), Map.entry("贮藏", "贮藏"), Map.entry("类别", "类别"),
-            Map.entry("制剂", "制剂"), Map.entry("附注", "附注"), Map.entry("包装", "包装"),
-            Map.entry("有效期", "有效期"), Map.entry("核准日期", "核准日期"),
-            Map.entry("修订日期", "修订日期"), Map.entry("执行标准", "执行标准"),
-            Map.entry("批准文号", "批准文号"), Map.entry("禁忌", "禁忌"),
-            Map.entry("不良反应", "不良反应"), Map.entry("正文", "正文"),
-            Map.entry("处方", "处方"), Map.entry("功能", "功能与主治"), Map.entry("主治", "功能与主治"),
-            Map.entry("用法", "用法与用量"), Map.entry("用量", "用法与用量"),
-            Map.entry("注意", "注意事项"), Map.entry("含量", "含量测定")
-    );
+    // 复用 PromptService.SECTION_DISPLAY 统一权威映射,避免两处重复定义导致不一致
 
     private final RetrieverService retrieverService;
     private final LLMService llmService;
@@ -125,7 +111,7 @@ public class ChatController {
                             String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                                     "intent", intent,
                                     "sources", sources,
-                                    "cid", cid
+                                    "conversation_id", cid
                             ));
                             sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
                         } catch (Exception ignored) {}
@@ -242,7 +228,7 @@ public class ChatController {
                                         String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                                                 "intent", intent,
                                                 "sources", sources,
-                                                "cid", cid,
+                                                "conversation_id", cid,
                                                 "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText
                                         ));
                                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
@@ -504,7 +490,7 @@ public class ChatController {
                     final List<Map<String, Object>> sources = buildSources(docs);
                     try {
                         String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
-                                "intent", intent, "sources", sources, "cid", cid,
+                                "intent", intent, "sources", sources, "conversation_id", cid,
                                 "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText));
                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
                     } catch (Exception ignored) {}
@@ -523,8 +509,9 @@ public class ChatController {
     public ResponseEntity<Map<String, Object>> getHistory(
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        var items = persistenceService.getHistory(page, pageSize);
-        return ResponseEntity.ok(Map.of("items", items, "page", page, "page_size", pageSize));
+        // getHistory 现在直接返回包含 items/page/page_size/total/total_pages 的 Map
+        var result = persistenceService.getHistory(page, pageSize);
+        return ResponseEntity.ok(result);
     }
 
     @GetMapping("/history/{cid}")
@@ -593,7 +580,7 @@ public class ChatController {
                     if (drugName == null || drugName.isEmpty()) {
                         drugName = extractDrugName(content);
                     }
-                    String sectionDisplay = SECTION_DISPLAY.getOrDefault(storedSection, storedSection);
+                    String sectionDisplay = PromptService.SECTION_DISPLAY.getOrDefault(storedSection, storedSection);
                     if (sectionDisplay == null || sectionDisplay.isEmpty()) {
                         sectionDisplay = realSection(content, storedSection);
                     }
@@ -648,95 +635,6 @@ public class ChatController {
         return sb.toString();
     }
 
-    /** 剂量/用法/禁忌等零容错场景:直接用检索结果构建回答,不经过 LLM */
-    private String buildDirectAnswer(String query, List<Map<String, Object>> docs) {
-        // 从 query 中提取可能的药品名
-        String targetDrug = extractQueryDrugName(query);
-        // 从 query 中提取关注的 section(如"用法用量"→"用法与用量")
-        String targetSection = extractQuerySection(query);
-
-        // 过滤:用 chunk 头部【药名 - section】精确匹配
-        List<Map<String, Object>> filtered = new ArrayList<>();
-        List<Map<String, Object>> fallback = new ArrayList<>();
-        for (Map<String, Object> d : docs) {
-            String content = (String) d.getOrDefault("content", "");
-            String storedSection = (String) d.getOrDefault("section", "");
-            String section = realSection(content, storedSection);
-            // 精确匹配:【布洛芬 - xxx】不匹配【布洛芬口服溶液 - xxx】
-            String exactHeader = "【" + targetDrug + " - ";
-            boolean drugExactMatch = targetDrug.isEmpty() || content.contains(exactHeader);
-            boolean drugPartialMatch = !drugExactMatch && content.contains(targetDrug);
-            boolean sectionMatch = targetSection.isEmpty() || section.contains(targetSection);
-
-            if (drugExactMatch && sectionMatch) {
-                filtered.add(d);
-            } else if (drugExactMatch) {
-                fallback.add(d);
-            } else if (drugPartialMatch && sectionMatch && filtered.isEmpty()) {
-                fallback.add(d);  // 兜底:无精确匹配时才用模糊匹配
-            }
-        }
-
-        // 精确匹配不够,用 fallback
-        List<Map<String, Object>> selected = filtered.size() >= 1 ? filtered : fallback;
-
-        StringBuilder sb = new StringBuilder();
-        sb.append("【原文引用】以下内容直接来自药典原文,未经 AI 改写:\n\n");
-        int count = 0;
-        for (Map<String, Object> d : selected) {
-            if (count >= 2) break;
-            String content = (String) d.getOrDefault("content", "");
-            String storedSection = (String) d.getOrDefault("section", "");
-            String section = realSection(content, storedSection);
-            String drugName = extractDrugName(content);
-            content = content.replaceAll("\\s*来源:.*$", "").trim();
-            if (content.length() > 300) {
-                int cut = content.indexOf('\n', 250);
-                if (cut < 0 || cut > 350) cut = 300;
-                content = content.substring(0, cut).trim() + "…";
-            }
-            if (!content.isEmpty()) {
-                sb.append("【").append(drugName).append(" - ").append(section).append("】\n");
-                sb.append(content).append("\n\n");
-                count++;
-            }
-        }
-        if (count == 0) return "未找到匹配的原文内容。";
-        sb.append("—— 以上内容直接引用自药典原文,确保准确性。\n本回答由AI生成,仅供参考。");
-        return sb.toString();
-    }
-
-    /** 从 query 提取药品名:"布洛芬的用法用量" → "布洛芬" */
-    private String extractQueryDrugName(String query) {
-        if (query == null) return "";
-        String cleaned = query.trim();
-        // 去掉常见后缀词(包括拼接形式如"阿莫西林禁忌")
-        String[] suffixes = {
-            "的用法与用量", "的用法用量", "用法与用量", "用法用量", "的用法", "的用量",
-            "的副作用", "不良反应", "的禁忌", "禁忌", "的注意事项", "注意事项",
-            "是什么", "说明书", "怎么用", "怎么吃", "多少钱", "的用量", "用量", "的剂量", "剂量"
-        };
-        for (String s : suffixes) {
-            if (cleaned.endsWith(s)) {
-                cleaned = cleaned.substring(0, cleaned.length() - s.length()).trim();
-                break;
-            }
-        }
-        // 去掉问句前缀
-        cleaned = cleaned.replaceAll("^(什么是|怎么|如何|告诉我|请问|查询|搜索|查一下)", "").trim();
-        return cleaned;
-    }
-
-    /** 从 query 提取关注的 section */
-    private String extractQuerySection(String query) {
-        if (query.contains("用法") || query.contains("用量") || query.contains("剂量") || query.contains("怎么吃") || query.contains("怎么用")) return "用法";
-        if (query.contains("禁忌") || query.contains("禁用")) return "禁忌";
-        if (query.contains("副作用") || query.contains("不良反应")) return "不良反应";
-        if (query.contains("注意") || query.contains("慎用")) return "注意";
-        if (query.contains("贮藏") || query.contains("保存")) return "贮藏";
-        return "";
-    }
-
     private String extractDrugName(String content) {
         if (content == null) return "";
         int start = content.indexOf("【");

+ 7 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/ConversationRepository.java

@@ -1,6 +1,8 @@
 package com.pharmacopoeia.repository;
 
 import com.pharmacopoeia.entity.Conversation;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 
 import java.util.List;
@@ -9,4 +11,9 @@ import java.util.Optional;
 public interface ConversationRepository extends JpaRepository<Conversation, Long> {
     Optional<Conversation> findByConversationId(String conversationId);
     List<Conversation> findByUserIdOrderByCreatedAtDesc(Long userId);
+
+    /**
+     * 按创建时间倒序分页查询,避免全量加载到内存导致 OOM。
+     */
+    Page<Conversation> findAllByOrderByCreatedAtDesc(Pageable pageable);
 }

+ 9 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/MessageRepository.java

@@ -2,6 +2,8 @@ package com.pharmacopoeia.repository;
 
 import com.pharmacopoeia.entity.Message;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 import java.util.List;
 import java.util.Optional;
@@ -9,4 +11,11 @@ import java.util.Optional;
 public interface MessageRepository extends JpaRepository<Message, Long> {
     List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId);
     long countByConversationId(String conversationId);
+
+    /**
+     * 按 conversationId 列表批量统计消息数,解决 N+1 查询问题。
+     * 返回每行 [conversationId(String), count(Long)]。
+     */
+    @Query("SELECT m.conversationId, COUNT(m) FROM Message m WHERE m.conversationId IN :cids GROUP BY m.conversationId")
+    List<Object[]> countByConversationIds(@Param("cids") List<String> cids);
 }

+ 40 - 10
backend-java/src/main/java/com/pharmacopoeia/service/ChatPersistenceService.java

@@ -5,10 +5,14 @@ import com.pharmacopoeia.entity.Conversation;
 import com.pharmacopoeia.entity.Message;
 import com.pharmacopoeia.repository.ConversationRepository;
 import com.pharmacopoeia.repository.MessageRepository;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.*;
+import java.util.stream.Collectors;
 
 @Service
 public class ChatPersistenceService {
@@ -47,16 +51,35 @@ public class ChatPersistenceService {
         messageRepository.save(msg);
     }
 
-    public List<Map<String, Object>> getHistory(int page, int pageSize) {
-        int offset = (page - 1) * pageSize;
-        List<Map<String, Object>> items = new ArrayList<>();
+    /**
+     * 分页查询对话历史。
+     * 使用数据库分页(Pageable)避免全量加载到内存导致 OOM,
+     * 并通过批量查询消息数解决 N+1 问题。
+     *
+     * @return Map 包含 items / page / page_size / total / total_pages
+     */
+    public Map<String, Object> getHistory(int page, int pageSize) {
+        // 防御性参数校验
+        int safePage = Math.max(1, page);
+        int safePageSize = Math.max(1, pageSize);
+        Pageable pageable = PageRequest.of(safePage - 1, safePageSize);
+
+        Page<Conversation> convPage = conversationRepository.findAllByOrderByCreatedAtDesc(pageable);
+
+        // 批量查询消息数,解决 N+1 问题
+        List<String> cids = convPage.getContent().stream()
+                .map(Conversation::getConversationId)
+                .collect(Collectors.toList());
+        Map<String, Long> countMap = new HashMap<>();
+        if (!cids.isEmpty()) {
+            for (Object[] row : messageRepository.countByConversationIds(cids)) {
+                countMap.put((String) row[0], (Long) row[1]);
+            }
+        }
 
-        var convs = conversationRepository.findAll();
-        convs.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));
-        int end = Math.min(offset + pageSize, convs.size());
-        for (int i = offset; i < end; i++) {
-            var c = convs.get(i);
-            long count = messageRepository.countByConversationId(c.getConversationId());
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (Conversation c : convPage.getContent()) {
+            long count = countMap.getOrDefault(c.getConversationId(), 0L);
             items.add(Map.of(
                     "conversation_id", c.getConversationId(),
                     "title", c.getTitle() != null && !c.getTitle().isBlank() ? c.getTitle() : "新的对话",
@@ -64,7 +87,14 @@ public class ChatPersistenceService {
                     "message_count", count
             ));
         }
-        return items;
+
+        return Map.of(
+                "items", items,
+                "page", safePage,
+                "page_size", safePageSize,
+                "total", convPage.getTotalElements(),
+                "total_pages", convPage.getTotalPages()
+        );
     }
 
     public List<Map<String, Object>> getConversationDetail(String cid) {

+ 5 - 0
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -3,6 +3,8 @@ package com.pharmacopoeia.service;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.pharmacopoeia.config.QwenProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
 import org.springframework.web.reactive.function.client.WebClient;
@@ -15,6 +17,8 @@ import java.util.Map;
 @Service
 public class LLMService {
 
+    private static final Logger log = LoggerFactory.getLogger(LLMService.class);
+
     private final WebClient chatClient;
     private final WebClient embedClient;
     private final QwenProperties props;
@@ -86,6 +90,7 @@ public class LLMService {
             JsonNode node = mapper.readTree(response);
             return node.get("choices").get(0).get("message").get("content").asText();
         } catch (Exception e) {
+            log.error("chat() 解析 LLM 响应失败,response={}", response, e);
             return "";
         }
     }

+ 9 - 4
backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java

@@ -135,17 +135,22 @@ public class PromptService {
             "no_docs", NO_DOCS
     );
 
-    // 栏目中文显示名映射(与 ChatController.SECTION_DISPLAY 保持一致)
-    private static final Map<String, String> SECTION_DISPLAY = Map.ofEntries(
+    // 栏目中文显示名映射(统一权威版本,供 ChatController 复用以避免重复定义不一致)
+    public static final Map<String, String> SECTION_DISPLAY = Map.ofEntries(
             Map.entry("处方", "处方"), Map.entry("制法", "制法"), Map.entry("性状", "性状"),
             Map.entry("鉴别", "鉴别"), Map.entry("检查", "检查"), Map.entry("浸出物", "浸出物"),
             Map.entry("含量测定", "含量测定"), Map.entry("功能主治", "功能与主治"),
             Map.entry("用法用量", "用法与用量"), Map.entry("注意事项", "注意事项"),
             Map.entry("规格", "规格"), Map.entry("贮藏", "贮藏"), Map.entry("类别", "类别"),
             Map.entry("制剂", "制剂"), Map.entry("附注", "附注"), Map.entry("包装", "包装"),
-            Map.entry("有效期", "有效期"), Map.entry("执行标准", "执行标准"),
+            Map.entry("有效期", "有效期"), Map.entry("核准日期", "核准日期"),
+            Map.entry("修订日期", "修订日期"), Map.entry("执行标准", "执行标准"),
             Map.entry("批准文号", "批准文号"), Map.entry("禁忌", "禁忌"),
-            Map.entry("不良反应", "不良反应"), Map.entry("正文", "正文")
+            Map.entry("不良反应", "不良反应"), Map.entry("正文", "正文"),
+            // 别名映射(简写→标准名)
+            Map.entry("功能", "功能与主治"), Map.entry("主治", "功能与主治"),
+            Map.entry("用法", "用法与用量"), Map.entry("用量", "用法与用量"),
+            Map.entry("注意", "注意事项"), Map.entry("含量", "含量测定")
     );
 
     public List<Map<String, String>> buildPrompt(String query, List<Map<String, Object>> documents, String intent) {

+ 150 - 21
backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java

@@ -1,8 +1,13 @@
 package com.pharmacopoeia.service;
 
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Duration;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -11,15 +16,87 @@ public class RetrieverService {
 
     private final JdbcTemplate jdbc;
     private final LLMService llmService;
+    private final StringRedisTemplate redis;
+    private final boolean redisAvailable;
+
+    // Embedding 缓存 key 前缀 + TTL(7 天)
+    private static final String EMBED_CACHE_PREFIX = "aiyaodian:embed:";
+    private static final Duration EMBED_CACHE_TTL = Duration.ofDays(7);
 
     private static final List<String> NEGATION_PATTERNS = List.of(
             "不是", "没有", "并非", "算不上", "怎么会是", "不可能", "不会",
             "没得", "没", "无", "不属", "不属于", "不是什么", "这不是"
     );
 
-    public RetrieverService(JdbcTemplate jdbc, LLMService llmService) {
+    public RetrieverService(JdbcTemplate jdbc, LLMService llmService,
+                            StringRedisTemplate redis) {
         this.jdbc = jdbc;
         this.llmService = llmService;
+        this.redis = redis;
+        // 探测 Redis 是否可用,不可用时降级为每次调用远程 API
+        this.redisAvailable = pingRedis();
+    }
+
+    /** 启动时探测 Redis,失败则降级为无缓存模式,避免阻塞主流程 */
+    private boolean pingRedis() {
+        try {
+            return "PONG".equals(redis.getConnectionFactory().getConnection().ping());
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /** 计算 query 的 SHA-256 作为缓存 key */
+    private String cacheKey(String text) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] hash = md.digest(text.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder(2 * hash.length);
+            for (byte b : hash) sb.append(String.format("%02x", b));
+            return EMBED_CACHE_PREFIX + sb;
+        } catch (Exception e) {
+            return EMBED_CACHE_PREFIX + text.hashCode();
+        }
+    }
+
+    /** 将 List<Float> 序列化为逗号分隔字符串,便于存入 Redis */
+    private String encodeVec(List<Float> vec) {
+        StringBuilder sb = new StringBuilder(vec.size() * 8);
+        for (int i = 0; i < vec.size(); i++) {
+            if (i > 0) sb.append(',');
+            sb.append(vec.get(i));
+        }
+        return sb.toString();
+    }
+
+    /** 反序列化 */
+    private List<Float> decodeVec(String s) {
+        if (s == null || s.isEmpty()) return List.of();
+        String[] parts = s.split(",");
+        List<Float> vec = new ArrayList<>(parts.length);
+        for (String p : parts) vec.add(Float.parseFloat(p));
+        return vec;
+    }
+
+    /** 带缓存的 Embedding 查询:相同 query 命中缓存则跳过远程 API 调用 */
+    private List<Float> embedWithCache(String query) {
+        if (!redisAvailable) return llmService.embed(query);
+
+        String key = cacheKey(query);
+        try {
+            String cached = redis.opsForValue().get(key);
+            if (cached != null) return decodeVec(cached);
+        } catch (DataAccessException ignored) {
+            // Redis 异常时降级到远程调用
+        }
+
+        List<Float> vec = llmService.embed(query);
+        try {
+            redis.opsForValue().set(key, encodeVec(vec), EMBED_CACHE_TTL);
+        } catch (DataAccessException ignored) {
+            // 缓存写入失败不影响主流程
+        }
+        return vec;
     }
 
     /** 检测 query 中关键词之前是否存在否定词,避免误匹配 */
@@ -69,46 +146,70 @@ public class RetrieverService {
         return "drug_query";
     }
 
+    /** 检索主入口:2025 优先,2025 结果不足时降级到全部版本(含 2020 补充) */
     public List<Map<String, Object>> search(String query, String intent, int topK) {
-        List<Float> vec = llmService.embed(query);
+        List<Float> vec = embedWithCache(query);
         String vecStr = vec.stream()
                 .map(String::valueOf)
                 .collect(Collectors.joining(",", "[", "]"));
 
-        // 尝试提取药品名,用于精确定位
         String drugName = extractDrugName(query);
 
+        // ============================================================
+        // 第一轮:限定 2025 年版
+        // ============================================================
+        List<Map<String, Object>> results = searchWithVersion(vecStr, drugName, "2025年版", topK);
+
+        // ============================================================
+        // 如果 2025 结果不足(<3 条 或 最高相似度 <0.4),降级到全部版本
+        // ============================================================
+        if (results.size() < 3 || maxSimilarity(results) < 0.4) {
+            List<Map<String, Object>> fallback = searchWithVersion(vecStr, drugName, null, topK);
+            // 合并:2025 结果排在前面,2020 补充排在后面,去重
+            results = mergeResults(results, fallback, topK);
+        }
+
+        return results;
+    }
+
+    /** 带版本过滤的向量检索。version 为 null 时不限制版本。 */
+    private List<Map<String, Object>> searchWithVersion(
+            String vecStr, String drugName, String version, int topK) {
+
+        String versionFilter = (version != null)
+                ? " AND d.source_version = '" + version + "'"
+                : "";
+
         String sql;
         Object[] params;
         if (!drugName.isEmpty()) {
-            // 先精确匹配,没结果再前缀匹配(如"布洛芬"→先查"布洛芬",没有再查"布洛芬片/胶囊等")
+            // 精确匹配
             sql = """
                 SELECT c.content, c.source, c.drug_id, c.section,
                        d.name, d.category, d.source_version, d.source_volume,
                        1 - (c.vec <=> ?::vector) AS similarity
                 FROM drug_chunks c
                 JOIN drugs d ON d.drug_id = c.drug_id
-                WHERE c.vec IS NOT NULL AND d.name = ?
+                WHERE c.vec IS NOT NULL AND d.name = ?""" + versionFilter + """
                 ORDER BY c.vec <=> ?::vector
                 LIMIT ?
                 """;
             params = new Object[]{vecStr, drugName, vecStr, topK};
             List<Map<String, Object>> results = jdbc.queryForList(sql, params);
-            if (results.isEmpty()) {
-                // 精确无结果,用前缀匹配
-                sql = """
-                    SELECT c.content, c.source, c.drug_id, c.section,
-                           d.name, d.category, d.source_version, d.source_volume,
-                           1 - (c.vec <=> ?::vector) AS similarity
-                    FROM drug_chunks c
-                    JOIN drugs d ON d.drug_id = c.drug_id
-                    WHERE c.vec IS NOT NULL AND d.name LIKE ?
-                    ORDER BY c.vec <=> ?::vector
-                    LIMIT ?
-                    """;
-                params = new Object[]{vecStr, drugName + "%", vecStr, topK};
-            }
-            return jdbc.queryForList(sql, params);
+            if (!results.isEmpty()) return results;
+
+            // 前缀匹配兜底
+            sql = """
+                SELECT c.content, c.source, c.drug_id, c.section,
+                       d.name, d.category, d.source_version, d.source_volume,
+                       1 - (c.vec <=> ?::vector) AS similarity
+                FROM drug_chunks c
+                JOIN drugs d ON d.drug_id = c.drug_id
+                WHERE c.vec IS NOT NULL AND d.name LIKE ?""" + versionFilter + """
+                ORDER BY c.vec <=> ?::vector
+                LIMIT ?
+                """;
+            params = new Object[]{vecStr, drugName + "%", vecStr, topK};
         } else {
             sql = """
                 SELECT c.content, c.source, c.drug_id, c.section,
@@ -116,7 +217,7 @@ public class RetrieverService {
                        1 - (c.vec <=> ?::vector) AS similarity
                 FROM drug_chunks c
                 JOIN drugs d ON d.drug_id = c.drug_id
-                WHERE c.vec IS NOT NULL
+                WHERE c.vec IS NOT NULL""" + versionFilter + """
                 ORDER BY c.vec <=> ?::vector
                 LIMIT ?
                 """;
@@ -126,6 +227,34 @@ public class RetrieverService {
         return jdbc.queryForList(sql, params);
     }
 
+    /** 合并两轮结果:2025 在前,去重,不超过 topK */
+    private List<Map<String, Object>> mergeResults(
+            List<Map<String, Object>> first, List<Map<String, Object>> second, int topK) {
+        Set<String> seen = new HashSet<>();
+        List<Map<String, Object>> merged = new ArrayList<>();
+
+        for (Map<String, Object> r : first) {
+            String key = (String) r.getOrDefault("drug_id", "") + "|" + r.getOrDefault("section", "");
+            if (seen.add(key)) merged.add(r);
+        }
+        for (Map<String, Object> r : second) {
+            String key = (String) r.getOrDefault("drug_id", "") + "|" + r.getOrDefault("section", "");
+            if (seen.add(key)) merged.add(r);
+        }
+        return merged.subList(0, Math.min(topK, merged.size()));
+    }
+
+    private double maxSimilarity(List<Map<String, Object>> results) {
+        return results.stream()
+                .mapToDouble(r -> toDouble(r.get("similarity")))
+                .max().orElse(0.0);
+    }
+
+    private double toDouble(Object o) {
+        if (o instanceof Number n) return n.doubleValue();
+        return 0.0;
+    }
+
     // 剂型后缀(长后缀优先,避免"缓释胶囊"被错误截断为"缓释")
     private static final List<String> FORMULATION_SUFFIXES = List.of(
         "缓释胶囊", "缓释片", "肠溶胶囊", "肠溶片", "分散片", "咀嚼片",

+ 61 - 27
backend-java/src/main/resources/static/index.html

@@ -219,38 +219,72 @@ async function processQ(){
   var aiDiv=addMsg('ai','',true,''),contentDiv=aiDiv.querySelector('.content');
   contentDiv.innerHTML='<div class="thinking"><span class="think-dot"></span> 检索中...</div>';
   ABORT=new AbortController();
+  var fullAnswer='', curIntent='', curSources=[], curCid=CID||'';
   try{
-    var r=await fetch(API+'/api/v1/chat/ask',{method:'POST',signal:ABORT.signal,headers:{'Content-Type':'application/json','Authorization':'Bearer '+TOKEN},body:JSON.stringify({message:text,conversationId:CID||undefined})});
+    var r=await fetch(API+'/api/v1/chat/stream',{method:'POST',signal:ABORT.signal,headers:{'Content-Type':'application/json','Accept':'text/event-stream','Authorization':'Bearer '+TOKEN},body:JSON.stringify({message:text,conversationId:CID||undefined})});
     if(!r.ok)throw new Error('HTTP '+r.status);
-    var d=await r.json();
-    var intent=d.intent||'';
-    var answer=d.answer||'无结果';
-    var sources=d.sources||[];
-    var cid=d.conversation_id||'';
-    if(cid){CID=cid;loadHistory()}
+    if(!r.body){throw new Error('浏览器不支持流式响应')}
 
-    // update tag
-    if(intent){var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(intent);tagEl.className='tag '+tagClass(intent)}}
+    var reader=r.body.getReader();
+    var decoder=new TextDecoder('utf-8');
+    var buffer='';
+    var inThinking=true;
 
-    // show answer with typing effect
-    var idx=0;
-    var timer=setInterval(function(){
-      if(idx>=answer.length){clearInterval(timer);
-        contentDiv.innerHTML=md2html(answer);contentDiv.querySelector('.cursor')?.remove();
-        aiDiv.classList.remove('streaming');
-        // add sources
-        if(sources.length){
-          var srcDiv=document.createElement('div');srcDiv.className='source';var parts=[];
-          for(var j=0;j<Math.min(5,sources.length);j++){var s=sources[j];parts.push('<span>'+(s.section||s.name||'')+' ('+Number(s.score||0).toFixed(2)+')</span>')}
-          srcDiv.innerHTML='📚 '+parts.join(' · ');aiDiv.appendChild(srcDiv)}
-        STREAMING=false;document.getElementById('stopBtn').style.display='none';
-        document.getElementById('queue').style.display='none';
-        document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
-        if(MQ.length>0)setTimeout(processQ,300);return}
-      idx+=3;
-      contentDiv.innerHTML=md2html(answer.substring(0,idx))+'<span class="cursor">|</span>';
+    function appendToken(token){
+      if(inThinking){inThinking=false;contentDiv.innerHTML='';}
+      fullAnswer+=token;
+      contentDiv.innerHTML=md2html(fullAnswer)+'<span class="cursor">|</span>';
       document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
-    },15);
+    }
+    function applyIntent(intent){
+      curIntent=intent;
+      var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(intent);tagEl.className='tag '+tagClass(intent)}
+    }
+    function setStatus(status){
+      if(inThinking&&status){var t=contentDiv.querySelector('.thinking');if(t)t.innerHTML='<span class="think-dot"></span> '+status;}
+    }
+    function applyMeta(meta){
+      if(meta.conversation_id){curCid=meta.conversation_id;CID=curCid;loadHistory()}
+      if(meta.sources&&meta.sources.length){curSources=meta.sources}
+    }
+    function finalize(){
+      contentDiv.innerHTML=md2html(fullAnswer);contentDiv.querySelector('.cursor')?.remove();
+      aiDiv.classList.remove('streaming');
+      if(curSources.length){
+        var srcDiv=document.createElement('div');srcDiv.className='source';var parts=[];
+        for(var j=0;j<Math.min(5,curSources.length);j++){var s=curSources[j];parts.push('<span>'+(s.section||s.name||'')+' ('+Number(s.score||0).toFixed(2)+')</span>')}
+        srcDiv.innerHTML='📚 '+parts.join(' · ');aiDiv.appendChild(srcDiv)}
+      STREAMING=false;document.getElementById('stopBtn').style.display='none';
+      document.getElementById('queue').style.display='none';
+      document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
+      if(MQ.length>0)setTimeout(processQ,300);
+    }
+
+    // SSE 解析循环
+    while(true){
+      var done, value;
+      var res=await reader.read();done=res.done;value=res.value;
+      if(done)break;
+      buffer+=decoder.decode(value,{stream:true});
+      var lines=buffer.split('\n');
+      buffer=lines.pop();
+      var curEvent='content';
+      for(var i=0;i<lines.length;i++){
+        var line=lines[i];
+        if(line.indexOf('event:')===0){curEvent=line.substring(6).trim();continue}
+        if(line.indexOf('data:')===0){
+          var data=line.substring(5);
+          if(data.startsWith(' '))data=data.substring(1);
+          if(data==='[DONE]'){finalize();return}
+          if(curEvent==='intent'){applyIntent(data)}
+          else if(curEvent==='status'){setStatus(data)}
+          else if(curEvent==='meta'){try{applyMeta(JSON.parse(data))}catch(e){}}
+          else{appendToken(data)}
+        }
+      }
+    }
+    if(fullAnswer||curSources.length){finalize()}
+    else{contentDiv.innerHTML='<span style="color:var(--rd)">无结果</span>';aiDiv.classList.remove('streaming');STREAMING=false;document.getElementById('stopBtn').style.display='none'}
   }catch(e){
     if(e.name!=='AbortError'){contentDiv.innerHTML='<span style="color:var(--rd)">❌ '+e.message+'</span>';aiDiv.classList.remove('streaming')}
     STREAMING=false;document.getElementById('stopBtn').style.display='none';

+ 327 - 0
data-pipeline/pdf2020_ingest.py

@@ -0,0 +1,327 @@
+"""
+2020 年版药典 PDF 导入脚本
+解析 pharmacopoeia_2020_volume1_toc.pdf → 提取药品条目 → 向量化 → 写入 PostgreSQL
+"""
+import json, os, sys, re, hashlib, asyncio
+from pathlib import Path
+from collections import OrderedDict
+
+import httpx
+import fitz  # pymupdf
+from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy import text
+
+# ============================================
+# 配置
+# ============================================
+PDF_PATH = os.environ.get("PDF_2020_PATH", os.path.join(
+    os.path.dirname(__file__), "..", "data", "pharmacopoeia_2020_volume1_toc.pdf"))
+
+PG_HOST = os.environ.get("POSTGRES_HOST", "localhost")
+PG_PORT = os.environ.get("POSTGRES_PORT", "5432")
+PG_DB = os.environ.get("POSTGRES_DB", "pharmacopoeia")
+PG_USER = os.environ.get("POSTGRES_USER", "postgres")
+PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
+DB_URL = f"postgresql+asyncpg://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
+
+QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
+EMBEDDING_URL = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding"
+EMBEDDING_MODEL = "text-embedding-v3"
+BATCH_SIZE = 10
+
+# 2020 药典 section 标题识别
+SECTION_HEADERS = OrderedDict([
+    ("性状", "性状"), ("鉴别", "鉴别"), ("检查", "检查"),
+    ("含量测定", "含量测定"), ("浸出物", "浸出物"),
+    ("性味与归经", "性味与归经"), ("性味", "性味与归经"),
+    ("功能与主治", "功能主治"), ("功能", "功能主治"), ("主治", "功能主治"),
+    ("用法与用量", "用法用量"), ("用法", "用法用量"), ("用量", "用法用量"),
+    ("注意", "注意事项"), ("注意事项", "注意事项"),
+    ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
+    ("制剂", "制剂"), ("附注", "附注"), ("禁忌", "禁忌"),
+    ("不良反应", "不良反应"),
+    ("处方", "处方"), ("制法", "制法"),
+    ("包装", "包装"), ("有效期", "有效期"),
+    ("执行标准", "执行标准"), ("批准文号", "批准文号"),
+])
+
+
+def load_env():
+    env_file = Path(__file__).resolve().parent.parent / ".env"
+    if env_file.exists():
+        for line in open(env_file, encoding="utf-8"):
+            line = line.strip()
+            if line and not line.startswith("#") and "=" in line:
+                key, _, val = line.partition("=")
+                os.environ.setdefault(key.strip(), val.strip())
+
+
+def extract_drug_entries(pdf_path: str) -> list[dict]:
+    """从 PDF 中提取药品条目"""
+    doc = fitz.open(pdf_path)
+    print(f"📖 PDF: {doc.page_count} 页")
+
+    # 收集所有页面的文本
+    all_text = ""
+    for i in range(doc.page_count):
+        text = doc[i].get_text()
+        all_text += text + "\n"
+
+    # 清理:去掉页眉页码、多余空白
+    all_text = re.sub(r'\n{3,}', '\n\n', all_text)
+    all_text = re.sub(r'^\s*\d+\s*$', '', all_text, flags=re.MULTILINE)
+
+    # 找到正文开始位置(跳过前言/TOC)
+    # 正文以药名开头,通常格式为 "药名" 后紧跟 "【性状】" 等 section
+    # 扫描找到第一个药品条目
+    lines = all_text.split('\n')
+
+    # 策略:扫描【性状】标记,向前找药名
+    entries = []
+    current_name = None
+    current_sections = OrderedDict()
+    current_section = "正文"
+    current_text = []
+    in_entry = False
+
+    for line in lines:
+        line = line.strip()
+        if not line:
+            continue
+
+        # 跳过纯页码和标题行
+        if re.match(r'^\d{1,4}$', line):
+            continue
+        if line.startswith("中国药典") or line.startswith("ISBN"):
+            continue
+        if "图书在版" in line:
+            continue
+
+        # 检测 section 标题
+        section_found = None
+        section_content = ""
+
+        # 匹配 【xxx】 格式
+        m = re.match(r'^【(.+?)】\s*(.*)', line)
+        if m:
+            sec_name = m.group(1)
+            for key, std_name in SECTION_HEADERS.items():
+                if key in sec_name:
+                    section_found = std_name
+                    section_content = m.group(2)
+                    break
+
+        if section_found:
+            if in_entry and current_name:
+                # 保存上一个 section
+                if current_text:
+                    current_sections[current_section] = '\n'.join(current_text).strip()
+                    current_text = []
+                current_section = section_found
+                if section_content:
+                    current_text.append(section_content)
+            elif not in_entry and current_name:
+                # 第一个 section,标志着药品条目开始
+                in_entry = True
+                current_sections = OrderedDict()
+                current_section = section_found
+                current_text = [section_content] if section_content else []
+            continue
+
+        # 可能是药名行(短行,没有 section 标记,以中文开头)
+        # 药名通常在 section 之前的一两行
+        if not in_entry and re.match(r'^[一-鿿]{2,20}$', line):
+            # 可能是新药名
+            if current_name and current_sections:
+                # 保存上一个药品
+                if current_text:
+                    current_sections[current_section] = '\n'.join(current_text).strip()
+                entries.append({
+                    "name": current_name,
+                    "sections": dict(current_sections),
+                })
+                current_text = []
+                current_sections = OrderedDict()
+            current_name = line
+            in_entry = False
+            current_section = "正文"
+            current_text = []
+            continue
+
+        # 正文内容行
+        if in_entry and current_name:
+            current_text.append(line)
+
+    # 最后一个药品
+    if current_name and current_sections:
+        if current_text:
+            current_sections[current_section] = '\n'.join(current_text).strip()
+        entries.append({
+            "name": current_name,
+            "sections": dict(current_sections),
+        })
+
+    doc.close()
+
+    # 过滤无效条目(至少要有 2 个 section 或内容 > 100 字)
+    valid = []
+    for e in entries:
+        content_len = sum(len(v) for v in e["sections"].values())
+        if len(e["sections"]) >= 2 or content_len > 100:
+            # 生成 drug_id
+            hash_suffix = hashlib.md5(e["name"].encode()).hexdigest()[:6].upper()
+            e["drug_id"] = f"Z2020-{hash_suffix}"
+            e["category"] = "中药"
+            e["subcategory"] = ""
+            e["name_en"] = ""
+            e["pinyin"] = ""
+            e["source"] = {
+                "version": "2020年版",
+                "volume": "一部",
+                "page": "",
+            }
+            valid.append(e)
+
+    return valid
+
+
+async def get_embeddings(texts: list[str], text_type: str = "document") -> list[list[float]]:
+    async with httpx.AsyncClient(timeout=60) as client:
+        resp = await client.post(
+            EMBEDDING_URL,
+            headers={
+                "Content-Type": "application/json",
+                "Authorization": f"Bearer {QWEN_API_KEY}",
+            },
+            json={
+                "model": EMBEDDING_MODEL,
+                "input": {"texts": texts},
+                "parameters": {"text_type": text_type},
+            },
+        )
+        data = resp.json()
+    if data.get("code") and data.get("code") != "" and data.get("code") is not None:
+        raise RuntimeError(f"Embedding error: {data.get('message', data)}")
+    return [item["embedding"] for item in data["output"]["embeddings"]]
+
+
+async def ingest_entries(entries: list[dict]):
+    """向量化 + 入库"""
+    engine = create_async_engine(DB_URL)
+
+    chunks = []
+    chunk_meta = []
+
+    for entry in entries:
+        source = f"{entry['source']['version']} {entry['source']['volume']}"
+        for section_key, section_text in entry["sections"].items():
+            if not section_text or len(section_text.strip()) < 5:
+                continue
+            content = f"【{entry['name']} - {section_key}】\n{section_text}\n\n来源:{source}"
+            chunks.append(content)
+            chunk_meta.append({
+                "drug_id": entry["drug_id"],
+                "section": section_key,
+                "content": content,
+                "source": source,
+            })
+
+    print(f"  ✂️  {len(chunks)} chunks,向量化中...")
+
+    all_vectors = []
+    for i in range(0, len(chunks), BATCH_SIZE):
+        batch = chunks[i:i + BATCH_SIZE]
+        vecs = await get_embeddings(batch, text_type="document")
+        all_vectors.extend(vecs)
+        n = min(i + BATCH_SIZE, len(chunks))
+        print(f"    向量化: {n}/{len(chunks)}")
+
+    print(f"  ✅ 向量化完成, 维度={len(all_vectors[0]) if all_vectors else 'N/A'}")
+
+    # 写入 drug_chunks
+    chunk_count = 0
+    async with engine.begin() as conn:
+        for idx, (meta, vec) in enumerate(zip(chunk_meta, all_vectors)):
+            vec_str = f"[{','.join(str(v) for v in vec)}]"
+            await conn.execute(
+                text("""
+                    INSERT INTO drug_chunks (drug_id, section, content, source, chunk_index, embedding, vec)
+                    VALUES (:drug_id, :section, :content, :source, :chunk_index, :embedding, :vec)
+                    ON CONFLICT DO NOTHING
+                """),
+                {
+                    "drug_id": meta["drug_id"],
+                    "section": meta["section"],
+                    "content": meta["content"],
+                    "source": meta["source"],
+                    "chunk_index": idx,
+                    "embedding": json.dumps(vec),
+                    "vec": vec_str,
+                },
+            )
+            chunk_count += 1
+
+    # 写入 drugs 表
+    drug_count = 0
+    async with engine.begin() as conn:
+        for entry in entries:
+            await conn.execute(
+                text("""
+                    INSERT INTO drugs (drug_id, name, name_en, pinyin, category, subcategory,
+                                       sections, source_version, source_volume, source_page, is_active)
+                    VALUES (:drug_id, :name, :name_en, :pinyin, :category, :subcategory,
+                            :sections, :source_version, :source_volume, :source_page, TRUE)
+                    ON CONFLICT (drug_id) DO UPDATE SET
+                        sections = EXCLUDED.sections,
+                        updated_at = NOW()
+                """),
+                {
+                    "drug_id": entry["drug_id"],
+                    "name": entry["name"],
+                    "name_en": entry.get("name_en", ""),
+                    "pinyin": entry.get("pinyin", ""),
+                    "category": entry.get("category", ""),
+                    "subcategory": entry.get("subcategory", ""),
+                    "sections": json.dumps(entry.get("sections", {}), ensure_ascii=False),
+                    "source_version": entry["source"]["version"],
+                    "source_volume": entry["source"]["volume"],
+                    "source_page": entry["source"].get("page", ""),
+                },
+            )
+            drug_count += 1
+
+    await engine.dispose()
+    print(f"\n🎉 2020 药典入库完成!药品 {drug_count} 个,chunk {chunk_count} 条")
+
+
+async def main():
+    load_env()
+    global QWEN_API_KEY
+    QWEN_API_KEY = os.environ.get("QWEN_API_KEY", "")
+    if not QWEN_API_KEY:
+        print("❌ 未设置 QWEN_API_KEY")
+        sys.exit(1)
+
+    print("=" * 60)
+    print("📖 解析 2020 年版药典 PDF...")
+    entries = extract_drug_entries(PDF_PATH)
+    print(f"   提取药品条目: {len(entries)}")
+    print("=" * 60)
+
+    if not entries:
+        print("❌ 未提取到有效条目,请检查 PDF 解析逻辑")
+        return
+
+    # 打印前 5 个条目作为样品
+    print("\n📋 前 5 个条目预览:")
+    for e in entries[:5]:
+        secs = list(e["sections"].keys())
+        total_len = sum(len(v) for v in e["sections"].values())
+        print(f"   {e['name']} | {len(secs)} sections ({total_len} 字) | {secs}")
+
+    print(f"\n🚀 开始向量化入库...")
+    await ingest_entries(entries)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())