Kaynağa Gözat

数据学习更新

liuchengsen 1 ay önce
ebeveyn
işleme
6eaecdf250

+ 3 - 0
backend-java/src/main/java/com/pharmacopoeia/service/PromptService.java

@@ -18,6 +18,8 @@ public class PromptService {
             —— 不得编造药典版本号、页码
             —— 通用知识标注为【通用药学知识】
             —— 回答末尾必须包含【AI 声明】段落
+            —— 【最高优先级规则】参考资料中如无相关信息,必须明确告知用户,绝对不得编造内容
+            —— 【最高优先级规则】同一药品的不同剂型(如片、胶囊、注射液)信息不可混用
             """;
 
     private static final String AI_DISCLAIMER = """
@@ -117,6 +119,7 @@ public class PromptService {
             4. 不得推荐未在中国获批的药品
             5. 若无法确定安全答案,直接建议就医
             6. 每条信息标注为【通用药学知识】
+            7. 建议用户尝试更具体的关键词(如药品通用名)重新查询
             回答格式(紧凑排版,段落标题后直接接内容,不得有空行):
             【结论】    1到3句话回答用户问题。
             【详细说明】基于通用药学知识作答,每条标注【通用药学知识】。

+ 53 - 18
backend-java/src/main/java/com/pharmacopoeia/service/RetrieverService.java

@@ -126,36 +126,71 @@ public class RetrieverService {
         return jdbc.queryForList(sql, params);
     }
 
-    /** 从 query 中提取已知药品名:查 drugs 表精确匹配 */
+    // 剂型后缀(长后缀优先,避免"缓释胶囊"被错误截断为"缓释")
+    private static final List<String> FORMULATION_SUFFIXES = List.of(
+        "缓释胶囊", "缓释片", "肠溶胶囊", "肠溶片", "分散片", "咀嚼片",
+        "口服混悬液", "口服液", "混悬液", "滴眼液", "注射液",
+        "缓释", "肠溶", "胶囊", "颗粒", "糖浆", "软膏", "栓剂",
+        "片", "剂", "栓"
+    );
+
+    /** 从 query 中提取已知药品名:查 drugs 表,支持剂型后缀剥离和模糊匹配 */
     private String extractDrugName(String query) {
         String cleaned = query.trim();
-        // 逐步去掉尾部修饰词,尝试匹配药品名
-        String[] suffixes = {
+
+        // 第一步:去掉尾部常见修饰词
+        String[] querySuffixes = {
             "的用法与用量", "的用法用量", "用法与用量", "用法用量", "的用量", "的用法",
             "的副作用", "不良反应", "的禁忌", "禁忌", "的注意事项", "注意事项",
             "是什么", "说明书", "怎么用", "怎么吃", "的用量", "用量", "的剂量", "剂量"
         };
-        // 先尝试原始 query
+        for (String s : querySuffixes) {
+            if (cleaned.endsWith(s)) {
+                cleaned = cleaned.substring(0, cleaned.length() - s.length()).trim();
+                break;
+            }
+        }
+        // 去掉问句前缀
+        cleaned = cleaned.replaceAll("^(什么是|怎么|如何|告诉我|请问|查询|搜索|查一下)", "").trim();
+
+        if (cleaned.length() < 2) return "";
+
+        // 第二步:精确匹配原始 query(含剂型名如"布洛芬缓释胶囊")
+        String exact = tryExactMatch(cleaned);
+        if (!exact.isEmpty()) return exact;
+
+        // 第三步:逐步剥剂型后缀再试("布洛芬缓释胶囊"→"布洛芬")
+        for (String suffix : FORMULATION_SUFFIXES) {
+            if (cleaned.endsWith(suffix)) {
+                String base = cleaned.substring(0, cleaned.length() - suffix.length()).trim();
+                if (base.length() >= 2) {
+                    String match = tryExactMatch(base);
+                    if (!match.isEmpty()) return match;
+                }
+            }
+        }
+
+        // 第四步:ILIKE 模糊匹配兜底
+        return tryFuzzyMatch(cleaned);
+    }
+
+    private String tryExactMatch(String name) {
         try {
             List<String> matches = jdbc.queryForList(
                 "SELECT name FROM drugs WHERE name = ? AND is_active = TRUE LIMIT 1",
-                String.class, cleaned);
+                String.class, name);
             if (!matches.isEmpty()) return matches.get(0);
         } catch (Exception ignored) {}
+        return "";
+    }
 
-        // 逐步去掉后缀再试
-        for (String suffix : suffixes) {
-            if (cleaned.endsWith(suffix)) {
-                String candidate = cleaned.substring(0, cleaned.length() - suffix.length()).trim();
-                if (candidate.length() < 2) continue;
-                try {
-                    List<String> matches = jdbc.queryForList(
-                        "SELECT name FROM drugs WHERE name = ? AND is_active = TRUE LIMIT 1",
-                        String.class, candidate);
-                    if (!matches.isEmpty()) return matches.get(0);
-                } catch (Exception ignored) {}
-            }
-        }
+    private String tryFuzzyMatch(String name) {
+        try {
+            List<String> matches = jdbc.queryForList(
+                "SELECT name FROM drugs WHERE name ILIKE ? AND is_active = TRUE ORDER BY name LIMIT 1",
+                String.class, "%" + name + "%");
+            if (!matches.isEmpty()) return matches.get(0);
+        } catch (Exception ignored) {}
         return "";
     }
 

+ 34 - 9
data-pipeline/docx_ingest.py

@@ -17,6 +17,7 @@ from docx import Document
 from docx.oxml.ns import qn
 from sqlalchemy.ext.asyncio import create_async_engine
 from sqlalchemy import text
+from processors.chunker import DrugChunker
 
 # ============================================
 # 配置
@@ -432,23 +433,47 @@ async def get_embeddings(texts: list[str], text_type: str = "document") -> list[
 
 
 async def ingest_docx_entries(entries: list[dict], engine, start_idx: int = 0):
-    """批量向量化 + 入库"""
+    """批量向量化 + 入库。长 section 使用 DrugChunker 切分以避免语义稀释。"""
+    chunker = DrugChunker(chunk_size=1500, chunk_overlap=200, min_chunk_size=200)
     chunks = []
     chunk_meta = []
 
     for entry in entries:
         source = f"{entry['source']['version']} {entry['source']['volume']}"
+        drug_name = entry["name"]
         for section_key, section_text in entry["sections"].items():
             if not section_text or len(section_text.strip()) < 5:
                 continue
-            content = f"【{entry['name']} - {section_key}】\n{section_text}\n\n来源:{source}"
-            chunks.append(content)
-            chunk_meta.append({
-                "drug_id": entry["drug_id"],
-                "section": section_key,
-                "content": content,
-                "source": source,
-            })
+
+            # 短 section:直接作为 1 个 chunk
+            if len(section_text) <= 1500:
+                content = f"【{drug_name} - {section_key}】\n{section_text}\n\n来源:{source}"
+                chunks.append(content)
+                chunk_meta.append({
+                    "drug_id": entry["drug_id"],
+                    "section": section_key,
+                    "content": content,
+                    "source": source,
+                })
+            else:
+                # 长 section:用 DrugChunker 切分为多个 chunk(含 overlap)
+                source_dict = {
+                    "version": entry["source"]["version"],
+                    "volume": entry["source"]["volume"],
+                    "page": entry["source"].get("page", ""),
+                }
+                sub_chunks = chunker._split_long_section(
+                    section_text, drug_name, section_key, source_dict
+                )
+                for sub in sub_chunks:
+                    full_content = sub.content + f"\n\n来源:{source}"
+                    chunks.append(full_content)
+                    chunk_meta.append({
+                        "drug_id": entry["drug_id"],
+                        "section": section_key,
+                        "content": full_content,
+                        "source": source,
+                    })
 
     print(f"  ✂️  {len(chunks)} 个 chunks,向量化中...")
 

+ 185 - 0
data-pipeline/verify_import.py

@@ -0,0 +1,185 @@
+"""
+导入完整性验证脚本
+检查 DOCX 文件数 vs 数据库入库数,定位漏读/漏写的药品
+"""
+import os, sys, json, asyncio
+from pathlib import Path
+from collections import Counter
+
+# 添加同目录模块
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from docx_ingest import find_docx_files, parse_docx
+
+# 数据库
+import asyncpg
+
+SOURCE_DIR = os.environ.get("DOCX_SOURCE_DIR", "/opt/2025")
+DB_HOST = os.environ.get("POSTGRES_HOST", "localhost")
+DB_PORT = os.environ.get("POSTGRES_PORT", "5432")
+DB_NAME = os.environ.get("POSTGRES_DB", "pharmacopoeia")
+DB_USER = os.environ.get("POSTGRES_USER", "postgres")
+DB_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "postgres")
+
+
+async def main():
+    # ==========================================
+    # 1. 扫描 DOCX 文件
+    # ==========================================
+    print("=" * 60)
+    print("📂 扫描 DOCX 文件...")
+    files = find_docx_files(SOURCE_DIR)
+    print(f"   DOCX 文件总数: {len(files)}")
+
+    # 提取文件名(去 .docx)作为药品名
+    docx_names = set()
+    docx_by_volume = Counter()
+    for fp in files:
+        name = Path(fp).stem
+        docx_names.add(name)
+        vol = "一部"
+        for k in ["output4", "output3", "output2", "output"]:
+            if k in fp:
+                vol = {"output": "一部", "output2": "二部", "output3": "三部", "output4": "四部"}[k]
+                break
+        docx_by_volume[vol] += 1
+    print(f"   去重后唯一药名: {len(docx_names)}")
+    print(f"   各卷分布: {dict(docx_by_volume)}")
+
+    # ==========================================
+    # 2. 查询数据库
+    # ==========================================
+    print("\n📊 查询数据库...")
+    conn = await asyncpg.connect(
+        host=DB_HOST, port=DB_PORT, database=DB_NAME,
+        user=DB_USER, password=DB_PASSWORD
+    )
+
+    # 2.1 药品统计
+    total_drugs = await conn.fetchval("SELECT COUNT(*) FROM drugs WHERE is_active = TRUE")
+    total_chunks = await conn.fetchval("SELECT COUNT(*) FROM drug_chunks WHERE vec IS NOT NULL")
+    total_chunks_null = await conn.fetchval("SELECT COUNT(*) FROM drug_chunks WHERE vec IS NULL")
+
+    print(f"   drugs 表 (active): {total_drugs}")
+    print(f"   drug_chunks (有向量): {total_chunks}")
+    print(f"   drug_chunks (无向量): {total_chunks_null}")
+
+    # 2.2 获取数据库中所有药名
+    db_names = set()
+    rows = await conn.fetch("SELECT name FROM drugs WHERE is_active = TRUE")
+    for r in rows:
+        db_names.add(r["name"])
+    print(f"   数据库中药名: {len(db_names)}")
+
+    # 2.3 按来源版本统计
+    version_counts = await conn.fetch("""
+        SELECT source_version, COUNT(*) FROM drugs
+        WHERE is_active = TRUE GROUP BY source_version ORDER BY COUNT(*) DESC
+    """)
+    print(f"   版本分布:")
+    for r in version_counts:
+        print(f"     {r['source_version']}: {r['count']}")
+
+    # 2.4 按分类统计
+    cat_counts = await conn.fetch("""
+        SELECT category, COUNT(*) FROM drugs
+        WHERE is_active = TRUE GROUP BY category ORDER BY COUNT(*) DESC
+    """)
+    print(f"   分类分布:")
+    for r in cat_counts:
+        print(f"     {r['category']}: {r['count']}")
+
+    # ==========================================
+    # 3. 对比分析
+    # ==========================================
+    print("\n🔍 对比分析:")
+
+    # 3.1 DOCX 有但 DB 没有的(漏读)
+    missing = docx_names - db_names
+    if missing:
+        print(f"\n   ❌ DOCX 有但数据库没有({len(missing)} 个):")
+        for name in sorted(missing)[:30]:
+            print(f"      - {name}")
+        if len(missing) > 30:
+            print(f"      ... 还有 {len(missing) - 30} 个")
+    else:
+        print(f"\n   ✅ 无漏读:所有 DOCX 药名均已在数据库中")
+
+    # 3.2 DB 有但 DOCX 没有的(额外数据,如 Wiki)
+    extra = db_names - docx_names
+    if extra:
+        print(f"\n   📝 数据库额外药名(Wiki/样本数据,{len(extra)} 个):")
+        for name in sorted(extra):
+            print(f"      - {name}")
+
+    # ==========================================
+    # 4. 抽查特定药品的 sections
+    # ==========================================
+    print("\n🔬 抽查药品内容完整性:")
+    spot_check = ["布洛芬", "甲硝唑", "阿莫西林", "对乙酰氨基酚", "二甲双胍"]
+    for name in spot_check:
+        drug = await conn.fetchrow(
+            "SELECT drug_id, name, sections, source_version FROM drugs WHERE name = $1 AND is_active = TRUE",
+            name
+        )
+        if drug:
+            sections = json.loads(drug["sections"]) if drug["sections"] else {}
+            chunk_count = await conn.fetchval(
+                "SELECT COUNT(*) FROM drug_chunks WHERE drug_id = $1 AND vec IS NOT NULL",
+                drug["drug_id"]
+            )
+            sec_names = list(sections.keys())
+            print(f"   ✅ {name} | {len(sec_names)} sections | {chunk_count} chunks | {drug['source_version']}")
+            print(f"      栏目: {sec_names[:10]}...")
+        else:
+            print(f"   ❌ {name}: 数据库中不存在")
+
+    # ==========================================
+    # 5. 检查 chunks 完整性
+    # ==========================================
+    print("\n📐 Chunks 完整性检查:")
+
+    # 5.1 检查 drug_id 引用完整性(孤儿 chunk)
+    orphan_chunks = await conn.fetchval("""
+        SELECT COUNT(*) FROM drug_chunks c
+        LEFT JOIN drugs d ON d.drug_id = c.drug_id
+        WHERE d.id IS NULL
+    """)
+    print(f"   孤儿 chunks(无对应 drug): {orphan_chunks}")
+
+    # 5.2 有 drug 但没有 chunk 的
+    no_chunk_drugs = await conn.fetchval("""
+        SELECT COUNT(*) FROM drugs d
+        WHERE d.is_active = TRUE
+        AND NOT EXISTS (SELECT 1 FROM drug_chunks c WHERE c.drug_id = d.drug_id)
+    """)
+    print(f"   无 chunk 的药品: {no_chunk_drugs}")
+
+    # 5.3 vec 维度是否正确
+    vec_dim = await conn.fetchval("""
+        SELECT vector_dims(vec) FROM drug_chunks WHERE vec IS NOT NULL LIMIT 1
+    """)
+    print(f"   向量维度: {vec_dim}")
+
+    # ==========================================
+    # 6. 汇总结论
+    # ==========================================
+    print("\n" + "=" * 60)
+    print("📋 验证结论:")
+    print(f"   DOCX 文件: {len(files)}")
+    print(f"   数据库中药品: {total_drugs}")
+    print(f"   数据库 chunks: {total_chunks}")
+    print(f"   漏读药品: {len(missing)}")
+    print(f"   额外药品 (Wiki等): {len(extra)}")
+    print(f"   孤儿 chunks: {orphan_chunks}")
+    print(f"   无 chunk 药品: {no_chunk_drugs}")
+    if len(missing) == 0 and orphan_chunks == 0 and total_chunks > 0:
+        print("\n   ✅ 导入完整,无漏读漏写")
+    else:
+        print(f"\n   ⚠️  发现问题,请检查上述 ❌ 项")
+    print("=" * 60)
+
+    await conn.close()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 86 - 4
docs/CHANGELOG_JAVA_PYTHON_PARITY.md

@@ -416,7 +416,89 @@ $ cd backend-java && mvn compile
 ## 七、后续建议
 
 1. **【紧急】启动数据库并导入全部数据**: 按 5.6 节步骤执行,这是搜索功能可用的前提
-2. **【优化】模糊搜索纠错**: `RetrieverService.extractDrugName()` 增加编辑距离/拼音容错(如"缓稀"→"缓释")
-3. **Phase 2 待实现**: 平均响应时间追踪、Redis 限流、AI 出题功能、LLM 联网搜索增强
-4. **Phase 2 待实现**: Exam 模块从 stub 升级为真实 DB 查询(题库数据入库后)
-5. **已验证无差距**: Java ↔ Python 后端功能完全对齐
+2. **Phase 2 待实现**: 平均响应时间追踪、Redis 限流、AI 出题功能、LLM 联网搜索增强
+3. **Phase 2 待实现**: Exam 模块从 stub 升级为真实 DB 查询(题库数据入库后)
+4. **已验证无差距**: Java ↔ Python 后端功能完全对齐
+
+---
+
+## 八、第二轮变更:提高 RAG 回答质量(2026-07-20)
+
+### 8.1 背景
+
+导入完成后回答质量仍不理想,经代码审查发现 3 个瓶颈。
+
+### 8.2 变更明细
+
+#### 变更 1: `docx_ingest.py` — 引入 DrugChunker 切分长 section
+
+**问题**: 原代码每个 section 全文 = 1 个 chunk。甲硝唑"概述"2000+ 字 → 1 个巨型向量 → Embedding 语义信号稀释。
+
+**修改**: 在 `ingest_docx_entries()` 中引入已有的 `DrugChunker` 类。超过 1500 字的 section 自动切分为多个 chunk(含 200 字 overlap)。
+
+```python
+# 短 section: 直接作为 1 个 chunk(不变)
+if len(section_text) <= 1500:
+    content = f"【{drug_name} - {section_key}】\n{section_text}\n\n来源:{source}"
+# 长 section: 用 DrugChunker 切分(新增)
+else:
+    sub_chunks = chunker._split_long_section(section_text, drug_name, section_key, source_dict)
+```
+
+**影响**: 需重新运行 `docx_ingest.py` 重导数据。
+
+#### 变更 2: `RetrieverService.java` — 药名匹配增强
+
+**问题**: 搜"布洛芬缓释胶囊"→ 查不到精确药名 → 降级为全库向量搜索 → 可能返回其他药的相似段落。
+
+**修改**:
+- 新增 `FORMULATION_SUFFIXES`(缓释胶囊/片/注射液等 20 种剂型后缀)
+- `extractDrugName()` 改为 4 步递进匹配:
+  1. 精确匹配(如"布洛芬缓释胶囊")
+  2. 剥剂型后缀匹配(→"布洛芬")
+  3. ILIKE 模糊匹配兜底
+  4. 完全未命中则不限定检索范围
+- 匹配到的药名用于限定向量搜索范围(`WHERE d.name = '布洛芬'`)
+
+#### 变更 3: `PromptService.java` — Prompt 微调
+
+**修改**:
+- COMPLIANCE 新增两条最高优先级规则:"参考资料无信息不得编造""不同剂型信息不可混用"
+- NO_DOCS prompt 新增第 7 条:"建议用户尝试更具体的关键词"
+
+### 8.3 改动文件清单
+
+| 文件 | 改动 | 需重导数据 |
+|------|------|-----------|
+| `data-pipeline/docx_ingest.py` | 引入 DrugChunker 切分长 section | ✅ 是 |
+| `backend-java/.../RetrieverService.java` | 剂型后缀剥离 + ILIKE 模糊匹配 | ❌ 否 |
+| `backend-java/.../PromptService.java` | Prompt 加强版 | ❌ 否 |
+
+### 8.4 RAG 回答完整链路(优化后)
+
+```
+用户输入 "布洛芬缓释胶囊的用法用量"
+  │
+  ▼
+① 意图分类  classifyIntent()
+  本地关键词 → "usage_guide"
+  │
+  ▼
+② 药名提取  extractDrugName()
+  "布洛芬缓释胶囊" → 剥"缓释胶囊" → 基药名"布洛芬"
+  DB 精确匹配 → 命中 "布洛芬"
+  │
+  ▼
+③ 限定范围向量检索  search()
+  embed("布洛芬缓释胶囊的用法用量") → 1024维向量
+  SELECT ... WHERE d.name = '布洛芬' ORDER BY vec <=> query_vec
+  → Top-20(仅布洛芬相关 chunks)
+  │
+  ▼
+④ 重排序  RerankerService.rerank()
+  阈值过滤 + n-gram 加权 + Jaccard 去重 + section 加权
+  Top-20 → Top-5
+  │
+  ▼
+⑤ 拼 Prompt → Qwen 生成 → 返回
+```