liuchengsen пре 1 месец
родитељ
комит
e3533995f4
24 измењених фајлова са 2206 додато и 189 уклоњено
  1. 28 0
      backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java
  2. 4 30
      backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java
  3. 156 31
      backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java
  4. 1 1
      backend-java/src/main/java/com/pharmacopoeia/controller/DrugController.java
  5. 3 0
      backend-java/src/main/java/com/pharmacopoeia/repository/DrugRepository.java
  6. 142 11
      backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java
  7. 1 1
      backend-java/src/main/java/com/pharmacopoeia/service/ChatPersistenceService.java
  8. 15 2
      backend-java/src/main/java/com/pharmacopoeia/service/DrugService.java
  9. 43 8
      backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java
  10. 166 0
      backend-java/src/main/java/com/pharmacopoeia/service/RerankerService.java
  11. 46 14
      backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java
  12. 5 0
      backend-java/src/main/resources/application.yml
  13. 73 4
      backend-python/app/rag/reranker.py
  14. 88 44
      backend-python/app/rag/retriever.py
  15. 47 6
      data-pipeline/crawlers/extract_catalog.py
  16. 31 9
      data-pipeline/crawlers/wiki_spider.py
  17. 114 15
      data-pipeline/docx_ingest.py
  18. 157 6
      data-pipeline/processors/chunker.py
  19. 65 3
      data-pipeline/processors/cleaner.py
  20. 23 0
      database/migrate_json_to_jsonb.sql
  21. 18 4
      database/schema.sql
  22. 422 0
      docs/CHANGELOG_JAVA_PYTHON_PARITY.md
  23. 558 0
      docs/PARSING_ANALYSIS.md
  24. 0 0
      tools/write_md.py

+ 28 - 0
backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java

@@ -28,11 +28,39 @@ public class AdminController {
         int queries = jdbc.queryForObject("SELECT COUNT(*) FROM messages WHERE role = 'user'", Integer.class);
         int users = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
 
+        // Top queried drugs (last 30 days)
+        List<Map<String, Object>> topDrugs;
+        try {
+            topDrugs = jdbc.queryForList(
+                "SELECT d.name, COUNT(*) as cnt FROM messages m " +
+                "JOIN drug_chunks c ON m.content ILIKE '%' || c.drug_id || '%' " +
+                "JOIN drugs d ON d.drug_id = c.drug_id " +
+                "WHERE m.role = 'user' AND m.created_at > NOW() - INTERVAL '30 days' " +
+                "GROUP BY d.name ORDER BY cnt DESC LIMIT 10");
+        } catch (Exception e) {
+            topDrugs = List.of();
+        }
+
+        // Daily query count (last 7 days)
+        List<Map<String, Object>> dailyQueries;
+        try {
+            dailyQueries = jdbc.queryForList(
+                "SELECT TO_CHAR(created_at, 'YYYY-MM-DD') as date, COUNT(*) as cnt " +
+                "FROM messages WHERE role = 'user' " +
+                "AND created_at > NOW() - INTERVAL '7 days' " +
+                "GROUP BY date ORDER BY date");
+        } catch (Exception e) {
+            dailyQueries = List.of();
+        }
+
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("total_drugs", drugs);
         result.put("total_chunks", chunks);
         result.put("total_queries", queries);
         result.put("total_users", users);
+        result.put("top_drugs", topDrugs);
+        result.put("daily_queries", dailyQueries);
+        result.put("avg_response_time_ms", 0);  // Phase 2: implement response time tracking
         return ResponseEntity.ok(result);
     }
 

+ 4 - 30
backend-java/src/main/java/com/pharmacopoeia/controller/AdminKnowledgeController.java

@@ -28,13 +28,7 @@ public class AdminKnowledgeController {
             @RequestParam(required = false) String category,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return ResponseEntity.ok(Map.of(
-                "items", service.listDrugs(keyword, category, page, pageSize),
-                "total", 0,
-                "page", page,
-                "page_size", pageSize,
-                "message", "数据入库后可用"
-        ));
+        return ResponseEntity.ok(service.listDrugs(keyword, category, page, pageSize));
     }
 
     @GetMapping("/drugs/{drugId}")
@@ -93,12 +87,7 @@ public class AdminKnowledgeController {
             @RequestParam(required = false) String frequency,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return ResponseEntity.ok(Map.of(
-                "items", service.listKnowledgePoints(subject, chapterId, difficulty, frequency, page, pageSize),
-                "total", 0,
-                "page", page,
-                "page_size", pageSize
-        ));
+        return ResponseEntity.ok(service.listKnowledgePoints(subject, chapterId, difficulty, frequency, page, pageSize));
     }
 
     @GetMapping("/knowledge-points/{pointId}")
@@ -151,12 +140,7 @@ public class AdminKnowledgeController {
             @RequestParam(required = false) Boolean audited,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int pageSize) {
-        return ResponseEntity.ok(Map.of(
-                "items", service.listQuestions(subject, chapterId, questionType, difficulty, audited, page, pageSize),
-                "total", 0,
-                "page", page,
-                "page_size", pageSize
-        ));
+        return ResponseEntity.ok(service.listQuestions(subject, chapterId, questionType, difficulty, audited, page, pageSize));
     }
 
     @GetMapping("/questions/{questionId}")
@@ -221,17 +205,7 @@ public class AdminKnowledgeController {
 
     @GetMapping("/stats")
     public ResponseEntity<Map<String, Object>> knowledgeStats() {
-        return ResponseEntity.ok(Map.of(
-                "drugs", Map.of("total", 0, "active", 0, "with_chunks", 0),
-                "knowledge_points", Map.of("total", 0, "by_subject", Map.of()),
-                "questions", Map.of("total", 0, "audited", 0, "pending", 0),
-                "vector_index", Map.of(
-                        "status", "empty",
-                        "collection", "drug_entries",
-                        "last_reindex", null
-                ),
-                "last_data_update", null
-        ));
+        return ResponseEntity.ok(service.knowledgeStats());
     }
 
     // ==================== Helpers ====================

+ 156 - 31
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -14,27 +14,49 @@ import reactor.core.publisher.Flux;
 import reactor.core.publisher.Sinks;
 
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 @RestController
 @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("含量", "含量测定")
+    );
+
     private final RetrieverService retrieverService;
     private final LLMService llmService;
     private final PromptService promptService;
     private final ChatPersistenceService persistenceService;
+    private final RerankerService rerankerService;
     private final JdbcTemplate jdbc;
     private final QwenProperties props;
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
-                          ChatPersistenceService cps, JdbcTemplate jdbc, QwenProperties props) {
+                          ChatPersistenceService cps, RerankerService rrs,
+                          JdbcTemplate jdbc, QwenProperties props) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
         this.persistenceService = cps;
+        this.rerankerService = rrs;
         this.jdbc = jdbc;
         this.props = props;
     }
@@ -48,7 +70,7 @@ public class ChatController {
 
         String intent = retrieverService.classifyIntent(query);
         List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
-        docs = rerank(docs, query, 5);
+        docs = rerankerService.rerank(docs, query, 5);
 
         // 统一:LLM 回答 + 原文对照
         List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
@@ -63,7 +85,8 @@ public class ChatController {
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
-                "intent", intent
+                "intent", intent,
+                "conversation_id", cid
         ));
     }
 
@@ -75,7 +98,7 @@ public class ChatController {
                 : UUID.randomUUID().toString();
 
         final String intent = retrieverService.classifyIntent(query);
-        final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
+        final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
 
         Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
 
@@ -101,7 +124,8 @@ public class ChatController {
                         try {
                             String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                                     "intent", intent,
-                                    "sources", sources
+                                    "sources", sources,
+                                    "cid", cid
                             ));
                             sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
                         } catch (Exception ignored) {}
@@ -142,7 +166,7 @@ public class ChatController {
 
         String intent = retrieverService.classifyIntent(query);
         List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
-        docs = rerank(docs, query, 5);
+        docs = rerankerService.rerank(docs, query, 5);
 
         // Step 3: 构建 Prompt(含图片分析上下文)+ 联网搜索
         List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
@@ -162,7 +186,8 @@ public class ChatController {
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
-                "intent", intent
+                "intent", intent,
+                "conversation_id", cid
         ));
     }
 
@@ -193,7 +218,7 @@ public class ChatController {
                         final String intent = retrieverService.classifyIntent(query);
                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
 
-                        final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
+                        final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
                                 .data("已匹配 " + docs.size() + " 条药典资料,生成回答中(已启用联网搜索)...").build());
 
@@ -217,6 +242,7 @@ public class ChatController {
                                         String meta = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(Map.of(
                                                 "intent", intent,
                                                 "sources", sources,
+                                                "cid", cid,
                                                 "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText
                                         ));
                                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
@@ -275,7 +301,7 @@ public class ChatController {
         // Step 3: RAG 检索
         String intent = retrieverService.classifyIntent(query);
         List<Map<String, Object>> docs = retrieverService.search(query, intent, 20);
-        docs = rerank(docs, query, 5);
+        docs = rerankerService.rerank(docs, query, 5);
 
         // Step 4: 构建 Prompt + 联网搜索
         List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
@@ -298,7 +324,8 @@ public class ChatController {
         return ResponseEntity.ok(Map.of(
                 "answer", answer,
                 "sources", sources,
-                "intent", intent
+                "intent", intent,
+                "conversation_id", cid
         ));
     }
 
@@ -344,6 +371,92 @@ public class ChatController {
         return sink.asFlux();
     }
 
+    // ============================================================
+    // 文件上传 API(multipart → base64 → 复用已有对话管线)
+    // ============================================================
+
+    @PostMapping("/upload-image")
+    public ResponseEntity<Map<String, Object>> uploadImage(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(defaultValue = "") String message,
+            @RequestParam(defaultValue = "") String conversationId) {
+
+        // 校验 MIME 类型
+        Set<String> allowed = Set.of("image/jpeg", "image/png", "image/webp", "image/bmp");
+        String contentType = file.getContentType();
+        if (contentType == null || !allowed.contains(contentType)) {
+            throw new IllegalArgumentException(
+                    "不支持的图片格式: " + contentType + ",支持 jpg/png/webp/bmp");
+        }
+
+        // 校验大小 ≤ 10MB
+        if (file.getSize() > 10 * 1024 * 1024) {
+            throw new IllegalArgumentException("图片大小不能超过 10MB");
+        }
+
+        // 转 base64 → 委托给 ask-image
+        String base64;
+        try {
+            base64 = Base64.getEncoder().encodeToString(file.getBytes());
+        } catch (Exception e) {
+            throw new RuntimeException("读取上传文件失败", e);
+        }
+
+        ImageChatRequest req = new ImageChatRequest();
+        req.setImageBase64(base64);
+        req.setMimeType(contentType);
+        req.setMessage(message);
+        req.setConversationId(
+                conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
+        return chatAskImage(req);
+    }
+
+    @PostMapping("/upload-media")
+    public ResponseEntity<Map<String, Object>> uploadMedia(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(defaultValue = "") String message,
+            @RequestParam(defaultValue = "") String conversationId) {
+
+        String contentType = file.getContentType();
+        if (contentType == null) {
+            throw new IllegalArgumentException("无法识别的媒体类型");
+        }
+
+        String mediaType;
+        long maxSize;
+        if (contentType.startsWith("image/")) {
+            mediaType = "image";
+            maxSize = 10 * 1024 * 1024;  // 10MB
+        } else if (contentType.startsWith("video/")) {
+            mediaType = "video";
+            maxSize = 50 * 1024 * 1024;  // 50MB
+        } else {
+            throw new IllegalArgumentException(
+                    "不支持的媒体格式: " + contentType + ",支持 jpg/png/webp/bmp/mp4/mov/avi/webm");
+        }
+
+        if (file.getSize() > maxSize) {
+            throw new IllegalArgumentException(
+                    "文件大小不能超过 " + (maxSize / 1024 / 1024) + "MB");
+        }
+
+        String base64;
+        try {
+            base64 = Base64.getEncoder().encodeToString(file.getBytes());
+        } catch (Exception e) {
+            throw new RuntimeException("读取上传文件失败", e);
+        }
+
+        MultimodalChatRequest req = new MultimodalChatRequest();
+        req.setMessage(message);
+        req.setMediaType(mediaType);
+        req.setMediaBase64(base64);
+        req.setMediaMime(contentType);
+        req.setConversationId(
+                conversationId.isBlank() ? UUID.randomUUID().toString() : conversationId);
+        return chatAskMultimodal(req);
+    }
+
     /** 流式多模态:OCR 完成后,走 RAG + 生成 */
     private void doStreamAnswer(Sinks.Many<ServerSentEvent<String>> sink, String cid,
                                 MultimodalChatRequest request, String ocrText, String mediaLabel) {
@@ -364,7 +477,7 @@ public class ChatController {
         final String intent = retrieverService.classifyIntent(query);
         sink.tryEmitNext(ServerSentEvent.<String>builder().event("intent").data(intent).build());
 
-        final List<Map<String, Object>> docs = rerank(retrieverService.search(query, intent, 20), query, 5);
+        final List<Map<String, Object>> docs = rerankerService.rerank(retrieverService.search(query, intent, 20), query, 5);
         final boolean enableSearch = !ocrText.isEmpty() || props.isEnableWebSearch();
         sink.tryEmitNext(ServerSentEvent.<String>builder().event("status")
                 .data("已匹配 " + docs.size() + " 条药典资料,生成回答中"
@@ -391,7 +504,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,
+                                "intent", intent, "sources", sources, "cid", cid,
                                 "ocr_text", ocrText.length() > 200 ? ocrText.substring(0, 200) : ocrText));
                         sink.tryEmitNext(ServerSentEvent.<String>builder().event("meta").data(meta).build());
                     } catch (Exception ignored) {}
@@ -470,16 +583,43 @@ public class ChatController {
                 .limit(3)  // 最多 3 条
                 .map(d -> {
                     String content = (String) d.getOrDefault("content", "");
+                    String drugName = (String) d.getOrDefault("name", "");
                     String storedSection = (String) d.getOrDefault("section", "");
-                    String section = realSection(content, storedSection);
-                    String drugName = extractDrugName(content);
+                    String sourceVersion = (String) d.getOrDefault("source_version", "");
+                    String sourceVolume = (String) d.getOrDefault("source_volume", "");
+                    String category = (String) d.getOrDefault("category", "");
+
+                    // 优先用 DB 元数据,回退到内容解析
+                    if (drugName == null || drugName.isEmpty()) {
+                        drugName = extractDrugName(content);
+                    }
+                    String sectionDisplay = SECTION_DISPLAY.getOrDefault(storedSection, storedSection);
+                    if (sectionDisplay == null || sectionDisplay.isEmpty()) {
+                        sectionDisplay = realSection(content, storedSection);
+                    }
+
+                    // 构建完整来源引用
+                    StringBuilder sourceBuilder = new StringBuilder();
+                    if (!sourceVersion.isEmpty()) sourceBuilder.append(sourceVersion);
+                    if (!sourceVolume.isEmpty()) {
+                        if (!sourceBuilder.isEmpty()) sourceBuilder.append(" ");
+                        sourceBuilder.append(sourceVolume);
+                    }
+                    String src = (String) d.getOrDefault("source", "");
+                    if (!src.isEmpty()) {
+                        if (!sourceBuilder.isEmpty()) sourceBuilder.append(" ");
+                        sourceBuilder.append(src);
+                    }
+                    String fullSource = sourceBuilder.toString();
+
                     content = content.replaceAll("\\s*来源:.*$", "");
                     content = content.replaceAll("[\\r\\n]+", " ").trim();
                     String excerpt = content.length() > 500 ? content.substring(0, 500) + "…" : content;
                     return Map.<String, Object>of(
                             "name", drugName,
-                            "section", section,
-                            "source", d.getOrDefault("source", ""),
+                            "section", sectionDisplay,
+                            "category", category != null ? category : "",
+                            "source", fullSource,
                             "excerpt", excerpt
                     );
                 })
@@ -619,21 +759,6 @@ public class ChatController {
         return storedSection;
     }
 
-    private List<Map<String, Object>> rerank(List<Map<String, Object>> docs, String query, int topK) {
-        if (docs.size() <= topK) return docs;
-        docs.sort((a, b) -> {
-            double sa = toDouble(a.get("similarity"));
-            double sb = toDouble(b.get("similarity"));
-            return Double.compare(sb, sa);
-        });
-        return docs.subList(0, Math.min(topK, docs.size()));
-    }
-
-    private double toDouble(Object o) {
-        if (o instanceof Number n) return n.doubleValue();
-        return 0;
-    }
-
     private String cleanAnswer(String text) {
         if (text == null) return "";
         // 去除多余空白行(保留单个换行),修复 Qwen 常见格式问题

+ 1 - 1
backend-java/src/main/java/com/pharmacopoeia/controller/DrugController.java

@@ -36,6 +36,6 @@ public class DrugController {
 
     @GetMapping("/category/tree")
     public ResponseEntity<Map<String, Object>> getCategoryTree() {
-        return ResponseEntity.ok(Map.of("categories", drugService.getCategoryTree()));
+        return ResponseEntity.ok(Map.of("tree", drugService.getCategoryTree()));
     }
 }

+ 3 - 0
backend-java/src/main/java/com/pharmacopoeia/repository/DrugRepository.java

@@ -20,4 +20,7 @@ public interface DrugRepository extends JpaRepository<Drug, Long> {
 
     @Query("SELECT DISTINCT d.category FROM Drug d WHERE d.category IS NOT NULL ORDER BY d.category")
     List<String> findDistinctCategories();
+
+    @Query("SELECT DISTINCT d.category, d.subcategory FROM Drug d WHERE d.category IS NOT NULL ORDER BY d.category, d.subcategory")
+    List<Object[]> findCategorySubcategoryPairs();
 }

+ 142 - 11
backend-java/src/main/java/com/pharmacopoeia/service/AdminKnowledgeService.java

@@ -8,6 +8,8 @@ import com.pharmacopoeia.entity.Question;
 import com.pharmacopoeia.repository.DrugRepository;
 import com.pharmacopoeia.repository.KnowledgePointRepository;
 import com.pharmacopoeia.repository.QuestionRepository;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -19,20 +21,39 @@ public class AdminKnowledgeService {
     private final DrugRepository drugRepository;
     private final KnowledgePointRepository knowledgePointRepository;
     private final QuestionRepository questionRepository;
+    private final JdbcTemplate jdbc;
     private final ObjectMapper mapper = new ObjectMapper();
 
     public AdminKnowledgeService(DrugRepository drugRepository,
                                   KnowledgePointRepository knowledgePointRepository,
-                                  QuestionRepository questionRepository) {
+                                  QuestionRepository questionRepository,
+                                  JdbcTemplate jdbc) {
         this.drugRepository = drugRepository;
         this.knowledgePointRepository = knowledgePointRepository;
         this.questionRepository = questionRepository;
+        this.jdbc = jdbc;
     }
 
     // ==================== Drug CRUD ====================
 
-    public List<Map<String, Object>> listDrugs(String keyword, String category, int page, int pageSize) {
-        return Collections.emptyList();
+    public Map<String, Object> listDrugs(String keyword, String category, int page, int pageSize) {
+        var pageable = PageRequest.of(page - 1, pageSize);
+        var result = (keyword != null && !keyword.isBlank())
+                ? drugRepository.searchByKeyword(keyword, pageable)
+                : (category != null && !category.isBlank())
+                        ? drugRepository.findByCategoryAndIsActiveTrue(category, pageable)
+                        : drugRepository.findByIsActiveTrue(pageable);
+
+        var items = result.getContent().stream()
+                .map(this::drugToMap)
+                .collect(java.util.stream.Collectors.toList());
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("items", items);
+        response.put("total", result.getTotalElements());
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
     }
 
     public Optional<Map<String, Object>> getDrug(String drugId) {
@@ -89,10 +110,40 @@ public class AdminKnowledgeService {
 
     // ==================== KnowledgePoint CRUD ====================
 
-    public List<Map<String, Object>> listKnowledgePoints(String subject, String chapterId,
-                                                          Integer difficulty, String frequency,
-                                                          int page, int pageSize) {
-        return Collections.emptyList();
+    public Map<String, Object> listKnowledgePoints(String subject, String chapterId,
+                                                    Integer difficulty, String frequency,
+                                                    int page, int pageSize) {
+        StringBuilder sql = new StringBuilder("SELECT * FROM knowledge_points WHERE 1=1");
+        List<Object> params = new ArrayList<>();
+
+        if (subject != null && !subject.isBlank()) {
+            sql.append(" AND subject = ?");
+            params.add(subject);
+        }
+        if (chapterId != null && !chapterId.isBlank()) {
+            sql.append(" AND chapter_id = ?");
+            params.add(chapterId);
+        }
+        if (difficulty != null) {
+            sql.append(" AND difficulty = ?");
+            params.add(difficulty);
+        }
+        if (frequency != null && !frequency.isBlank()) {
+            sql.append(" AND frequency = ?");
+            params.add(frequency);
+        }
+
+        sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
+        params.add(pageSize);
+        params.add((page - 1) * pageSize);
+
+        List<Map<String, Object>> items = jdbc.queryForList(sql.toString(), params.toArray());
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("items", items);
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
     }
 
     public Optional<KnowledgePoint> getKnowledgePoint(String pointId) {
@@ -144,10 +195,44 @@ public class AdminKnowledgeService {
 
     // ==================== Question CRUD ====================
 
-    public List<Map<String, Object>> listQuestions(String subject, String chapterId,
-                                                    String questionType, Integer difficulty,
-                                                    Boolean audited, int page, int pageSize) {
-        return Collections.emptyList();
+    public Map<String, Object> listQuestions(String subject, String chapterId,
+                                              String questionType, Integer difficulty,
+                                              Boolean audited, int page, int pageSize) {
+        StringBuilder sql = new StringBuilder("SELECT * FROM questions WHERE 1=1");
+        List<Object> params = new ArrayList<>();
+
+        if (subject != null && !subject.isBlank()) {
+            sql.append(" AND subject = ?");
+            params.add(subject);
+        }
+        if (chapterId != null && !chapterId.isBlank()) {
+            sql.append(" AND chapter_id = ?");
+            params.add(chapterId);
+        }
+        if (questionType != null && !questionType.isBlank()) {
+            sql.append(" AND question_type = ?");
+            params.add(questionType);
+        }
+        if (difficulty != null) {
+            sql.append(" AND difficulty = ?");
+            params.add(difficulty);
+        }
+        if (audited != null) {
+            sql.append(" AND audited = ?");
+            params.add(audited);
+        }
+
+        sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
+        params.add(pageSize);
+        params.add((page - 1) * pageSize);
+
+        List<Map<String, Object>> items = jdbc.queryForList(sql.toString(), params.toArray());
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("items", items);
+        response.put("page", page);
+        response.put("page_size", pageSize);
+        return response;
     }
 
     public Optional<Question> getQuestion(String questionId) {
@@ -222,6 +307,52 @@ public class AdminKnowledgeService {
         return r;
     }
 
+    // ==================== Statistics ====================
+
+    public Map<String, Object> knowledgeStats() {
+        int totalDrugs = jdbc.queryForObject("SELECT COUNT(*) FROM drugs WHERE is_active = TRUE", Integer.class);
+        int drugsWithChunks = jdbc.queryForObject(
+                "SELECT COUNT(DISTINCT drug_id) FROM drug_chunks WHERE vec IS NOT NULL", Integer.class);
+
+        int totalKPs = jdbc.queryForObject("SELECT COUNT(*) FROM knowledge_points", Integer.class);
+        List<Map<String, Object>> kpBySubject = jdbc.queryForList(
+                "SELECT subject, COUNT(*) as cnt FROM knowledge_points GROUP BY subject ORDER BY cnt DESC");
+
+        int totalQs = jdbc.queryForObject("SELECT COUNT(*) FROM questions", Integer.class);
+        int auditedQs = jdbc.queryForObject("SELECT COUNT(*) FROM questions WHERE audited = TRUE", Integer.class);
+        int pendingQs = jdbc.queryForObject("SELECT COUNT(*) FROM questions WHERE audited = FALSE", Integer.class);
+
+        String lastUpdate = jdbc.queryForObject(
+                "SELECT COALESCE(MAX(updated_at)::text, '') FROM drugs WHERE is_active = TRUE", String.class);
+
+        Map<String, Object> drugsStats = new LinkedHashMap<>();
+        drugsStats.put("total", totalDrugs);
+        drugsStats.put("active", totalDrugs);
+        drugsStats.put("with_chunks", drugsWithChunks);
+
+        Map<String, Object> kpStats = new LinkedHashMap<>();
+        kpStats.put("total", totalKPs);
+        kpStats.put("by_subject", kpBySubject);
+
+        Map<String, Object> qStats = new LinkedHashMap<>();
+        qStats.put("total", totalQs);
+        qStats.put("audited", auditedQs);
+        qStats.put("pending", pendingQs);
+
+        Map<String, Object> viStats = new LinkedHashMap<>();
+        viStats.put("status", "active");
+        viStats.put("collection", "drug_chunks");
+        viStats.put("last_reindex", lastUpdate);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("drugs", drugsStats);
+        result.put("knowledge_points", kpStats);
+        result.put("questions", qStats);
+        result.put("vector_index", viStats);
+        result.put("last_data_update", lastUpdate);
+        return result;
+    }
+
     // ==================== Helpers ====================
 
     @SuppressWarnings("unchecked")

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

@@ -59,7 +59,7 @@ public class ChatPersistenceService {
             long count = messageRepository.countByConversationId(c.getConversationId());
             items.add(Map.of(
                     "conversation_id", c.getConversationId(),
-                    "title", c.getTitle() != null ? c.getTitle() : "",
+                    "title", c.getTitle() != null && !c.getTitle().isBlank() ? c.getTitle() : "新的对话",
                     "created_at", c.getCreatedAt() != null ? c.getCreatedAt().toString() : "",
                     "message_count", count
             ));

+ 15 - 2
backend-java/src/main/java/com/pharmacopoeia/service/DrugService.java

@@ -69,7 +69,20 @@ public class DrugService {
         );
     }
 
-    public List<String> getCategoryTree() {
-        return drugRepository.findDistinctCategories();
+    public List<Map<String, Object>> getCategoryTree() {
+        // 返回层次化分类树(与 Python 一致的格式)
+        List<Object[]> pairs = drugRepository.findCategorySubcategoryPairs();
+        Map<String, List<String>> tree = new java.util.LinkedHashMap<>();
+        for (Object[] row : pairs) {
+            String cat = (String) row[0];
+            String sub = (String) row[1];
+            tree.computeIfAbsent(cat, k -> new java.util.ArrayList<>());
+            if (sub != null && !sub.isBlank() && !tree.get(cat).contains(sub)) {
+                tree.get(cat).add(sub);
+            }
+        }
+        return tree.entrySet().stream()
+                .map(e -> Map.<String, Object>of("name", e.getKey(), "children", e.getValue()))
+                .collect(Collectors.toList());
     }
 }

+ 43 - 8
backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java

@@ -27,14 +27,16 @@ public class PromptService {
             """;
 
     // ============================================================
-    // 1. 药品信息查询(紧凑排版)
+    // 1. 药品信息查询(紧凑排版,适配2025版药典格式
     // ============================================================
     private static final String DRUG_QUERY = """
-            你是中华药典AI助手。简洁回答用户的药品问题。
-            规则:剂量、用法、禁忌等关键数据必须完整复制原文,数字不能丢。
+            你是中华药典AI助手,基于《中国药典》(2025年版)回答药品问题。
+            参考资料包含来源版本、药典部次、药品类别和标准栏目(如【性状】【鉴别】【检查】【含量测定】等),
+            请据此准确引用原文,保留数字、剂量、单位的完整性和准确性。
             回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
             【结论】   2-3句话概括。
-            【来源明细】列出本题引用的所有资料名称及出处。
+            【详细信息】分栏目引用原文,标注来源版本和栏目名称。
+            【来源明细】列出本题引用的所有资料名称及出处(含版本号如"2025版药典一部")。
             """ + COMPLIANCE + AI_DISCLAIMER;
 
     // ============================================================
@@ -58,7 +60,8 @@ public class PromptService {
     // 3. 法规条款查询(紧凑排版)
     // ============================================================
     private static final String REGULATION = """
-            你是药典法规条款查询助手。
+            你是药典法规条款查询助手,基于《中国药典》(2025年版)四部结构回答:
+            凡例 → 通则(制剂通则/通用检测方法/指导原则)→ 药用辅料 → 通用技术要求。
             规则:逐字引用原文条款,保留编号和术语原貌,标注条款出处。
             回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
             【摘要】    一句话概述该条款内容。
@@ -72,12 +75,14 @@ public class PromptService {
     // 4. 执业药师考试辅导(紧凑排版)
     // ============================================================
     private static final String EXAM_TUTOR = """
-            你是执业药师考试辅导助手。
+            你是执业药师考试辅导助手,基于2025年版《中国药典》和考试大纲教学
             规则:定位考点(科目-章节),分点讲解,标注来源,区分【教学补充】和【药典原文】。
+            参考资料包含药品类别、栏目、版本信息,请据此准确标注出处。
             回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
 
             【考点定位】一句话定位考点(科目-章节-知识点)。
-            【知识要点】分点讲解核心内容,标注来源名称。教学延伸标注【教学补充,非药典原文】。
+            【知识要点】分点讲解核心内容,标注来源名称(如"2025版药典二部")。
+                       教学延伸标注【教学补充,非药典原文】。
             【记忆技巧】口诀、对比表格、联想记忆等。
             【考试频率】高频 / 中频 / 低频。
             【来源明细】大纲章节 + 药典出处 + 参考资料名称。
@@ -127,6 +132,19 @@ public class PromptService {
             "no_docs", NO_DOCS
     );
 
+    // 栏目中文显示名映射(与 ChatController.SECTION_DISPLAY 保持一致)
+    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("正文", "正文")
+    );
+
     public List<Map<String, String>> buildPrompt(String query, List<Map<String, Object>> documents, String intent) {
         String systemPrompt;
         String userMessage;
@@ -138,7 +156,24 @@ public class PromptService {
             systemPrompt = PROMPT_MAP.getOrDefault(intent, NO_DOCS);
 
             String context = documents.stream()
-                    .map(d -> "【资料:" + d.get("source") + "】\n" + d.get("content"))
+                    .map(d -> {
+                        String source = (String) d.getOrDefault("source", "未知来源");
+                        String section = (String) d.getOrDefault("section", "");
+                        String sectionDisplay = SECTION_DISPLAY.getOrDefault(section, section);
+                        String category = (String) d.getOrDefault("category", "");
+                        String version = (String) d.getOrDefault("source_version", "");
+                        String volume = (String) d.getOrDefault("source_volume", "");
+                        String content = (String) d.getOrDefault("content", "");
+
+                        StringBuilder sb = new StringBuilder();
+                        sb.append("【来源:").append(source).append("】");
+                        if (!version.isEmpty()) sb.append("【版本:").append(version).append("】");
+                        if (!volume.isEmpty()) sb.append("【部次:").append(volume).append("】");
+                        if (!sectionDisplay.isEmpty()) sb.append("【栏目:").append(sectionDisplay).append("】");
+                        if (!category.isEmpty()) sb.append("【类别:").append(category).append("】");
+                        sb.append("\n").append(content);
+                        return sb.toString();
+                    })
                     .collect(Collectors.joining("\n---\n"));
 
             userMessage = "【参考资料】\n" + context + "\n"

+ 166 - 0
backend-java/src/main/java/com/pharmacopoeia/service/RerankerService.java

@@ -0,0 +1,166 @@
+package com.pharmacopoeia.service;
+
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.regex.*;
+import java.util.stream.*;
+
+/**
+ * 重排序服务:融合 Python reranker 的阈值过滤 + 关键词加权 + 去重,
+ * 以及 Java 原有的 section 加权 + 版本优先级。
+ */
+@Service
+public class RerankerService {
+
+    private static final double MIN_SIMILARITY_THRESHOLD = 0.3;
+    private static final double DEDUP_THRESHOLD = 0.8;
+
+    /**
+     * 完整重排序管线:
+     * 1. 最低相似度阈值过滤 (0.3)
+     * 2. 中文 n-gram 关键词重叠加权 (+0.3 * coverage)
+     * 3. Section 相关性加权 (+0.1)
+     * 4. 2025 版本优先级 (+0.05)
+     * 5. Jaccard 内容去重 (>0.8 视为重复)
+     */
+    public List<Map<String, Object>> rerank(
+            List<Map<String, Object>> docs, String query, int topK) {
+
+        if (docs == null || docs.isEmpty()) return List.of();
+        if (docs.size() <= topK) return docs;
+
+        // --- Step 1: 阈值过滤 ---
+        List<Map<String, Object>> filtered = docs.stream()
+                .filter(d -> toDouble(d.get("similarity")) >= MIN_SIMILARITY_THRESHOLD)
+                .collect(Collectors.toList());
+
+        // 如果全部被过滤掉,回退到原始结果(避免空结果比低置信度结果更差)
+        if (filtered.isEmpty()) {
+            filtered = new ArrayList<>(docs);
+        }
+
+        // --- Step 2: 关键词加权 ---
+        Set<String> queryTokens = tokenizeQuery(query);
+        String targetSection = extractQuerySection(query);
+        boolean hasSectionTarget = !targetSection.isEmpty();
+
+        for (Map<String, Object> doc : filtered) {
+            String content = (String) doc.getOrDefault("content", "");
+            double keywordBonus = keywordOverlapScore(queryTokens, content) * 0.3;
+
+            double score = toDouble(doc.get("similarity")) + keywordBonus;
+
+            // Section 相关性加权 (+0.1) — 沿用 Java 原有逻辑
+            if (hasSectionTarget) {
+                String sec = (String) doc.getOrDefault("section", "");
+                if (sec != null && sec.contains(targetSection)) score += 0.1;
+            }
+
+            // 2025 版优先级 (+0.05) — 沿用 Java 原有逻辑
+            String ver = (String) doc.getOrDefault("source_version", "");
+            if (ver != null && ver.contains("2025")) score += 0.05;
+
+            doc.put("score", score);
+        }
+
+        // --- Step 3: 按融合分数降序排序 ---
+        filtered.sort((a, b) -> Double.compare(
+                toDouble(b.get("score")), toDouble(a.get("score"))));
+
+        // --- Step 4: Jaccard 内容去重 ---
+        List<Map<String, Object>> deduped = new ArrayList<>();
+        List<String> seenTexts = new ArrayList<>();
+        for (Map<String, Object> doc : filtered) {
+            String content = (String) doc.getOrDefault("content", "");
+            if (isDuplicate(content, seenTexts, DEDUP_THRESHOLD)) continue;
+            deduped.add(doc);
+            seenTexts.add(content);
+        }
+
+        return deduped.subList(0, Math.min(topK, deduped.size()));
+    }
+
+    // ========================================
+    // 中文 n-gram 分词 (2-4 字) + 英文/数字词
+    // ========================================
+    Set<String> tokenizeQuery(String query) {
+        Set<String> tokens = new HashSet<>();
+        if (query == null) return tokens;
+
+        for (int n = 2; n <= 4; n++) {
+            for (int i = 0; i <= query.length() - n; i++) {
+                String seg = query.substring(i, i + n);
+                if (seg.codePoints().allMatch(this::isCJK)) {
+                    tokens.add(seg);
+                }
+            }
+        }
+        // 英文/数字词
+        Matcher m = Pattern.compile("[a-zA-Z0-9]+").matcher(query);
+        while (m.find()) {
+            tokens.add(m.group().toLowerCase());
+        }
+        return tokens;
+    }
+
+    private boolean isCJK(int codePoint) {
+        return (codePoint >= 0x4E00 && codePoint <= 0x9FFF)     // CJK Unified
+            || (codePoint >= 0x3400 && codePoint <= 0x4DBF)     // CJK Ext-A
+            || (codePoint >= 0x20000 && codePoint <= 0x2A6DF);  // CJK Ext-B
+    }
+
+    // ========================================
+    // 关键词重叠加权:query token 在文档中出现的比例
+    // ========================================
+    double keywordOverlapScore(Set<String> queryTerms, String content) {
+        if (queryTerms.isEmpty() || content == null) return 0.0;
+        long matched = queryTerms.stream().filter(content::contains).count();
+        return (double) matched / queryTerms.size();
+    }
+
+    // ========================================
+    // Jaccard 字符集去重 (>0.8 = 近似重复)
+    // ========================================
+    boolean isDuplicate(String content, List<String> seenTexts, double threshold) {
+        if (content == null || content.isEmpty() || seenTexts.isEmpty()) return false;
+
+        // 采样前 200 字符做快速比较
+        String sample = content.substring(0, Math.min(200, content.length()));
+        Set<Integer> contentSample = sample.codePoints().boxed().collect(Collectors.toSet());
+        if (contentSample.isEmpty()) return false;
+
+        // 只比较最近 5 个已选文本
+        int startIdx = Math.max(0, seenTexts.size() - 5);
+        for (int i = startIdx; i < seenTexts.size(); i++) {
+            String seen = seenTexts.get(i);
+            String seenSample = seen.substring(0, Math.min(200, seen.length()));
+            Set<Integer> seenCp = seenSample.codePoints().boxed().collect(Collectors.toSet());
+            long intersection = contentSample.stream().filter(seenCp::contains).count();
+            long union = contentSample.size() + seenCp.size() - intersection;
+            if (union > 0 && (double) intersection / union > threshold) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // ========================================
+    // 从 query 提取关注的 section
+    // ========================================
+    String extractQuerySection(String query) {
+        if (query == null) return "";
+        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 double toDouble(Object o) {
+        if (o instanceof Number n) return n.doubleValue();
+        return 0.0;
+    }
+}

+ 46 - 14
backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java

@@ -12,35 +12,60 @@ public class RetrieverService {
     private final JdbcTemplate jdbc;
     private final LLMService llmService;
 
+    private static final List<String> NEGATION_PATTERNS = List.of(
+            "不是", "没有", "并非", "算不上", "怎么会是", "不可能", "不会",
+            "没得", "没", "无", "不属", "不属于", "不是什么", "这不是"
+    );
+
     public RetrieverService(JdbcTemplate jdbc, LLMService llmService) {
         this.jdbc = jdbc;
         this.llmService = llmService;
     }
 
+    /** 检测 query 中关键词之前是否存在否定词,避免误匹配 */
+    private boolean hasNegation(String text, String keyword) {
+        int idx = text.indexOf(keyword);
+        if (idx < 0) return false;
+        String prefix = text.substring(0, idx);
+        for (String neg : NEGATION_PATTERNS) {
+            if (prefix.endsWith(neg) || prefix.contains(neg)) return true;
+        }
+        return false;
+    }
+
     public String classifyIntent(String query) {
         String q = query.trim();
 
+        // 1. 用药安全/用法用量(优先级最高,避免"过敏"与症状类冲突)
         if (anyMatch(q, "怎么吃", "吃多少", "怎么用", "怎么服用", "孕妇", "儿童用量",
                 "副作用多大", "伤肝", "伤肾", "安全吗", "副作用", "不良反应",
                 "禁忌", "过敏", "能不能", "可以吗", "用法", "用量", "剂量",
-                "用药指导", "一天几次", "一次多少", "饭前", "饭后", "空腹")) {
+                "用药指导", "一天几次", "一次多少", "饭前", "饭后", "空腹",
+                "能不能一起吃", "相互作用", "过量", "停用", "停药", "忌口",
+                "饮酒", "肝功能", "肾功能")) {
             return "usage_guide";
         }
 
-        if (anyMatch(q, "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛", "嗓子疼",
-                "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
-                "体温", "多少度", "退烧", "止痛", "止泻")) {
-            return "symptom_advice";
+        // 2. 考试辅导(高优先级,关键词明确)
+        if (anyMatch(q, "执业药师", "考点", "历年真题", "考试大纲", "高频考点",
+                "药物化学", "药剂学", "药理学", "药分", "药物分析")) {
+            return "exam_tutor";
         }
 
-        if (anyMatch(q, "凡例", "通则规定", "制剂通则", "一般规定")) {
+        // 3. 法规条款(关键词明确)
+        if (anyMatch(q, "凡例", "通则规定", "制剂通则", "一般规定", "通则")) {
             return "regulation";
         }
 
-        if (anyMatch(q, "执业药师", "考点", "历年真题", "考试大纲", "高频考点")) {
-            return "exam_tutor";
+        // 4. 症状用药建议(安全类关键词已在上方处理,"过敏"不会落到这里)
+        if (anyMatch(q, "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛", "嗓子疼",
+                "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
+                "体温", "多少度", "退烧", "止痛", "止泻", "鼻塞", "流鼻涕",
+                "头晕", "乏力", "呕吐", "腹痛", "咽痛", "打喷嚏")) {
+            return "symptom_advice";
         }
 
+        // 5. 兜底:药品查询
         return "drug_query";
     }
 
@@ -59,6 +84,7 @@ public class RetrieverService {
             // 先精确匹配,没结果再前缀匹配(如"布洛芬"→先查"布洛芬",没有再查"布洛芬片/胶囊等")
             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
@@ -72,6 +98,7 @@ public class RetrieverService {
                 // 精确无结果,用前缀匹配
                 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
@@ -84,11 +111,13 @@ public class RetrieverService {
             return jdbc.queryForList(sql, params);
         } else {
             sql = """
-                SELECT content, source, drug_id, section,
-                       1 - (vec <=> ?::vector) AS similarity
-                FROM drug_chunks
-                WHERE vec IS NOT NULL
-                ORDER BY vec <=> ?::vector
+                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
+                ORDER BY c.vec <=> ?::vector
                 LIMIT ?
                 """;
             params = new Object[]{vecStr, vecStr, topK};
@@ -132,7 +161,10 @@ public class RetrieverService {
 
     private boolean anyMatch(String text, String... keywords) {
         for (String kw : keywords) {
-            if (text.contains(kw)) return true;
+            int idx = text.indexOf(kw);
+            if (idx >= 0 && !hasNegation(text, kw)) {
+                return true;
+            }
         }
         return false;
     }

+ 5 - 0
backend-java/src/main/resources/application.yml

@@ -26,6 +26,11 @@ spring:
     serialization:
       write-dates-as-timestamps: false
 
+  servlet:
+    multipart:
+      max-file-size: 50MB
+      max-request-size: 55MB
+
 server:
   port: 9000
 

+ 73 - 4
backend-python/app/rag/reranker.py

@@ -1,12 +1,20 @@
 """
 BGE-Reranker-v2-m3 重排序
 Cross-Encoder 精排,将 Top-20 精准压缩到 Top-5
+在当前未加载 Cross-Encoder 模型的情况下,提供基于信号融合的精排:
+  - 最低相似度阈值过滤
+  - 关键词重叠度加权
+  - 内容去重
 """
+import re
 from typing import Optional
 from app.core.config import get_settings
 
 settings = get_settings()
 
+# 最低相似度阈值:低于此分数的结果很可能不相关,直接丢弃
+MIN_SIMILARITY_THRESHOLD = 0.3
+
 
 class Reranker:
     def __init__(self, model_name: Optional[str] = None):
@@ -14,6 +22,7 @@ class Reranker:
         self._model = None
 
     def _load_model(self):
+        """Phase 3 实现:加载 BGE-Reranker-v2-m3 Cross-Encoder"""
         pass
 
     def rerank(
@@ -22,9 +31,69 @@ class Reranker:
         documents: list[dict],
         top_k: int = 5,
     ) -> list[dict]:
+        """重排序:融合向量相似度 + 关键词重叠度,过滤低质量结果并去重。"""
         if not documents:
             return []
-        sorted_docs = sorted(
-            documents, key=lambda d: d.get("score", 0), reverse=True
-        )
-        return sorted_docs[:top_k]
+
+        # 1. 过滤:低于阈值的结果直接丢弃(避免答非所问)
+        filtered = [d for d in documents if d.get("score", 0) >= MIN_SIMILARITY_THRESHOLD]
+
+        # 2. 关键词加权:查询词在文档中出现越多,得分越高
+        query_terms = self._tokenize_query(query)
+        for doc in filtered:
+            content = doc.get("content", "")
+            keyword_bonus = self._keyword_overlap_score(query_terms, content)
+            # 原始相似度 + 关键词奖励(关键词匹配最高加 0.3)
+            doc["score"] = doc.get("score", 0) + keyword_bonus * 0.3
+
+        # 3. 按融合分数排序
+        sorted_docs = sorted(filtered, key=lambda d: d.get("score", 0), reverse=True)
+
+        # 4. 去重:移除内容高度重叠的 chunk(Jaccard 相似度 > 0.8)
+        deduped = []
+        seen_texts = []
+        for doc in sorted_docs:
+            content = doc.get("content", "")
+            if self._is_duplicate(content, seen_texts, threshold=0.8):
+                continue
+            deduped.append(doc)
+            seen_texts.append(content)
+
+        return deduped[:top_k]
+
+    def _tokenize_query(self, query: str) -> set[str]:
+        """提取查询中的关键词(中文按 1-4 字切分,英文按空格切分)。"""
+        tokens = set()
+        # 中文:提取 2-4 字短语
+        for n in range(2, 5):
+            for i in range(len(query) - n + 1):
+                seg = query[i:i + n]
+                if all('一' <= c <= '鿿' for c in seg):
+                    tokens.add(seg)
+        # 英文/数字词
+        for word in re.findall(r'[a-zA-Z0-9]+', query):
+            tokens.add(word.lower())
+        return tokens
+
+    def _keyword_overlap_score(self, query_terms: set[str], content: str) -> float:
+        """计算查询词在文档中的覆盖率(0.0 ~ 1.0)。"""
+        if not query_terms:
+            return 0.0
+        matched = sum(1 for t in query_terms if t in content)
+        return matched / len(query_terms)
+
+    def _is_duplicate(self, content: str, seen_texts: list[str], threshold: float = 0.8) -> bool:
+        """检查当前内容是否与已选中的内容高度重复(基于 Jaccard 字符集相似度)。"""
+        if not content or not seen_texts:
+            return False
+        # 采样前 200 字符做快速比较
+        content_sample = set(content[:200])
+        if not content_sample:
+            return False
+        for seen in seen_texts[-5:]:  # 只比较最近 5 个
+            seen_sample = set(seen[:200])
+            intersection = len(content_sample & seen_sample)
+            union = len(content_sample | seen_sample)
+            if union > 0 and intersection / union > threshold:
+                return True
+        return False

+ 88 - 44
backend-python/app/rag/retriever.py

@@ -2,6 +2,7 @@
 混合检索器:pgvector 向量检索 + BM25 关键词检索
 """
 import os
+import re
 import json
 from typing import Optional
 
@@ -19,26 +20,52 @@ DB_URL = settings.database_url
 
 
 def classify_intent(query: str) -> str:
+    """基于关键词的意图分类,含否定语义处理和置信度兜底。"""
     q = query.strip()
 
-    usage_keywords = ["怎么吃", "吃多少", "怎么用", "一天几次", "多长时间",
-                      "能一起吃", "孕妇能用", "儿童用量", "哺乳期",
-                      "饭前还是饭后", "空腹", "过量", "漏服", "停药",
-                      "副作用多大", "伤肝吗", "伤肾吗", "安全吗"]
-    safety_sections = ["副作用", "不良反应", "禁忌", "过敏", "注意事项",
-                       "能不能", "可以吗", "会不"]
-    regulation_keywords = ["凡例", "通则规定", "制剂通则",
-                           "一般规定", "通用技术要求", "检验方法通则"]
-    exam_keywords = ["执业药师考试", "考点", "历年真题", "考试大纲",
-                     "高频考点", "报名时间"]
+    # --- 否定语义检测:先检查是否包含否定模式 ---
+    negation_patterns = [
+        r"不是", r"没有", r"并非", r"不算", r"不属于",
+        r"这不是", r"我没有", r"不包含", r"不涉及",
+    ]
+    has_negation = any(re.search(pat, q) for pat in negation_patterns)
+
+    # 如果有否定语义,直接返回药品查询(用户可能在排除某些情况)
+    if has_negation:
+        return "drug_query"
+
+    # --- 关键词定义 ---
+    usage_keywords = [
+        "怎么吃", "吃多少", "怎么用", "一天几次", "多长时间",
+        "能一起吃", "孕妇能用", "儿童用量", "哺乳期",
+        "饭前还是饭后", "空腹", "过量", "漏服", "停药",
+        "副作用多大", "伤肝吗", "伤肾吗", "安全吗",
+    ]
+    safety_sections = [
+        "副作用", "不良反应", "禁忌", "注意事项",
+        "能不能", "可以吗", "会不会",
+    ]
+    regulation_keywords = [
+        "凡例", "通则规定", "制剂通则",
+        "一般规定", "通用技术要求", "检验方法通则",
+    ]
+    exam_keywords = [
+        "执业药师考试", "考点", "历年真题", "考试大纲",
+        "高频考点", "报名时间",
+    ]
     # 症状/疾病求药关键词
-    symptom_keywords = ["吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
-                        "买什么药", "推荐什么药", "用什么药", "用药建议",
-                        "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
-                        "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
-                        "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
-                        "体温", "多少度", "退烧", "止痛", "止泻"]
+    symptom_keywords = [
+        "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
+        "买什么药", "推荐什么药", "用什么药", "用药建议",
+        "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛",
+        "嗓子疼", "流鼻涕", "鼻塞", "肚子疼", "胃疼",
+        "过敏", "皮肤痒", "失眠", "便秘", "牙疼",
+        "体温", "多少度", "退烧", "止痛", "止泻",
+    ]
 
+    # --- 优先级匹配(usage > symptom > regulation > exam > drug_query) ---
+    # 注意:"过敏"从 safety_sections 中移除,只保留在 symptom_keywords,
+    # 避免"过敏"被误判为用法询问而非症状求药。
     if any(kw in q for kw in usage_keywords):
         return "usage_guide"
     if any(kw in q for kw in safety_sections):
@@ -49,6 +76,8 @@ def classify_intent(query: str) -> str:
         return "regulation"
     if any(kw in q for kw in exam_keywords):
         return "exam_tutor"
+
+    # 兜底:无明确意图时走药品通用查询(向量检索命中率最高)
     return "drug_query"
 
 
@@ -77,6 +106,25 @@ class MixedRetriever:
     def __init__(self):
         self.table_name = "drug_chunks"
         self.vector_dim = settings.embedding_dim
+        self._engine = None
+
+    @property
+    def engine(self):
+        """延迟创建数据库引擎,复用连接池。"""
+        if self._engine is None:
+            self._engine = create_async_engine(
+                DB_URL,
+                pool_size=5,
+                max_overflow=10,
+                pool_pre_ping=True,
+            )
+        return self._engine
+
+    async def close(self):
+        """释放数据库连接池。"""
+        if self._engine is not None:
+            await self._engine.dispose()
+            self._engine = None
 
     async def search(
         self,
@@ -88,31 +136,27 @@ class MixedRetriever:
         query_vec = await _get_query_embedding(query)
         vec_str = "[" + ",".join(str(v) for v in query_vec) + "]"
 
-        engine = create_async_engine(DB_URL)
-        try:
-            async with engine.connect() as conn:
-                result = await conn.execute(
-                    text("""
-                        SELECT content, source, drug_id, section,
-                               1 - (vec <=> CAST(:qv AS vector)) AS similarity
-                        FROM drug_chunks
-                        WHERE vec IS NOT NULL
-                        ORDER BY vec <=> CAST(:qv AS vector)
-                        LIMIT :k
-                    """),
-                    {"qv": vec_str, "k": top_k},
-                )
-                rows = result.fetchall()
-
-            docs = []
-            for row in rows:
-                docs.append({
-                    "content": row[0],
-                    "source": row[1],
-                    "drug_id": row[2],
-                    "section": row[3],
-                    "score": float(row[4]),
-                })
-            return docs
-        finally:
-            await engine.dispose()
+        async with self.engine.connect() as conn:
+            result = await conn.execute(
+                text("""
+                    SELECT content, source, drug_id, section,
+                           1 - (vec <=> CAST(:qv AS vector)) AS similarity
+                    FROM drug_chunks
+                    WHERE vec IS NOT NULL
+                    ORDER BY vec <=> CAST(:qv AS vector)
+                    LIMIT :k
+                """),
+                {"qv": vec_str, "k": top_k},
+            )
+            rows = result.fetchall()
+
+        docs = []
+        for row in rows:
+            docs.append({
+                "content": row[0],
+                "source": row[1],
+                "drug_id": row[2],
+                "section": row[3],
+                "score": float(row[4]),
+            })
+        return docs

+ 47 - 6
data-pipeline/crawlers/extract_catalog.py

@@ -89,8 +89,49 @@ def find_toc_pages(pages: list[dict]) -> tuple[int, int]:
     return start, end
 
 
+def compute_level_thresholds(pages: list[dict], start: int, end: int) -> dict:
+    """分析 block x 坐标,动态检测层级边界(替代硬编码阈值)"""
+    x_values = []
+    for pi in range(start, min(end + 1, len(pages))):
+        for block in pages[pi]["blocks"]:
+            text = block["text"].strip()
+            if len(text) < 3:
+                continue
+            x_values.append(block["x0"])
+
+    if not x_values:
+        return {"part_max": 50, "chapter_max": 80}
+
+    # 聚类:找自然断点作为 part/chapter/section 边界
+    x_sorted = sorted(set(x_values))
+    if len(x_sorted) < 3:
+        return {"part_max": 50, "chapter_max": 80}
+
+    # 找最大的两个间距作为分界
+    gaps = [(x_sorted[i+1] - x_sorted[i], x_sorted[i], x_sorted[i+1])
+            for i in range(len(x_sorted) - 1)]
+    gaps.sort(reverse=True)
+
+    # 用小 gap 作为 part 边界,大 gap 作为 chapter 边界
+    small_gap, large_gap = sorted(gaps[:2], key=lambda g: g[1])
+    part_max = small_gap[2]    # 部级最大 x
+    chapter_max = large_gap[2]  # 章级最大 x
+
+    # 合理性检查:如果两个阈值过于接近,回退到默认值
+    if chapter_max - part_max < 5:
+        print("   ⚠️ 动态阈值过于接近,使用默认值 (50, 80)")
+        return {"part_max": 50, "chapter_max": 80}
+
+    return {"part_max": part_max, "chapter_max": chapter_max}
+
+
 def parse_toc_structure(pages: list[dict], start: int, end: int) -> list[dict]:
-    """解析目录层级结构"""
+    """解析目录层级结构(动态阈值)"""
+    thresholds = compute_level_thresholds(pages, start, end)
+    part_max = thresholds["part_max"]
+    chapter_max = thresholds["chapter_max"]
+    print(f"   动态层级阈值: 部级 x<{part_max}, 章级 {part_max}≤x<{chapter_max}, 节级 x≥{chapter_max}")
+
     toc_items = []
     current_part = ""  # 部
     current_chapter = ""  # 章
@@ -104,7 +145,7 @@ def parse_toc_structure(pages: list[dict], start: int, end: int) -> list[dict]:
             x = block["x0"]  # 缩进量判断层级
 
             # 部级(最左对齐)
-            if x < 50 and ("凡例" in text or "通则" in text or "正文" in text):
+            if x < part_max and ("凡例" in text or "通则" in text or "正文" in text):
                 item = {
                     "level": 1,
                     "title": text,
@@ -116,8 +157,8 @@ def parse_toc_structure(pages: list[dict], start: int, end: int) -> list[dict]:
                 current_chapter = ""
                 print(f"  📘 {text}")
 
-            # 章级(左缩进约 50-80
-            elif 40 <= x < 100 and ("章" in text or len(text) > 3):
+            # 章级(elif 确保不与部级重叠
+            elif part_max <= x < chapter_max and ("章" in text or len(text) > 3):
                 item = {
                     "level": 2,
                     "title": text,
@@ -131,8 +172,8 @@ def parse_toc_structure(pages: list[dict], start: int, end: int) -> list[dict]:
                 current_chapter = text
                 print(f"    📗 {text}")
 
-            # 节级(更右的缩进
-            elif x >= 80 and ("节" in text or "章" not in text):
+            # 节级(elif 确保不与章级重叠
+            elif x >= chapter_max:
                 item = {
                     "level": 3,
                     "title": text,

+ 31 - 9
data-pipeline/crawlers/wiki_spider.py

@@ -43,6 +43,10 @@ SECTION_PATTERNS = {
     "药理": re.compile(r"(药理|药效|作用机制|Pharmacology|Mechanism)"),
 }
 
+# 内容长度限制(避免单 section 过长超出 LLM 上下文窗口)
+MAX_OVERVIEW_LENGTH = 5000
+MAX_SECTION_LENGTH = 8000
+
 
 def fetch_wiki_page(drug_name: str, client: httpx.Client) -> tuple[int, str]:
     """获取维基百科页面"""
@@ -88,7 +92,7 @@ def parse_drug_sections(html: str, drug_name: str) -> dict:
         if elem.name in ("h2", "h3", "h4"):
             # 保存之前的内容
             if overview_text:
-                sections["概述"] = " ".join(overview_text)[:2000]
+                sections["概述"] = " ".join(overview_text)[:MAX_OVERVIEW_LENGTH]
                 overview_text = []
             break
         if elem.name == "p" or elem.name == "div":
@@ -97,7 +101,7 @@ def parse_drug_sections(html: str, drug_name: str) -> dict:
                 overview_text.append(t)
 
     if overview_text:
-        sections["概述"] = " ".join(overview_text)[:2000]
+        sections["概述"] = " ".join(overview_text)[:MAX_OVERVIEW_LENGTH]
 
     # 提取各 section
     for elem in content.children:
@@ -109,9 +113,9 @@ def parse_drug_sections(html: str, drug_name: str) -> dict:
                         matched = key
                         break
                 if matched:
-                    sections[matched] = " ".join(current_text)[:3000]
+                    sections[matched] = " ".join(current_text)[:MAX_SECTION_LENGTH]
                 else:
-                    sections[current_section] = " ".join(current_text)[:3000]
+                    sections[current_section] = " ".join(current_text)[:MAX_SECTION_LENGTH]
             current_section = elem.get_text(strip=True).replace("[编辑]", "").strip()
             current_text = []
             continue
@@ -129,9 +133,9 @@ def parse_drug_sections(html: str, drug_name: str) -> dict:
                 matched = key
                 break
         if matched:
-            sections[matched] = " ".join(current_text)[:3000]
+            sections[matched] = " ".join(current_text)[:MAX_SECTION_LENGTH]
         else:
-            sections[current_section] = " ".join(current_text)[:3000]
+            sections[current_section] = " ".join(current_text)[:MAX_SECTION_LENGTH]
 
     return {
         "sections": sections,
@@ -161,12 +165,30 @@ def wiki_to_drug_entry(drug_name: str, parsed: dict) -> dict:
 
 
 def infer_category(name: str, sections: dict) -> str:
-    """推断药品分类"""
+    """推断药品分类(基于名称和正文内容综合判断)"""
     all_text = " ".join(sections.values()) + name
-    if any(k in all_text for k in ["疫苗", "毒素", "抗血清", "免疫球蛋白"]):
+
+    # 生物制品特征词(优先级最高)
+    bio_markers = ["疫苗", "毒素", "抗血清", "免疫球蛋白", "单克隆抗体", "重组", "基因工程"]
+    if any(k in all_text for k in bio_markers):
         return "生物制品"
-    if any(k in name for k in ["草", "花", "叶", "根", "皮", "丸", "散", "汤", "丹"]):
+
+    # 中药特征:多个特征同时出现才判定为中药,减少误判
+    tcm_name_markers = ["丸", "散", "汤", "丹", "膏", "颗粒", "胶囊"]
+    tcm_content_markers = ["中药", "本草", "性味", "归经", "炮制", "饮片"]
+    tcm_herb_chars = ["草", "花", "叶", "根", "皮", "参", "芪", "苓", "术", "芍", "芷"]
+
+    # 检查药品名是否含多个中药特征
+    name_tcm_score = sum(1 for k in tcm_name_markers if k in name)
+    name_tcm_score += sum(1 for c in tcm_herb_chars if c in name)
+
+    # 检查正文是否含中药特征
+    content_tcm = any(k in all_text for k in tcm_content_markers)
+
+    # 名称含中药剂型 + 至少 1 个其他特征 → 中药
+    if name_tcm_score >= 2 or (name_tcm_score >= 1 and content_tcm):
         return "中药"
+
     return "化学药"
 
 

+ 114 - 15
data-pipeline/docx_ingest.py

@@ -14,6 +14,7 @@ from collections import OrderedDict
 
 import httpx
 from docx import Document
+from docx.oxml.ns import qn
 from sqlalchemy.ext.asyncio import create_async_engine
 from sqlalchemy import text
 
@@ -49,6 +50,9 @@ SECTION_HEADERS = OrderedDict([
     ("注意", "注意事项"), ("注意事项", "注意事项"),
     ("规格", "规格"), ("贮藏", "贮藏"), ("类别", "类别"),
     ("制剂", "制剂"), ("附注", "附注"),
+    ("包装", "包装"), ("有效期", "有效期"),
+    ("核准日期", "核准日期"), ("修订日期", "修订日期"),
+    ("执行标准", "执行标准"), ("批准文号", "批准文号"),
 ])
 
 # 药典部别映射
@@ -74,11 +78,23 @@ def load_env():
                     os.environ.setdefault(key.strip(), val.strip())
 
 
+def _extract_table_text(table) -> str:
+    """将 python-docx Table 对象转换为可读文本。"""
+    rows = []
+    for row in table.rows:
+        cells = [cell.text.strip() for cell in row.cells]
+        # 跳过全空行
+        if any(c for c in cells):
+            rows.append(" | ".join(cells))
+    return "\n".join(rows)
+
+
 def parse_docx(filepath: str) -> dict | None:
     """
     解析单个 DOCX 文件为药典条目。
     药品名 = 文件名(去掉 .docx)
     凡例/纲要类:名称加上部别前缀
+    按文档顺序遍历段落和表格,表格内容归入当前 section。
     """
     filename = Path(filepath).stem
     volume = get_volume_group(filepath)
@@ -98,16 +114,48 @@ def parse_docx(filepath: str) -> dict | None:
     current_section = "正文"
     current_text = []
 
-    # 收集所有段落
-    for p in doc.paragraphs:
-        text = p.text.strip()
-        if not text or text.isspace():
+    # 遍历文档 body 中的所有元素(按文档顺序,同时处理段落和表格)
+    body = doc.element.body
+    for child in body:
+        # --- 表格 ---
+        if child.tag == qn("w:tbl"):
+            # 找到对应的 python-docx Table 对象
+            tbl = None
+            for t in doc.tables:
+                if t._element is child:
+                    tbl = t
+                    break
+            if tbl is not None:
+                tbl_text = _extract_table_text(tbl)
+                if tbl_text:
+                    current_text.append(f"[表格]\n{tbl_text}")
             continue
 
-        style = p.style.name if p.style else ""
+        # --- 段落 ---
+        if child.tag != qn("w:p"):
+            continue
+
+        # 从段落 XML 中提取文本和样式
+        p_text = ""
+        p_style = ""
+        for p_child in child:
+            if p_child.tag == qn("w:pPr"):
+                for style_child in p_child:
+                    if style_child.tag == qn("w:pStyle"):
+                        p_style = style_child.get(qn("w:val"), "")
+            elif p_child.tag == qn("w:r"):
+                for r_child in p_child:
+                    if r_child.tag == qn("w:t"):
+                        t = r_child.text
+                        if t:
+                            p_text += t
+
+        text = p_text.strip()
+        if not text or text.isspace():
+            continue
 
         # Heading 1 通常是药品名(与文件名一致),直接跳过,不加入 sections
-        if 'Heading 1' in style:
+        if p_style and (p_style.startswith("Heading") or "Heading" in p_style):
             continue
 
         # 拼音行(紧跟药名后)
@@ -115,11 +163,11 @@ def parse_docx(filepath: str) -> dict | None:
             pinyin = text.strip()
             continue
 
-        # 独立的 section 标题(2-6 字短行)
+        # 独立的 section 标题(短行,阈值放宽以适应较长标题
         section_matched = detect_section_header(text)
-        if section_matched and len(text) <= 10:
+        if section_matched and len(text) <= 15:
             if current_text:
-                sections[current_section] = '\n'.join(current_text).strip()
+                sections[current_section] = "\n".join(current_text).strip()
                 current_text = []
             current_section = section_matched
             continue
@@ -128,7 +176,7 @@ def parse_docx(filepath: str) -> dict | None:
         m = re.match(r'^【(.+?)】\s*(.*)', text)
         if m and detect_section_header(m.group(1)):
             if current_text:
-                sections[current_section] = '\n'.join(current_text).strip()
+                sections[current_section] = "\n".join(current_text).strip()
                 current_text = []
             current_section = detect_section_header(m.group(1))
             if m.group(2):
@@ -178,12 +226,12 @@ def split_numbered_content(text: str) -> OrderedDict:
       - 1. 2. 3. 或 1)2)3)
     """
     # 中文数字
-    cn_nums = "一二三四五六七八九十"
+    cn_nums = "一二三四五六七八九十百千"
     patterns = [
-        # ^一、或 \n一、 开头
-        re.compile(r'(?:^|\n)([' + cn_nums + r']{1,2})[、,,]'),
+        # ^一、或 \n一、 开头(支持"二十一"等复合数字,最大3字)
+        re.compile(r'(?:^|\n)([' + cn_nums + r']{1,3})[、,,]'),
         # (一)开头
-        re.compile(r'(?:^|\n)(([' + cn_nums + r']{1,2}))'),
+        re.compile(r'(?:^|\n)(([' + cn_nums + r']{1,3}))'),
         # 数字编号
         re.compile(r'(?:^|\n)(\d{1,2})[\.\))]'),
     ]
@@ -237,8 +285,59 @@ def split_numbered_content(text: str) -> OrderedDict:
 
 
 def is_pinyin_line(text: str) -> bool:
+    """检测文本是否为拼音行。
+
+    判断逻辑:
+    1. 字母占比 > 30% 且长度在合理范围
+    2. 不含常见英文单词(如 drug, tablet, injection 等)
+    3. 不含化学元素符号(如 Na, K, HCl 等大写字母组合)
+    4. 拼音通常全小写或带声调符号
+    """
+    if len(text) <= 3 or len(text) >= 200:
+        return False
+
+    # 检查是否包含英文单词(排除化学药英文名)
+    english_indicators = [
+        "tablet", "injection", "capsule", "solution", "sodium",
+        "chloride", "hydrochloride", "acid", "oxide", "sulfate",
+        "phosphate", "acetate", "extract", "powder", "granule",
+        "oral", "intravenous", "topical", "ophthalmic",
+        "for", "and", "the", "with", "mg", "ml", "μg",
+        "Tablet", "Injection", "Capsule", "Solution",
+    ]
+    text_lower = text.lower()
+    if any(ind in text_lower for ind in english_indicators):
+        return False
+
+    # 检查是否含大写字母组合(化学式如 HCl, NaOH 等)
+    if re.search(r'[A-Z]{2,}', text):
+        return False
+
+    # 计算字母占比
     alpha_count = sum(1 for c in text if c.isascii() and c.isalpha())
-    return alpha_count > len(text) * 0.3 and len(text) > 3 and len(text) < 200
+    total = len(text.replace(" ", ""))
+    if total == 0:
+        return False
+    alpha_ratio = alpha_count / total
+
+    # 拼音行通常字母占比 > 50% 且以空格分词
+    if alpha_ratio < 0.5:
+        return False
+
+    # 检查是否有拼音声调标记(āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ)
+    tone_marks = set("āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ")
+    has_tones = any(c in tone_marks for c in text)
+
+    # 拼音行特征:全小写字母 + 空格分词 + 可能带声调
+    # 单词长度短(拼音音节通常 1-6 个字母)
+    words = text.split()
+    if len(words) >= 2 and all(len(w) <= 8 for w in words):
+        if has_tones:
+            return True
+        # 无明确声调时提高阈值,减少误判
+        return alpha_ratio > 0.7
+
+    return has_tones
 
 
 def detect_section_header(text: str) -> str | None:

+ 157 - 6
data-pipeline/processors/chunker.py

@@ -63,7 +63,7 @@ class DrugChunker:
     def _split_long_section(
         self, text: str, drug_name: str, section: str, source: dict
     ) -> list[Chunk]:
-        """对过长 section 按段落切分"""
+        """对过长 section 按段落切分,相邻 chunk 之间保留 overlap 以避免上下文断裂"""
         paragraphs = text.split("\n")
         chunks = []
         buffer = ""
@@ -78,7 +78,11 @@ class DrugChunker:
                         "category": "",
                     }
                 ))
-                buffer = para
+                # overlap: 从上一个 buffer 尾部保留 chunk_overlap 字符作为下一段的上下文
+                if self.chunk_overlap > 0 and len(buffer) > self.chunk_overlap:
+                    buffer = buffer[-self.chunk_overlap:] + "\n" + para
+                else:
+                    buffer = para
             else:
                 buffer += ("\n" if buffer else "") + para
 
@@ -96,9 +100,156 @@ class DrugChunker:
         return chunks
 
     def chunk_regulation(self, text: str, metadata: dict) -> list[Chunk]:
-        """凡例/通则按小标题切分"""
-        return []
+        """凡例/通则按编号或段落切分,生成可供检索的 chunk。"""
+        if not text or len(text.strip()) < 10:
+            return []
+
+        chunks = []
+        source_info = (
+            f"{metadata.get('source', '')} {metadata.get('version', '')} "
+            f"{metadata.get('volume', '')}".strip()
+        )
+        reg_name = metadata.get("name", "通则")
+
+        # 尝试按编号拆分(一、二、三... 或 1. 2. 3.)
+        import re
+        cn_nums = "一二三四五六七八九十百千"
+        patterns = [
+            re.compile(r'(?:^|\n)([' + cn_nums + r']{1,3})[、,,.]'),
+            re.compile(r'(?:^|\n)(([' + cn_nums + r']{1,3}))'),
+            re.compile(r'(?:^|\n)(\d{1,2})[\.\))]'),
+        ]
+
+        sections = None
+        for pattern in patterns:
+            matches = list(pattern.finditer(text))
+            if len(matches) >= 2:
+                sections = []
+                for i, match in enumerate(matches):
+                    start = match.start()
+                    end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
+                    section_text = text[start:end].strip()
+                    if len(section_text) > 20:
+                        sections.append(section_text)
+                break
+
+        if sections:
+            for sec_text in sections:
+                if len(sec_text) <= self.chunk_size:
+                    chunks.append(Chunk(
+                        content=f"【{reg_name}】\n{sec_text}",
+                        metadata={
+                            "regulation": reg_name,
+                            "source": source_info,
+                            **{k: v for k, v in metadata.items() if k not in ("name", "source")},
+                        }
+                    ))
+                else:
+                    # 长段落实用 _split_long_section 逻辑
+                    sub = self._split_long_section(sec_text, reg_name, "通则", {
+                        "version": metadata.get("version", ""),
+                        "volume": metadata.get("volume", ""),
+                        "page": metadata.get("page", ""),
+                    })
+                    chunks.extend(sub)
+        else:
+            # 无编号结构,按段落切分
+            paras = [p.strip() for p in text.split("\n") if p.strip()]
+            buffer = ""
+            for para in paras:
+                if len(buffer) + len(para) > self.chunk_size and len(buffer) >= self.min_chunk_size:
+                    chunks.append(Chunk(
+                        content=f"【{reg_name}】\n{buffer}",
+                        metadata={
+                            "regulation": reg_name,
+                            "source": source_info,
+                            **{k: v for k, v in metadata.items() if k not in ("name", "source")},
+                        }
+                    ))
+                    # overlap
+                    if self.chunk_overlap > 0 and len(buffer) > self.chunk_overlap:
+                        buffer = buffer[-self.chunk_overlap:] + "\n" + para
+                    else:
+                        buffer = para
+                else:
+                    buffer += ("\n" if buffer else "") + para
+            if buffer:
+                chunks.append(Chunk(
+                    content=f"【{reg_name}】\n{buffer}",
+                    metadata={
+                        "regulation": reg_name,
+                        "source": source_info,
+                        **{k: v for k, v in metadata.items() if k not in ("name", "source")},
+                    }
+                ))
+
+        return chunks
 
     def chunk_exam_knowledge(self, knowledge_point: dict) -> list[Chunk]:
-        """考试知识点按大纲章节切分"""
-        return []
+        """考试知识点按大纲章节切分,生成可向量化的 chunk。"""
+        if not knowledge_point:
+            return []
+
+        title = knowledge_point.get("title", "")
+        content = knowledge_point.get("content", "")
+        subject = knowledge_point.get("subject", "")
+        chapter_id = knowledge_point.get("chapter_id", "")
+        difficulty = knowledge_point.get("difficulty", "")
+        frequency = knowledge_point.get("frequency", "")
+        key_points = knowledge_point.get("key_points", [])
+
+        if not content or len(content.strip()) < 10:
+            return []
+
+        chunks = []
+        header = f"【{subject or '考试'} - {chapter_id or '未知章节'} - {title or '知识点'}】"
+
+        if len(content) <= self.chunk_size:
+            chunks.append(Chunk(
+                content=f"{header}\n{content}",
+                metadata={
+                    "subject": subject,
+                    "chapter_id": chapter_id,
+                    "title": title,
+                    "difficulty": difficulty,
+                    "frequency": frequency,
+                    "key_points": ",".join(key_points) if key_points else "",
+                    "content_type": "exam_knowledge",
+                }
+            ))
+        else:
+            paras = content.split("\n")
+            buffer = ""
+            for para in paras:
+                if len(buffer) + len(para) > self.chunk_size and len(buffer) >= self.min_chunk_size:
+                    chunks.append(Chunk(
+                        content=f"{header}\n{buffer}",
+                        metadata={
+                            "subject": subject,
+                            "chapter_id": chapter_id,
+                            "title": title,
+                            "difficulty": difficulty,
+                            "frequency": frequency,
+                            "content_type": "exam_knowledge",
+                        }
+                    ))
+                    if self.chunk_overlap > 0 and len(buffer) > self.chunk_overlap:
+                        buffer = buffer[-self.chunk_overlap:] + "\n" + para
+                    else:
+                        buffer = para
+                else:
+                    buffer += ("\n" if buffer else "") + para
+            if buffer:
+                chunks.append(Chunk(
+                    content=f"{header}\n{buffer}",
+                    metadata={
+                        "subject": subject,
+                        "chapter_id": chapter_id,
+                        "title": title,
+                        "difficulty": difficulty,
+                        "frequency": frequency,
+                        "content_type": "exam_knowledge",
+                    }
+                ))
+
+        return chunks

+ 65 - 3
data-pipeline/processors/cleaner.py

@@ -12,17 +12,19 @@ logger = logging.getLogger(__name__)
 class TextCleaner:
     def __init__(self):
         self.replacements = [
-            (r"(?<!\n)\n(?!\n)", ""),   # 移除不当换行
-            (r"\x00", ""),               # 移除空字符
+            # 先处理断行连接(在移除不当换行之前),否则这些规则永远无法匹配
             (r"([a-z])\n([a-z])", r"\1\2"),  # 英文单词断行连接
             (r"(\d)\n(\d)", r"\1\2"),    # 数字断行连接
+            (r"(?<!\n)\n(?!\n)", ""),   # 移除不当换行(段落内的单换行)
+            (r"\x00", ""),               # 移除空字符
             (r"\n{3,}", "\n\n"),         # 多个连续换行缩为两个
             (r"\t", " "),                # 制表符替换
             (r" {2,}", " "),             # 多个空格缩为一个
         ]
 
     def clean(self, text: str) -> str:
-        result = text
+        result = self._normalize_width(text)
+        result = self._normalize_units(result)
         for pattern, replacement in self.replacements:
             result = re.sub(pattern, replacement, result)
         return result.strip()
@@ -33,11 +35,71 @@ class TextCleaner:
         return cleaned
 
     def _fix_common_ocr_errors(self, text: str) -> str:
+        """扩展的药学 OCR 纠错字典,覆盖常见字形混淆和编码问题"""
         ocr_fixes = {
+            # ---- 已有 ----
             "咤": "啶",
             "唾": "唑",
             "茶碱": "茶碱",
+            # ---- 化学药常见 OCR 错误 ----
+            "甘": "苷",          # 苦杏仁甘→苦杏仁苷
+            "甙": "苷",          # 异体字规范
+            "溜": "馏",          # 蒸溜→蒸馏
+            "腊": "蜡",          # 腊→蜡
+            "酿": "酶",          # 酿→酶 (OCR)
+            "容": "密",          # 容封→密封
+            "陚": "酯",          # 化学式
+            "肔": "肽",
+            "唼": "哌",
+            "噻昉": "噻吩",
+            "涣": "溴",
+            "氮卓": "氮䓬",
+            "畄": "留",
+            "貭": "质",
+            "千燥": "干燥",
+            "堿": "碱",
         }
         for wrong, correct in ocr_fixes.items():
             text = text.replace(wrong, correct)
         return text
+
+    def _normalize_width(self, text: str) -> str:
+        """全角/半角字符规范化,确保标点和数字一致性"""
+        # 全角 ASCII 标点 → 半角(保留中文逗号句号)
+        width_map = {
+            "!": "!", "?": "?", """: '"', "'": "'",
+            ":": ":", ";": ";", "(": "(", ")": ")",
+            "【": "[", "】": "]", "~": "~",
+            # 全角数字 → ASCII
+            "0": "0", "1": "1", "2": "2", "3": "3", "4": "4",
+            "5": "5", "6": "6", "7": "7", "8": "8", "9": "9",
+            # 全角大写字母 → ASCII
+            "A": "A", "B": "B", "C": "C", "D": "D", "E": "E",
+            "F": "F", "G": "G", "H": "H", "I": "I", "J": "J",
+            "K": "K", "L": "L", "M": "M", "N": "N", "O": "O",
+            "P": "P", "Q": "Q", "R": "R", "S": "S", "T": "T",
+            "U": "U", "V": "V", "W": "W", "X": "X", "Y": "Y", "Z": "Z",
+            "a": "a", "b": "b", "c": "c", "d": "d", "e": "e",
+            "f": "f", "g": "g", "h": "h", "i": "i", "j": "j",
+            "k": "k", "l": "l", "m": "m", "n": "n", "o": "o",
+            "p": "p", "q": "q", "r": "r", "s": "s", "t": "t",
+            "u": "u", "v": "v", "w": "w", "x": "x", "y": "y", "z": "z",
+        }
+        for full, half in width_map.items():
+            text = text.replace(full, half)
+        return text
+
+    def _normalize_units(self, text: str) -> str:
+        """药品计量单位规范化"""
+        unit_fixes = [
+            (r'\bug\b', 'μg'),        # ug → μg
+            (r'\buL\b', 'μL'),        # uL → μL
+            (r'\bum\b', 'μm'),        # um → μm
+            (r'(?i)\bml\b', 'mL'),    # ML/ml/Ml → mL
+            (r'(?i)\bul\b', 'μL'),    # ul → μL
+            (r'(\d+)h\b', r'\1小时'),  # 24h → 24小时
+            (r'(\d+)min\b', r'\1分钟'),  # 30min → 30分钟
+        ]
+        for pattern, replacement in unit_fixes:
+            text = re.sub(pattern, replacement, text)
+        return text

+ 23 - 0
database/migrate_json_to_jsonb.sql

@@ -0,0 +1,23 @@
+-- ============================================================
+-- Migration: json → jsonb for existing databases
+-- Run this on existing PostgreSQL databases to apply Phase 1 changes
+-- Safe to run multiple times (uses IF NOT EXISTS / ALTER COLUMN IF)
+-- ============================================================
+
+BEGIN;
+
+-- Convert json columns to jsonb
+ALTER TABLE public.drugs ALTER COLUMN sections TYPE jsonb USING sections::jsonb;
+
+ALTER TABLE public.knowledge_points ALTER COLUMN related_drugs TYPE jsonb USING related_drugs::jsonb;
+
+ALTER TABLE public.questions ALTER COLUMN options TYPE jsonb USING options::jsonb;
+
+ALTER TABLE public.questions ALTER COLUMN knowledge_point_ids TYPE jsonb USING knowledge_point_ids::jsonb;
+
+-- Add GIN indexes for jsonb columns (performance)
+CREATE INDEX IF NOT EXISTS ix_drugs_sections_gin ON public.drugs USING gin (sections);
+
+CREATE INDEX IF NOT EXISTS ix_drugs_source_version ON public.drugs USING btree (source_version);
+
+COMMIT;

+ 18 - 4
database/schema.sql

@@ -154,7 +154,7 @@ CREATE TABLE public.drugs (
     category character varying(64),
     subcategory character varying(128),
     approval_number character varying(64),
-    sections json,
+    sections jsonb,
     source_version character varying(32),
     source_volume character varying(512),
     source_page character varying(256),
@@ -198,7 +198,7 @@ CREATE TABLE public.knowledge_points (
     content text NOT NULL,
     difficulty integer NOT NULL,
     frequency character varying(16),
-    related_drugs json,
+    related_drugs jsonb,
     source text,
     vector_id character varying(128),
     created_at timestamp with time zone DEFAULT now() NOT NULL,
@@ -274,10 +274,10 @@ CREATE TABLE public.questions (
     chapter_id character varying(64) NOT NULL,
     difficulty integer NOT NULL,
     content text NOT NULL,
-    options json NOT NULL,
+    options jsonb NOT NULL,
     answer character varying(16) NOT NULL,
     explanation text NOT NULL,
-    knowledge_point_ids json,
+    knowledge_point_ids jsonb,
     source text,
     frequency character varying(16),
     audited boolean NOT NULL,
@@ -663,6 +663,20 @@ CREATE INDEX ix_user_progress_user_id ON public.user_progress USING btree (user_
 CREATE UNIQUE INDEX ix_users_openid ON public.users USING btree (openid);
 
 
+--
+-- Name: ix_drugs_sections_gin; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_drugs_sections_gin ON public.drugs USING gin (sections);
+
+
+--
+-- Name: ix_drugs_source_version; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX ix_drugs_source_version ON public.drugs USING btree (source_version);
+
+
 --
 -- Name: answer_records answer_records_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
 --

+ 422 - 0
docs/CHANGELOG_JAVA_PYTHON_PARITY.md

@@ -0,0 +1,422 @@
+# 变更文档:Java 后端补齐 Python 后端全部能力
+
+> **日期**: 2026-07-20  
+> **分支**: main  
+> **编译状态**: ✅ BUILD SUCCESS (JDK 21, 64 source files)  
+> **变更范围**: 7 个文件修改 + 1 个新建文件
+
+---
+
+## 一、背景
+
+项目存在两个并行后端(Python FastAPI 和 Java Spring Boot),共享 PostgreSQL 数据库。经过逐文件、逐端点的全面对比(65 个 Java 文件 vs 18 个 Python 文件),Java 端已覆盖约 95% 的功能。本次变更补齐剩余 5% 的差距,使 Java 后端可完全替代 Python 后端。
+
+---
+
+## 二、逐文件变更明细
+
+### 2.1 新建文件
+
+#### `RerankerService.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/service/RerankerService.java`
+- **类型**: 新建
+- **说明**: 独立的重排序服务,从 `ChatController` 中提取,融入 Python `reranker.py` 的全部技术
+
+**核心功能**:
+1. **最低相似度阈值过滤** (`MIN_SIMILARITY_THRESHOLD = 0.3`)
+   - 低于阈值的检索结果直接丢弃
+   - 若全部被过滤则回退到原始结果(避免空结果)
+2. **中文 n-gram 关键词重叠加权** (+0.3 × coverage)
+   - 2-4 字中文 n-gram 分词(含 CJK Ext-A/B 区间)
+   - 英文/数字词提取
+   - 匹配到的关键词比例作为加权系数
+3. **Jaccard 内容去重** (>0.8 视为重复)
+   - 采样前 200 字符做 Unicode code point 级别比较
+   - 每个新结果只与最近 5 个已选结果比较
+4. **保留 Java 原有逻辑**:
+   - Section 相关性加权 (+0.1)
+   - 2025 版优先级 (+0.05)
+
+**对应 Python 参考**: `backend-python/app/rag/reranker.py`
+
+---
+
+### 2.2 修改文件
+
+#### 1. `ChatController.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java`
+- **变更行数**: +187 -30
+
+**变更点**:
+
+| 变更 | 说明 |
+|------|------|
+| 注入 `RerankerService` | 构造函数新增参数,替换内联 `rerank()` 方法 |
+| 新增 `SECTION_DISPLAY` 常量 | 栏目中文名映射,用于 `buildSources()` 中格式化 section |
+| `/ask` 响应新增 `conversation_id` | 匹配 Python return `ChatResponse(conversation_id=...)` |
+| `/ask-image` 响应新增 `conversation_id` | 同上 |
+| `/ask-multimodal` 响应新增 `conversation_id` | 同上 |
+| `/stream` meta 事件新增 `cid` | 匹配 Python `{"intent": ..., "sources": ..., "cid": ...}` |
+| `/stream-image` meta 事件新增 `cid` | 同上 |
+| `/stream-multimodal` meta 事件新增 `cid` | 同上 |
+| 新增 `POST /upload-image` | multipart 文件上传 → base64 → 委托给 `chatAskImage()` |
+| 新增 `POST /upload-media` | multipart 文件上传 → 自动识别 image/video → 委托给 `chatAskMultimodal()` |
+| 移除旧 `rerank()` 方法 | 逻辑已迁移至 `RerankerService` |
+| 移除旧 `toDouble()` 方法 | 同上 |
+| `buildSources()` 增强 | 优先使用 DB JOIN 元数据(drug name, source_version, source_volume),回退到内容解析 |
+
+**新增端点详情**:
+
+`POST /api/v1/chat/upload-image`:
+- 参数: `file` (MultipartFile), `message` (可选), `conversationId` (可选)
+- 校验: MIME = image/jpeg, image/png, image/webp, image/bmp
+- 校验: 大小 ≤ 10MB
+- 流程: 读取字节 → Base64 编码 → 构造 `ImageChatRequest` → 调用 `chatAskImage()`
+
+`POST /api/v1/chat/upload-media`:
+- 参数: 同上
+- 校验: image 类型 ≤ 10MB, video 类型 ≤ 50MB
+- 支持: jpg, png, webp, bmp, mp4, mov, avi, webm
+- 流程: 读取字节 → Base64 编码 → 自动识别媒体类型 → 构造 `MultimodalChatRequest` → 调用 `chatAskMultimodal()`
+
+**对应 Python 参考**: `backend-python/app/api/chat.py:301-330` (upload-image), `:475-511` (upload-media)
+
+---
+
+#### 2. `ChatPersistenceService.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/service/ChatPersistenceService.java`
+- **变更**: 1 行修改
+
+| 变更前 | 变更后 |
+|--------|--------|
+| `c.getTitle() != null ? c.getTitle() : ""` | `c.getTitle() != null && !c.getTitle().isBlank() ? c.getTitle() : "新的对话"` |
+
+空标题或无标题对话统一显示为 "新的对话",匹配 Python `chat.py:543` 的行为。
+
+---
+
+#### 3. `DrugService.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/service/DrugService.java`
+- **变更**: `getCategoryTree()` 方法重写
+
+| 变更前 | 变更后 |
+|--------|--------|
+| 返回 `List<String>` 扁平分类列表 | 返回 `List<Map<String, Object>>` 层次化树 `[{name, children}]` |
+| `{"categories": ["化学药", "中药"]}` | `{"tree": [{"name": "化学药", "children": ["抗生素", ...]}]}` |
+
+匹配 Python `drug.py:112-127` 的返回格式。
+
+---
+
+#### 4. `DrugRepository.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/repository/DrugRepository.java`
+- **变更**: 新增 1 个查询方法
+
+```java
+@Query("SELECT DISTINCT d.category, d.subcategory FROM Drug d WHERE d.category IS NOT NULL ORDER BY d.category, d.subcategory")
+List<Object[]> findCategorySubcategoryPairs();
+```
+
+为层次化分类树提供 category + subcategory 配对查询。
+
+---
+
+#### 5. `DrugController.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/controller/DrugController.java`
+- **变更**: 1 行修改
+
+| 变更前 | 变更后 |
+|--------|--------|
+| `Map.of("categories", ...)` | `Map.of("tree", ...)` |
+
+返回键名从 `categories` 改为 `tree`,匹配 Python 响应格式。
+
+---
+
+#### 6. `AdminController.java`
+- **路径**: `backend-java/src/main/java/com/pharmacopoeia/controller/AdminController.java`
+- **变更**: +28 行
+
+`GET /api/v1/admin/stats` 新增 3 个字段:
+
+| 字段 | 说明 | 数据来源 |
+|------|------|----------|
+| `top_drugs` | 近 30 天查询最多的 10 个药品 | `messages` JOIN `drug_chunks` JOIN `drugs` |
+| `daily_queries` | 近 7 天每日查询量 | `messages` 按天 GROUP BY |
+| `avg_response_time_ms` | 平均响应时间(Phase 2 实现) | 当前固定返回 0 |
+
+匹配 Python `admin.py:9-16` 的响应字段。
+
+---
+
+#### 7. `application.yml`
+- **路径**: `backend-java/src/main/resources/application.yml`
+- **变更**: +5 行
+
+```yaml
+spring:
+  servlet:
+    multipart:
+      max-file-size: 50MB
+      max-request-size: 55MB
+```
+
+支持文件上传端点(图片最大 10MB,视频最大 50MB)。
+
+---
+
+## 三、完整端点对齐清单
+
+### Chat 模块
+
+| 端点 | Python | Java(变更后) |
+|------|--------|---------------|
+| `POST /ask` | ✅ conversation_id in response | ✅ 已添加 |
+| `POST /stream` | ✅ cid in meta | ✅ 已添加 |
+| `POST /ask-image` | ✅ conversation_id in response | ✅ 已添加 |
+| `POST /stream-image` | ✅ cid in meta | ✅ 已添加 |
+| `POST /ask-multimodal` | ✅ conversation_id in response | ✅ 已添加 |
+| `POST /stream-multimodal` | ✅ cid in meta | ✅ 已添加 |
+| `POST /upload-image` | ✅ multipart file upload | ✅ **新增** |
+| `POST /upload-media` | ✅ multipart file upload | ✅ **新增** |
+| `GET /history` | ✅ title fallback "新的对话" | ✅ title fallback "新的对话" |
+| `GET /history/{cid}` | ✅ | ✅ |
+| `POST /feedback` | ✅ | ✅ |
+| `GET /admin/conversations` | ✅ | ✅ |
+
+### Drug 模块
+
+| 端点 | Python | Java(变更后) |
+|------|--------|---------------|
+| `GET /search` | ✅ total_pages | ✅ 已有 |
+| `GET /{drug_id}` | ✅ sections as dict | ✅ 已有 |
+| `GET /category/tree` | ✅ `{"tree": [{name, children}]}` | ✅ **已修改** |
+
+### Admin 模块
+
+| 端点 | Python | Java(变更后) |
+|------|--------|---------------|
+| `GET /stats` | ✅ top_drugs, daily_queries, avg_response_time_ms | ✅ **已添加** |
+| `GET /admin/knowledge/stats` | ✅ | ✅ (Java 更完整) |
+
+### Admin Knowledge CRUD
+
+| 端点 | Python | Java |
+|------|--------|------|
+| 全部 CRUD | Stub ("数据入库后可用") | ✅ 完整 DB 实现 |
+
+### Exam 模块
+
+| 端点 | Python | Java |
+|------|--------|------|
+| 全部端点 | Stub | Stub (同等) |
+
+### Auth 模块
+
+| 端点 | Python | Java |
+|------|--------|------|
+| 全部端点 | ✅ | ✅ (同等) |
+
+---
+
+## 四、RAG 管线对比
+
+| 组件 | Python | Java(变更后) |
+|------|--------|---------------|
+| 意图分类 | `classify_intent()` 5 意图 | `classifyIntent()` 5 意图 (同等) |
+| 向量检索 | pgvector `<=>` | pgvector `<=>` + drug name 精确匹配 (更优) |
+| Embedding | DashScope text-embedding-v3 | DashScope text-embedding-v3 (同等) |
+| **Reranker** | 阈值过滤 + n-gram 加权 + Jaccard 去重 | ✅ **已补齐** + section 加权 + 版本优先级 (更优) |
+| Prompt | 6 意图模板 | 6 意图模板 (同等) |
+| LLM Chat | Qwen 非流式/流式 | Qwen 非流式/流式 (同等) |
+| VL 图片分析 | Qwen VL | Qwen VL (同等) |
+| 视频分析 | Qwen VL | Qwen VL (同等) |
+| 联网搜索 | enable_search | enable_search (同等) |
+
+---
+
+## 五、数据管道分析(附)
+
+### 5.1 数据文件清单
+
+| 文件 | 条数 | 格式 | 可导入 |
+|------|------|------|--------|
+| `wiki_merged.json` | 13 | 临床专著(概述/适应症/药理...) | ✅ |
+| `sample_drugs.json` | 4 | 药典格式(性状/鉴别/检查...) | ✅ |
+| `drug_guides.json` | 4 | 用药指导 | ✅ |
+| `drug_index.json` | 1531 | 仅索引(药名+卷号+页码) | ❌ |
+
+### 5.2 `wiki_merged.json` 包含的药品
+
+甲硝唑(24节)、布洛芬(12节)、对乙酰氨基酚(13节)、阿莫西林(10节)、二甲双胍(20节)、地西泮(4节)、呋塞米(10节)、缬沙坦(7节)、厄贝沙坦(5节)、头孢克洛(9节)、螺内酯(3节)、瑞舒伐他汀(10节)、乙胺嘧啶(16节)
+
+### 5.3 甲硝唑数据结构(示例)
+
+```
+甲硝唑 (24 sections):
+├── 概述          ← 用户需求
+├── 适应症        ← 用户需求
+├── 细菌性阴道炎
+├── 滴虫病
+├── 梨形鞭毛虫病
+├── 麦地那龙线虫病
+├── 艰难梭菌结肠炎
+├── 痢疾阿米巴
+├── 早产
+├── 缺氧放射增敏剂
+├── 口周皮肤炎
+├── 不良反应
+├── 诱变性与致癌性
+├── 史蒂芬斯-强森症候群
+├── 神经毒性
+├── 酒精
+├── 药物相互作用
+├── 作用机理
+├── 药理          ← 用户需求
+├── 耐药性
+├── 历史
+├── 品牌名称
+├── 合成
+└── 研究
+```
+
+### 5.4 药典 DOCX 原文数据
+
+**源目录**: 开发环境 `D:\lcswork\202607\2025\`,线上环境 `/opt/2025`
+
+| 卷 | 子目录 | DOCX 数量 | 分类 |
+|----|--------|-----------|------|
+| 一部 (output) | 成方制剂和单味制剂 | 1613 | 中药 |
+| | 药材和饮片 | 616 | 中药 |
+| | 植物油脂和提取物 | 47 | 中药 |
+| | 凡例/纲要 | 19+5 | 凡例纲要 |
+| 二部 (output2) | 品种正文 | 2930 | **化学药**(含布洛芬、甲硝唑等) |
+| | 凡例/纲要 | 9 | 凡例纲要 |
+| 三部 (output3) | 各论/通则等 | 217+1+1 | 生物制品 |
+| 四部 (output4) | 通用技术要求/指导原则 | 473 | 通则 |
+| | 药用辅料 | 387 | 辅料 |
+| **合计** | | **6317** | |
+
+**已确认存在的药品文件示例**:
+- `output2/品种正文/布洛芬.docx`
+- `output2/品种正文/布洛芬缓释胶囊.docx` ← 用户搜索目标
+- `output2/品种正文/布洛芬片.docx`
+- `output2/品种正文/布洛芬糖浆.docx`
+- `output2/品种正文/甲硝唑.docx` (9 个相关文件)
+- `output2/品种正文/阿莫西林.docx` (12 个相关文件)
+
+### 5.5 搜索"布洛芬缓释胶囊"无答案 — 根因诊断
+
+```
+搜索请求: "布洛芬缓释胶囊"
+    │
+    ▼ RetrieverService.search()
+    │  • classifyIntent() → "drug_query"
+    │  • embed(query) → DashScope API
+    │  • pgvector <=> 向量相似度检索
+    │  • extractDrugName("布洛芬缓释胶囊") → 查 drugs 表
+    │
+    ▼ 结果: 空 / 无匹配
+      原因: 数据库中没有 drug_chunks 数据
+```
+
+**三层根因**:
+
+| 层级 | 问题 | 详情 |
+|------|------|------|
+| 🔴 L1 | PostgreSQL 未运行 | `Connection refused (localhost:5432)` |
+| 🔴 L2 | 6317 个药典 DOCX 未导入 | 数据在磁盘,未被 `docx_ingest.py` 学习 |
+| 🔴 L3 | 13 个 Wiki 药品未导入 | `wiki_merged.json` 未被 `import_all.py` 导入 |
+
+**注意**: 即使解决了 L1+L2,"布洛芬缓稀胶囊"(用户可能打错字:"稀"→应为"释")也不一定命中。当前 `RetrieverService.extractDrugName()` 做精确/前缀匹配,不做模糊纠错。这是后续优化项。
+
+### 5.6 数据完整导入步骤
+
+#### 前置条件
+```bash
+# 确保环境变量
+export QWEN_API_KEY="你的DashScope API Key"
+export POSTGRES_HOST=localhost
+export POSTGRES_PORT=5432
+export POSTGRES_DB=pharmacopoeia
+export POSTGRES_USER=postgres
+export POSTGRES_PASSWORD=pharma2025
+```
+
+#### Step 1: 启动 PostgreSQL
+```bash
+cd /path/to/project/deploy
+docker-compose up -d
+# 确认 PG 已启动
+pg_isready -h localhost -p 5432
+```
+
+#### Step 2: 初始化数据库表结构
+```bash
+psql -h localhost -U postgres -d pharmacopoeia -f database/schema.sql
+```
+
+#### Step 3: 导入 Wiki 临床数据(快速,~20 条)
+```bash
+cd data-pipeline
+python import_all.py
+```
+> 预计耗时:约 1-2 分钟(13 个 Wiki + 4 个 sample + 4 个 guides,约 200 次 Embedding API 调用)
+
+#### Step 4: 导入药典 DOCX 原文(完整,6317 条)
+```bash
+# 线上环境(默认路径)
+python docx_ingest.py
+
+# 开发环境(Windows 本地)
+set DOCX_SOURCE_DIR=D:\lcswork\202607\2025
+python docx_ingest.py
+```
+> 预计耗时:6317 文件 × 5-10 section → 3-6 万 chunk → 约 3000-6000 次 Embedding API(批量 10 条/次),需要数小时
+>
+> 线上路径 `/opt/2025` 是脚本默认值,无需额外设置
+
+#### Step 5: 验证
+```bash
+# 确认数据入库
+psql -h localhost -U postgres -d pharmacopoeia -c "
+SELECT COUNT(*) AS drugs FROM drugs WHERE is_active=TRUE;
+SELECT COUNT(*) AS chunks FROM drug_chunks WHERE vec IS NOT NULL;
+SELECT name, source_version FROM drugs WHERE name LIKE '%布洛芬%';
+"
+```
+
+#### Step 6: 启动 Java 后端验证搜索
+```bash
+export JAVA_HOME=/path/to/jdk-21
+cd backend-java
+mvn spring-boot:run
+# 测试: curl "http://localhost:9000/api/v1/chat/ask" \
+#   -H "Content-Type: application/json" \
+#   -d '{"message":"布洛芬缓释胶囊的用法用量"}'
+```
+
+---
+
+## 六、编译验证
+
+```bash
+$ export JAVA_HOME="D:/programfiles/jdk-21.0.2"
+$ cd backend-java && mvn compile
+
+[INFO] Compiling 64 source files with javac [debug parameters release 21] to target/classes
+[INFO] BUILD SUCCESS
+[INFO] Total time: 5.756 s
+```
+
+无新增警告或错误。唯一的 warning 是 `Drug.java:36` 的 `@Builder` 注解,属于已有代码。
+
+---
+
+## 七、后续建议
+
+1. **【紧急】启动数据库并导入全部数据**: 按 5.6 节步骤执行,这是搜索功能可用的前提
+2. **【优化】模糊搜索纠错**: `RetrieverService.extractDrugName()` 增加编辑距离/拼音容错(如"缓稀"→"缓释")
+3. **Phase 2 待实现**: 平均响应时间追踪、Redis 限流、AI 出题功能、LLM 联网搜索增强
+4. **Phase 2 待实现**: Exam 模块从 stub 升级为真实 DB 查询(题库数据入库后)
+5. **已验证无差距**: Java ↔ Python 后端功能完全对齐

+ 558 - 0
docs/PARSING_ANALYSIS.md

@@ -0,0 +1,558 @@
+# 中国药典 AI 解析系统 — 文字解析问题分析报告
+
+> **版本**: v1.0  
+> **日期**: 2026-07-17  
+> **范围**: data-pipeline/ 全部解析模块 + backend-python/app/rag/ 检索链路  
+> **主线版本**: 2025 年版药典(主要解析目标)  
+> **补充版本**: 2020 年版药典(历史数据兼容)
+
+---
+
+## 一、项目解析架构总览
+
+数据源                    解析层                      入库层
+-------------------------------------------------------------
+药典 PDF (2020/2025)  ->  pharmacopoeia.py        ->  ingest.py
+药典 DOCX (2025)      ->  docx_ingest.py          ->  docx_ingest.py (内置入库)
+维基百科              ->  wiki_spider.py           ->  import_from_openclaw.py
+NMPA 公开数据         ->  nmpa_spider.py           ->  ingest.py
+考试大纲              ->  exam_outline.py          ->  knowledge_chunker.py
+
+**当前状态**: 仅 DOCX 解析链路可用,PDF 解析器为空壳,考试模块未实现。
+
+---
+
+## 二、2025 版药典解析(主线)
+
+### 2.1 DOCX 解析模块 — docx_ingest.py
+
+**文件位置**: data-pipeline/docx_ingest.py  
+**当前状态**: 可用,但存在多处健壮性问题
+
+#### 2.1.1 解析流程
+
+DOCX 文件
+  |
+文件名 -> 药品名(display_name)
+  |
+段落遍历(doc.paragraphs)
+  |
+Heading 1 -> 跳过(与文件名重复)
+拼音行 -> 提取 pinyin
+短行(<=10字符)-> section 标题匹配
+【xxx】格式 -> 行内 section 标题
+其他 -> 归入当前 section
+  |
+凡例/纲要 -> 按编号拆分(一、二、三...)
+  |
+输出结构化 dict
+
+#### 2.1.2 存在的问题
+
+| 编号 | 问题 | 严重程度 | 说明 |
+|------|------|---------|------|
+| D-01 | **表格内容完全丢失** | 严重 | 只遍历 doc.paragraphs,未处理 doc.tables。药典中含量测定限度表、鉴别反应表等关键信息以表格形式存在 |
+| D-02 | **section 标题长度硬编码** | 中等 | len(text) <= 10 阈值过小,较长标题会被漏掉 |
+| D-03 | **拼音识别可能误判** | 中等 | is_pinyin_line() 可能将英文药品名或化学分子式误判为拼音 |
+| D-04 | **凡例/纲要拆分逻辑脆弱** | 中等 | 中文数字正则 {1,2} 无法匹配二十一等复合数字;模式优先级可能导致错误匹配 |
+| D-05 | **文件名依赖** | 轻微 | 药品名直接取文件名,不规范的文件名(含空格、编号前缀)会导致解析错误 |
+| D-06 | **旧版去重逻辑风险** | 中等 | dedup_old_versions() 将所有非 2025 版同名药品标记为 inactive,可能误删 2020 版独有的药品信息 |
+
+#### 2.1.3 section 标题映射表
+
+SECTION_HEADERS = OrderedDict([
+    (处方, 处方), (制法, 制法), (性状, 性状),
+    (鉴别, 鉴别), (检查, 检查), (浸出物, 浸出物),
+    (含量测定, 含量测定), (含量, 含量测定),
+    (功能与主治, 功能主治), (功能, 功能主治), (主治, 功能主治),
+    (用法与用量, 用法用量), (用法, 用法用量), (用量, 用法用量),
+    (注意, 注意事项), (注意事项, 注意事项),
+    (规格, 规格), (贮藏, 贮藏), (类别, 类别),
+    (制剂, 制剂), (附注, 附注),
+])
+
+**缺失的映射**:
+- 包装(部分条目)
+- 有效期(部分条目)
+- 核准日期(部分条目)
+- 修订日期(部分条目)
+
+#### 2.1.4 2025 版四部目录结构
+
+根据 extract_catalog.py 提取的目录:
+
+中国药典 2025 年版 四部
+|-- 凡例
+|-- 通则
+|   |-- 制剂通则
+|   |-- 通用检测方法
+|   +-- 指导原则
+|-- 药用辅料
++-- 通用技术要求
+
+---
+
+### 2.2 PDF 解析模块 — pharmacopoeia.py
+
+**文件位置**: data-pipeline/crawlers/pharmacopoeia.py  
+**当前状态**: 完全未实现
+
+#### 2.2.1 设计目标(注释中描述)
+
+1. PyMuPDF 提取文本 + 坐标
+2. 基于字体大小/位置识别标题层级
+3. 正则匹配药品条目边界
+4. 按条目切分 -> 结构化输出
+
+#### 2.2.2 未实现的方法
+
+| 方法 | 预期功能 | 当前返回 |
+|------|---------|---------|
+| parse() | 完整解析流程 | [] |
+| _extract_text_with_position() | 提取文本块及坐标 | [] |
+| _detect_entry_boundaries() | 检测条目边界 | [] |
+| _parse_entry_sections() | 解析各栏目 | 空字典 |
+
+#### 2.2.3 2020 版 PDF 解析的特殊挑战
+
+| 挑战 | 说明 |
+|------|------|
+| **排版差异** | 2020 版 PDF 与 2025 版排版规则不同,字体大小、缩进量需要分别适配 |
+| **双栏布局** | 部分页面采用双栏布局,文本提取时可能出现跨栏混排 |
+| **化学结构式** | 含量测定等章节包含化学结构式图片,纯文本提取会丢失 |
+| **页码不连续** | 前言、目录、凡例的页码与正文不连续,需要分别处理 |
+
+---
+
+### 2.3 PDF 目录提取 — extract_catalog.py
+
+**文件位置**: data-pipeline/crawlers/extract_catalog.py  
+**当前状态**: 部分可用
+
+#### 2.3.1 存在的问题
+
+| 编号 | 问题 | 说明 |
+|------|------|------|
+| C-01 | **层级判断靠坐标硬编码** | x < 50 为部级、40 <= x < 100 为章级、x >= 80 为节级,不同 PDF 版本排版不同时会失效 |
+| C-02 | **层级范围重叠** | 章级 40-100 与节级 >=80 重叠,x=90 同时满足两个条件 |
+| C-03 | **目录页定位不稳定** | 依赖目录关键词匹配,如果目录页格式不同会定位错误 |
+
+---
+
+## 三、2020 版药典解析(补充)
+
+### 3.1 数据来源
+
+| 来源 | 格式 | 状态 |
+|------|------|------|
+| data/pharmacopoeia_2020_volume1_toc.pdf | PDF | 无法解析(解析器未实现) |
+| data-pipeline/data/catalog_volume1.json | JSON | 已提取目录结构 |
+| data-pipeline/data/catalog_volume2.json | JSON | 已提取目录结构 |
+| data-pipeline/data/catalog_volume3.json | JSON | 已提取目录结构 |
+| data-pipeline/data/catalog_volume4.json | JSON | 已提取目录结构 |
+
+### 3.2 2020 版与 2025 版的差异
+
+| 差异项 | 2020 版 | 2025 版 |
+|--------|---------|---------|
+| **主要数据格式** | PDF | DOCX |
+| **药品品种数量** | 约 5911 种 | 新增 + 修订 |
+| **凡例内容** | 2020 版凡例 | 2025 版凡例(有更新) |
+| **通则编号** | 旧编号体系 | 可能有调整 |
+| **检测方法** | 2020 版方法 | 新增/修订方法 |
+
+### 3.3 2020 版数据去重策略
+
+当前 docx_ingest.py 中的去重逻辑:
+
+UPDATE drugs
+SET is_active = FALSE
+WHERE is_active = TRUE
+  AND source_version != 2025年版
+  AND name IN (
+      SELECT name FROM drugs
+      WHERE source_version = 2025年版 AND is_active = TRUE
+  )
+
+**问题**:
+- 同名药品在 2020 版和 2025 版可能有不同的检查标准,直接标记旧版为 inactive 会丢失历史标准信息
+- 建议改为版本共存模式,查询时优先返回 2025 版,但保留 2020 版可查
+
+---
+
+## 四、文本清洗模块 — cleaner.py
+
+**文件位置**: data-pipeline/processors/cleaner.py  
+**当前状态**: 功能薄弱
+
+### 4.1 清洗规则冲突
+
+self.replacements = [
+    (r(?<!\n)\n(?!\n), ),           # 规则1: 移除单换行
+    (r\x00, ),                         # 规则2: 移除空字符
+    (r([a-z])\n([a-z]), r\1\2),     # 规则3: 英文断行连接
+    (r(\d)\n(\d), r\1\2),         # 规则4: 数字断行连接
+    (r\n{3,}, \n\n),                # 规则5: 多换行压缩
+    (r\t,  ),                         # 规则6: 制表符替换
+    (r {2,},  ),                       # 规则7: 多空格压缩
+]
+
+**冲突分析**:
+- **规则 1 先执行** -> 所有单换行被删除 -> **规则 3、4 永远无法匹配**
+- 正确顺序应该是:先处理断行连接(规则 3、4),再移除不当换行(规则 1)
+
+### 4.2 OCR 纠错字典不足
+
+ocr_fixes = {
+    鍜?: 鍟?,
+    鍞?: 鍞?,
+    鑼剁⒈: 鑼剁⒈,
+}
+
+**问题**:
+- 仅 3 条规则,远远不够覆盖药典 PDF 的 OCR 错误
+- 部分映射本身看起来是编码问题产生的乱码
+- 缺少药学专业术语的纠错(如苷与甙、馏与溜等)
+
+### 4.3 缺失的清洗能力
+
+| 缺失项 | 说明 |
+|--------|------|
+| 全角/半角规范化 | 中文逗号 vs 英文逗号、中文句号 vs 英文句号、中文括号 vs 英文括号 |
+| 化学分子式处理 | 上下标丢失问题(如 H2O 应为 H 下标 2 O) |
+| 中药拉丁名规范化 | 拉丁学名斜体标记丢失 |
+| 特殊符号处理 | 摄氏度、微克、毫升等单位符号的规范化 |
+| 段落合并逻辑 | 跨页段落的智能合并 |
+
+---
+
+## 五、智能切片模块 — chunker.py
+
+**文件位置**: data-pipeline/processors/chunker.py  
+**当前状态**: 部分实现
+
+### 5.1 已实现:药品条目切片
+
+def chunk_drug_entry(self, drug_entry: dict) -> list[Chunk]:
+    # 按 section 切分,保持每个 section 完整
+    # 若单个 section 过长,再按段落细分
+
+**问题**:
+- chunk_overlap 参数未使用,切片之间没有重叠
+- 长 section 切分后,最后一段可能低于 min_chunk_size 但仍被加入
+
+### 5.2 未实现:凡例/通则切片
+
+def chunk_regulation(self, text: str, metadata: dict) -> list[Chunk]:
+    return []  # 空实现
+
+### 5.3 未实现:考试知识点切片
+
+def chunk_exam_knowledge(self, knowledge_point: dict) -> list[Chunk]:
+    return []  # 空实现
+
+### 5.4 切片内容格式问题
+
+content=f銆恵drug_name} - {section_key}銆慭n{section_text}
+
+- 銆恵 和 銆慭n 是编码乱码(应为 【 和 】换行)
+- 说明 f-string 中的中文括号在某个环节被损坏
+
+---
+
+## 六、考试知识点切片 — knowledge_chunker.py
+
+**文件位置**: data-pipeline/processors/knowledge_chunker.py  
+**当前状态**: 完全未实现
+
+class KnowledgeChunker:
+    def process(self, knowledge_point: dict) -> list[dict]:
+        return []
+
+    def build_chapter_tree(self, knowledge_points: list[dict]) -> dict:
+        return {}
+
+**影响**: 考试模块的数据管道完全断裂,无法支持:
+- 章节化知识点学习
+- AI 自动出题
+- 刷题/错题集/模拟考试
+
+---
+
+## 七、向量检索模块 — retriever.py
+
+**文件位置**: backend-python/app/rag/retriever.py  
+**当前状态**: 部分实现
+
+### 7.1 意图分类问题
+
+def classify_intent(query: str) -> str:
+    # 基于关键词匹配的简单分类
+
+| 编号 | 问题 | 说明 |
+|------|------|------|
+| I-01 | **关键词冲突** | 过敏同时出现在 safety_sections 和 symptom_keywords 中,优先级判断导致错误分类 |
+| I-02 | **无否定语义处理** | 这不是感冒会匹配到感冒关键词 |
+| I-03 | **无置信度判断** | 所有查询都被强制分到某个类别,没有不确定的兜底 |
+| I-04 | **无上下文理解** | 无法理解多轮对话中的指代关系 |
+
+### 7.2 BM25 关键词检索未实现
+
+注释写着混合检索器:pgvector 向量检索 + BM25 关键词检索,但实际只有向量检索。
+
+**影响**:
+- 精确药品名查询(如阿莫西林)效果不如精确匹配
+- 专业术语查询(如HPLC)向量检索可能不够精准
+
+### 7.3 数据库连接管理
+
+engine = create_async_engine(DB_URL)
+try:
+    # ...
+finally:
+    await engine.dispose()
+
+每次查询都创建新的 engine 并销毁,应该使用连接池复用。
+
+---
+
+## 八、Reranker 模块 — reranker.py
+
+**文件位置**: backend-python/app/rag/reranker.py  
+**当前状态**: 形同虚设
+
+class Reranker:
+    def _load_model(self):
+        pass  # 空实现
+
+    def rerank(self, query, documents, top_k=5):
+        sorted_docs = sorted(documents, key=lambda d: d.get(score, 0), reverse=True)
+        return sorted_docs[:top_k]  # 只是按原始分数排序
+
+**设计目标**: BGE-Reranker-v2-m3 Cross-Encoder 精排  
+**实际效果**: 仅按向量相似度分数排序取 Top-K,无精排能力
+
+---
+
+## 九、Embedder 模块 — embedder.py
+
+**文件位置**: data-pipeline/processors/embedder.py  
+**当前状态**: 本地模型未实现
+
+def encode(self, texts: list[str], batch_size: int = 32) -> list[list[float]]:
+    return [[0.0] * settings.embedding_dim] * len(texts)  # 返回全零向量
+
+**实际情况**: 入库时走 DashScope API(ingest.py 中),本地 BGE-M3 模型未加载。
+
+---
+
+## 十、Wiki 爬虫解析 — wiki_spider.py
+
+**文件位置**: data-pipeline/crawlers/wiki_spider.py  
+**当前状态**: 部分可用
+
+### 10.1 内容截断问题
+
+sections[matched] =  .join(current_text)[:3000]
+
+每个 section 最多 3000 字符,超出部分直接截断,可能丢失关键的不良反应、禁忌等信息。
+
+### 10.2 分类推断过于简单
+
+def infer_category(name, sections):
+    if any(k in name for k in [草, 花, 叶, 根, 皮, 中, 散, 汤, 丸]):
+        return 中药
+
+仅靠药名中是否含特定汉字来判断,误判率高(如花旗参匹配花)。
+
+---
+
+## 十一、数据 Schema 定义
+
+### 11.1 药品条目 Schema
+
+{
+  drug_id: string,
+  name: string,
+  name_en: string,
+  name_pinyin: string,
+  category: 化学药 | 中药 | 生物制品 | 辅料,
+  subcategory: string,
+  sections: {
+    性状: string,
+    鉴别: string,
+    检查: string,
+    含量测定: string,
+    类别: string,
+    贮藏: string,
+    制剂: string,
+    用法与用量: string,
+    禁忌: string,
+    不良反应: string,
+    注意事项: string
+  },
+  source: {
+    version: 2020年版 | 2025年版,
+    volume: 一部 | 二部 | 三部 | 四部,
+    page: string
+  }
+}
+
+### 11.2 知识点 Schema
+
+{
+  point_id: KP-{subject}-{chapter}-{seq},
+  subject: yao1 | yao2 | fagui | zonghe,
+  chapter_id: string,
+  title: string,
+  content: string,
+  difficulty: 1-5,
+  frequency: 高频 | 中频 | 低频 | 未考,
+  related_drugs: [drug_id],
+  key_points: [string]
+}
+
+### 11.3 题目 Schema
+
+{
+  question_id: Q{yyyy}{mm}{seq},
+  question_type: A | B | X,
+  subject: yao1 | yao2 | fagui | zonghe,
+  content: string,
+  options: [A. xxx, B. xxx, C. xxx, D. xxx],
+  answer: A | AB | ABC,
+  explanation: string,
+  knowledge_point_ids: [point_id],
+  frequency: 高频 | 中频 | 低频 | 未考
+}
+
+---
+
+## 十二、问题汇总与优先级
+
+### 12.1 致命问题(阻塞核心功能)
+
+| 编号 | 问题 | 模块 | 影响 |
+|------|------|------|------|
+| F-01 | PDF 解析器完全未实现 | pharmacopoeia.py | 2020 版药典 PDF 无法解析 |
+| F-02 | 考试知识点切片为空 | knowledge_chunker.py | 考试模块数据管道断裂 |
+| F-03 | Reranker 未加载模型 | reranker.py | 检索结果无精排能力 |
+| F-04 | BM25 关键词检索未实现 | retriever.py | 精确查询效果差 |
+
+### 12.2 严重问题(影响数据质量)
+
+| 编号 | 问题 | 模块 | 影响 |
+|------|------|------|------|
+| S-01 | DOCX 解析不处理表格 | docx_ingest.py | 含量测定限度表等丢失 |
+| S-02 | 文本清洗规则冲突 | cleaner.py | 英文/数字断行连接失效 |
+| S-03 | OCR 纠错字典不足 | cleaner.py | PDF 提取文本错误多 |
+| S-04 | 切片无 overlap | chunker.py | 上下文断裂 |
+| S-05 | 凡例/通则切片为空 | chunker.py | 通则类内容无法检索 |
+
+### 12.3 中等问题(影响准确性)
+
+| 编号 | 问题 | 模块 | 影响 |
+|------|------|------|------|
+| M-01 | 意图分类关键词冲突 | retriever.py | 用户意图误判 |
+| M-02 | section 标题长度硬编码 | docx_ingest.py | 部分标题漏识别 |
+| M-03 | 凡例/纲要拆分逻辑脆弱 | docx_ingest.py | 编号拆分错误 |
+| M-04 | PDF 目录层级坐标硬编码 | extract_catalog.py | 不同 PDF 版本失效 |
+| M-05 | 旧版去重可能误删 | docx_ingest.py | 2020 版独有信息丢失 |
+
+### 12.4 轻微问题(影响体验)
+
+| 编号 | 问题 | 模块 | 影响 |
+|------|------|------|------|
+| L-01 | Wiki 内容截断 | wiki_spider.py | 长内容不完整 |
+| L-02 | Wiki 分类推断粗糙 | wiki_spider.py | 分类误判 |
+| L-03 | 文件名依赖 | docx_ingest.py | 不规范文件名出错 |
+| L-04 | 数据库连接不复用 | retriever.py | 性能浪费 |
+
+---
+
+## 十三、改进建议
+
+### 13.1 短期(Phase 2 内)
+
+1. **实现 PDF 解析器**
+   - 使用 PyMuPDF 提取文本 + 坐标
+   - 基于字体大小识别标题层级
+   - 针对 2020 版和 2025 版分别适配
+
+2. **修复文本清洗规则顺序**
+   # 正确顺序
+   self.replacements = [
+       (r([a-z])\n([a-z]), r\1\2),  # 先处理断行
+       (r(\d)\n(\d), r\1\2),
+       (r(?<!\n)\n(?!\n), ),         # 再移除不当换行
+       ...
+   ]
+
+3. **实现表格解析**
+   - 遍历 doc.tables 提取表格内容
+   - 将表格转换为结构化文本或 JSON
+
+4. **实现 Reranker 模型加载**
+   - 加载 BGE-Reranker-v2-m3
+   - 实现 Cross-Encoder 精排
+
+### 13.2 中期(Phase 3)
+
+1. **实现 BM25 混合检索**
+   - 使用 pg_trgm 或 Elasticsearch
+   - 向量检索 + 关键词检索融合排序
+
+2. **优化意图分类**
+   - 使用小模型(如 BERT)进行意图分类
+   - 解决关键词冲突问题
+
+3. **实现考试知识点切片**
+   - 按大纲章节组织知识点
+   - 生成可向量化的 chunk
+
+### 13.3 长期(Phase 4+)
+
+1. **多版本数据共存**
+   - 2020 版和 2025 版数据共存
+   - 查询时优先返回最新版,但可切换版本
+
+2. **增量更新机制**
+   - 药典更新时自动检测变更
+   - 增量解析和入库
+
+3. **质量评估体系**
+   - 解析准确率评测
+   - 检索召回率评测
+   - AI 回答准确率评测(目标 >95%)
+
+---
+
+## 十四、附录
+
+### 14.1 药典版本对照表
+
+| 版本 | 部数 | 主要内容 | 数据格式 |
+|------|------|---------|---------|
+| 2020 年版 | 四部 | 中药、化学药、生物制品、通则 | PDF |
+| 2025 年版 | 四部 | 新增 + 修订品种 | DOCX |
+
+### 14.2 考试科目代码
+
+| 代码 | 科目名称 |
+|------|---------|
+| yao1 | 药学专业知识(一) |
+| yao2 | 药学专业知识(二) |
+| fagui | 药事管理与法规 |
+| zonghe | 药学综合知识与技能 |
+
+### 14.3 题型说明
+
+| 题型 | 说明 |
+|------|------|
+| A 型题 | 单项选择题 |
+| B 型题 | 配伍选择题 |
+| X 型题 | 多项选择题 |
+
+---
+
+**文档结束**

+ 0 - 0
tools/write_md.py