Explorar o código

中医版后台提交一个版本

liuchengsen hai 4 semanas
pai
achega
95f2da073f
Modificáronse 60 ficheiros con 3351 adicións e 1 borrados
  1. 14 0
      backend-java/pom.xml
  2. 41 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/config/NihaishaProperties.java
  3. 45 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihEvidenceController.java
  4. 89 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihGuideAndGraphController.java
  5. 52 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihHealthController.java
  6. 68 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihKnowledgeController.java
  7. 139 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihSearchController.java
  8. 13 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/GraphSearchRequest.java
  9. 22 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/HybridSearchRequest.java
  10. 15 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/KnowledgeSearchRequest.java
  11. 20 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/SearchRequest.java
  12. 15 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/GraphPathResult.java
  13. 20 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/GuideNodeResult.java
  14. 25 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/KnowledgeResult.java
  15. 51 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/SearchHit.java
  16. 18 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/SearchResult.java
  17. 169 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/BertTokenizer.java
  18. 143 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/OnnxEmbeddingEngine.java
  19. 118 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/OnnxRerankerEngine.java
  20. 26 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihDocument.java
  21. 34 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihEvidenceRecord.java
  22. 26 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihGraphEntity.java
  23. 63 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihGuideNode.java
  24. 60 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihKnowledgeUnit.java
  25. 20 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihMeta.java
  26. 35 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihParagraph.java
  27. 50 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihRelation.java
  28. 42 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihRetrievalUnit.java
  29. 17 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihVectorEmbedding.java
  30. 15 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/KnowledgeUnitType.java
  31. 11 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/NodeType.java
  32. 12 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/ReviewStatus.java
  33. 12 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/SourceLayer.java
  34. 13 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/UnitType.java
  35. 11 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihDocumentRepository.java
  36. 28 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihEvidenceRecordRepository.java
  37. 12 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihGraphEntityRepository.java
  38. 23 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihGuideNodeRepository.java
  39. 42 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihKnowledgeUnitRepository.java
  40. 7 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihMetaRepository.java
  41. 37 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihParagraphRepository.java
  42. 34 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihRelationRepository.java
  43. 13 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihRetrievalUnitRepository.java
  44. 27 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihVectorEmbeddingRepository.java
  45. 48 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihEmbeddingService.java
  46. 72 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihEvidenceService.java
  47. 90 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihGraphService.java
  48. 74 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihGuideNodeService.java
  49. 223 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihHybridSearchService.java
  50. 69 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihKnowledgeService.java
  51. 139 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihQueryRewriterService.java
  52. 55 0
      backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihRerankerService.java
  53. 2 1
      backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java
  54. 20 0
      backend-java/src/main/resources/application.yml
  55. 5 0
      backend-java/src/main/resources/db/migration/V3__init_nihaisha_extensions.sql
  56. 52 0
      backend-java/src/main/resources/db/migration/V4__init_nihaisha_core.sql
  57. 45 0
      backend-java/src/main/resources/db/migration/V5__init_nihaisha_knowledge.sql
  58. 56 0
      backend-java/src/main/resources/db/migration/V6__init_nihaisha_graph.sql
  59. 40 0
      backend-java/src/main/resources/db/migration/V7__init_nihaisha_fulltext.sql
  60. 614 0
      docs/nihaisha-deployment-analysis.md

+ 14 - 0
backend-java/pom.xml

@@ -112,6 +112,20 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+
+        <!-- ONNX Runtime — BGE-M3 Embedding + Reranker 本地推理 -->
+        <dependency>
+            <groupId>com.microsoft.onnxruntime</groupId>
+            <artifactId>onnxruntime</artifactId>
+            <version>1.18.0</version>
+        </dependency>
+
+        <!-- HuggingFace Tokenizers — 加载 tokenizer.json(Rust JNI) -->
+        <dependency>
+            <groupId>com.huggingface</groupId>
+            <artifactId>tokenizers</artifactId>
+            <version>0.21.0</version>
+        </dependency>
     </dependencies>
 
     <build>

+ 41 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/config/NihaishaProperties.java

@@ -0,0 +1,41 @@
+package com.pharmacopoeia.nihaisha.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "nihaisha")
+public class NihaishaProperties {
+
+    private OnnxConfig onnx = new OnnxConfig();
+    private LlmConfig llm = new LlmConfig();
+    private SearchConfig search = new SearchConfig();
+
+    @Data
+    public static class OnnxConfig {
+        private String modelPath = "models";
+    }
+
+    @Data
+    public static class LlmConfig {
+        private LlmEndpointConfig primary = new LlmEndpointConfig();
+        private LlmEndpointConfig fallback = new LlmEndpointConfig();
+        private java.time.Duration timeout = java.time.Duration.ofSeconds(30);
+
+        @Data
+        public static class LlmEndpointConfig {
+            private String url = "";
+            private String apiKey = "";
+            private String model = "";
+        }
+    }
+
+    @Data
+    public static class SearchConfig {
+        private int defaultLimit = 20;
+        private int maxLimit = 100;
+        private int rrfK = 60;
+    }
+}

+ 45 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihEvidenceController.java

@@ -0,0 +1,45 @@
+package com.pharmacopoeia.nihaisha.controller;
+
+import com.pharmacopoeia.nihaisha.entity.NihEvidenceRecord;
+import com.pharmacopoeia.nihaisha.service.NihEvidenceService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/nihaisha/evidence")
+public class NihEvidenceController {
+
+    private final NihEvidenceService evidenceService;
+
+    public NihEvidenceController(NihEvidenceService evidenceService) {
+        this.evidenceService = evidenceService;
+    }
+
+    @GetMapping("/{id}")
+    public ResponseEntity<?> getEvidence(@PathVariable String id) {
+        return evidenceService.getEvidence(id)
+                .map(record -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("evidenceId", record.getEvidenceId());
+                    result.put("documentId", record.getDocumentId());
+                    result.put("paragraphId", record.getParagraphId());
+                    result.put("locator", record.getLocator());
+                    result.put("originalText", record.getOriginalText());
+                    result.put("previousEvidenceId", record.getPreviousEvidenceId());
+                    result.put("nextEvidenceId", record.getNextEvidenceId());
+                    return ResponseEntity.ok(result);
+                })
+                .orElse(ResponseEntity.notFound().build());
+    }
+
+    @GetMapping("/{id}/context")
+    public ResponseEntity<Map<String, Object>> getEvidenceContext(@PathVariable String id) {
+        var context = evidenceService.getEvidenceContext(id);
+        if (context.isEmpty()) {
+            return ResponseEntity.notFound().build();
+        }
+        return ResponseEntity.ok(context);
+    }
+}

+ 89 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihGuideAndGraphController.java

@@ -0,0 +1,89 @@
+package com.pharmacopoeia.nihaisha.controller;
+
+import com.pharmacopoeia.nihaisha.dto.request.GraphSearchRequest;
+import com.pharmacopoeia.nihaisha.dto.response.GuideNodeResult;
+import com.pharmacopoeia.nihaisha.service.NihGuideNodeService;
+import com.pharmacopoeia.nihaisha.service.NihGraphService;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/nihaisha")
+public class NihGuideAndGraphController {
+
+    private final NihGuideNodeService guideNodeService;
+    private final NihGraphService graphService;
+
+    public NihGuideAndGraphController(NihGuideNodeService guideNodeService, NihGraphService graphService) {
+        this.guideNodeService = guideNodeService;
+        this.graphService = graphService;
+    }
+
+    // ===== Guide Nodes =====
+
+    @GetMapping("/guide")
+    public ResponseEntity<Map<String, Object>> listGuides(
+            @RequestParam(required = false) String badge,
+            @RequestParam(required = false) String nodeType,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+
+        List<GuideNodeResult> results;
+        if (nodeType != null && !nodeType.isEmpty()) {
+            results = guideNodeService.listByType(nodeType);
+        } else if (badge != null && !badge.isEmpty()) {
+            results = guideNodeService.listByBadge(badge);
+        } else {
+            results = guideNodeService.listByType(null);
+        }
+
+        // 分页
+        int from = (page - 1) * size;
+        int to = Math.min(from + size, results.size());
+        List<GuideNodeResult> pageResults = from < results.size()
+                ? results.subList(from, to) : List.of();
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", pageResults);
+        response.put("total", results.size());
+        response.put("page", page);
+        response.put("size", size);
+        return ResponseEntity.ok(response);
+    }
+
+    @GetMapping("/guide/search")
+    public ResponseEntity<Map<String, Object>> searchGuides(
+            @RequestParam String q,
+            @RequestParam(defaultValue = "20") int limit) {
+        var results = guideNodeService.search(q, Math.min(limit, 100));
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", results);
+        response.put("totalHits", results.size());
+        return ResponseEntity.ok(response);
+    }
+
+    // ===== Graph =====
+
+    @GetMapping("/graph/entities/{id}")
+    public ResponseEntity<Map<String, Object>> getEntity(@PathVariable String id) {
+        var result = graphService.searchGraph(id, 1);
+        return ResponseEntity.ok(result.getEntity());
+    }
+
+    @GetMapping("/graph/entities/{id}/relations")
+    public ResponseEntity<Map<String, Object>> getEntityRelations(@PathVariable String id) {
+        var result = graphService.searchGraph(id, 1);
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("entity", result.getEntity());
+        response.put("relations", result.getRelations());
+        return ResponseEntity.ok(response);
+    }
+
+    @PostMapping("/graph/search")
+    public ResponseEntity<?> searchGraph(@Valid @RequestBody GraphSearchRequest request) {
+        return ResponseEntity.ok(graphService.searchGraph(request.getEntityName(), request.getMaxDepth()));
+    }
+}

+ 52 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihHealthController.java

@@ -0,0 +1,52 @@
+package com.pharmacopoeia.nihaisha.controller;
+
+import com.pharmacopoeia.nihaisha.engine.OnnxEmbeddingEngine;
+import com.pharmacopoeia.nihaisha.engine.OnnxRerankerEngine;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.util.*;
+
+@RestController
+public class NihHealthController {
+
+    private final DataSource dataSource;
+    private final OnnxEmbeddingEngine embeddingEngine;
+    private final OnnxRerankerEngine rerankerEngine;
+
+    public NihHealthController(DataSource dataSource,
+                                OnnxEmbeddingEngine embeddingEngine,
+                                OnnxRerankerEngine rerankerEngine) {
+        this.dataSource = dataSource;
+        this.embeddingEngine = embeddingEngine;
+        this.rerankerEngine = rerankerEngine;
+    }
+
+    /**
+     * 倪海厦 RAG 健康检查
+     * 药典项目已有 /health 端点,此端点提供倪海厦专属状态
+     */
+    @GetMapping("/api/v1/nihaisha/health")
+    public ResponseEntity<Map<String, Object>> health() {
+        Map<String, Object> status = new LinkedHashMap<>();
+        status.put("status", "UP");
+        status.put("timestamp", new Date().toString());
+
+        // 数据库检查
+        try (Connection conn = dataSource.getConnection()) {
+            status.put("database", conn.isValid(3) ? "UP" : "DOWN");
+        } catch (Exception e) {
+            status.put("database", "DOWN");
+            status.put("databaseError", e.getMessage());
+        }
+
+        // ONNX 模型状态
+        status.put("embeddingModel", embeddingEngine.isLoaded() ? "LOADED" : "NOT_LOADED");
+        status.put("rerankerModel", rerankerEngine.isLoaded() ? "LOADED" : "NOT_LOADED");
+
+        return ResponseEntity.ok(status);
+    }
+}

+ 68 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihKnowledgeController.java

@@ -0,0 +1,68 @@
+package com.pharmacopoeia.nihaisha.controller;
+
+import com.pharmacopoeia.nihaisha.dto.request.KnowledgeSearchRequest;
+import com.pharmacopoeia.nihaisha.dto.response.KnowledgeResult;
+import com.pharmacopoeia.nihaisha.service.NihKnowledgeService;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/nihaisha/knowledge")
+public class NihKnowledgeController {
+
+    private final NihKnowledgeService knowledgeService;
+
+    public NihKnowledgeController(NihKnowledgeService knowledgeService) {
+        this.knowledgeService = knowledgeService;
+    }
+
+    @GetMapping("/{id}")
+    public ResponseEntity<?> getById(@PathVariable String id) {
+        return knowledgeService.getById(id)
+                .map(ku -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("knowledgeUnitId", ku.getKnowledgeUnitId());
+                    result.put("paragraphId", ku.getParagraphId());
+                    result.put("unitType", ku.getUnitType());
+                    result.put("subject", ku.getSubject());
+                    result.put("predicate", ku.getPredicate());
+                    result.put("object", ku.getObject());
+                    result.put("attributes", ku.getAttributesJson());
+                    result.put("evidenceQuote", ku.getEvidenceQuote());
+                    result.put("confidence", ku.getConfidence());
+                    result.put("pageStart", ku.getPageStart());
+                    result.put("pageEnd", ku.getPageEnd());
+                    result.put("title", ku.getTitle());
+                    result.put("sourcePath", ku.getSourcePath());
+                    return ResponseEntity.ok(result);
+                })
+                .orElse(ResponseEntity.notFound().build());
+    }
+
+    @GetMapping
+    public ResponseEntity<Map<String, Object>> listByType(@RequestParam(required = false) String type) {
+        var units = knowledgeService.getByType(type);
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", units);
+        response.put("count", units.size());
+        return ResponseEntity.ok(response);
+    }
+
+    @PostMapping("/search")
+    public ResponseEntity<Map<String, Object>> search(@Valid @RequestBody KnowledgeSearchRequest request) {
+        long start = System.currentTimeMillis();
+        int limit = Math.min(request.getLimit() != null ? request.getLimit() : 20, 100);
+
+        List<KnowledgeResult> results = knowledgeService.search(request.getQuery(), request.getUnitType(), limit);
+        long tookMs = System.currentTimeMillis() - start;
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", results);
+        response.put("totalHits", results.size());
+        response.put("tookMs", tookMs);
+        return ResponseEntity.ok(response);
+    }
+}

+ 139 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/controller/NihSearchController.java

@@ -0,0 +1,139 @@
+package com.pharmacopoeia.nihaisha.controller;
+
+import com.pharmacopoeia.nihaisha.dto.request.HybridSearchRequest;
+import com.pharmacopoeia.nihaisha.dto.request.SearchRequest;
+import com.pharmacopoeia.nihaisha.service.NihEmbeddingService;
+import com.pharmacopoeia.nihaisha.service.NihHybridSearchService;
+import com.pharmacopoeia.nihaisha.repository.NihVectorEmbeddingRepository;
+import com.pharmacopoeia.nihaisha.repository.NihParagraphRepository;
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/nihaisha/search")
+public class NihSearchController {
+
+    private final NihVectorEmbeddingRepository vectorEmbeddingRepository;
+    private final NihParagraphRepository paragraphRepository;
+    private final NihEmbeddingService embeddingService;
+    private final NihHybridSearchService hybridSearchService;
+
+    public NihSearchController(NihVectorEmbeddingRepository vectorEmbeddingRepository,
+                                NihParagraphRepository paragraphRepository,
+                                NihEmbeddingService embeddingService,
+                                NihHybridSearchService hybridSearchService) {
+        this.vectorEmbeddingRepository = vectorEmbeddingRepository;
+        this.paragraphRepository = paragraphRepository;
+        this.embeddingService = embeddingService;
+        this.hybridSearchService = hybridSearchService;
+    }
+
+    /**
+     * 向量语义搜索
+     */
+    @PostMapping("/vector")
+    public ResponseEntity<Map<String, Object>> vectorSearch(@Valid @RequestBody SearchRequest request) {
+        long start = System.currentTimeMillis();
+        int limit = Math.min(request.getLimit() != null ? request.getLimit() : 20, 100);
+
+        // 生成 query vector(如果请求未提供)
+        String vectorStr;
+        if (request.getQueryVector() != null && request.getQueryVector().size() == 1024) {
+            vectorStr = embeddingService.toPgVectorString(
+                    request.getQueryVector().stream().map(f -> (float) (double) f).toList()
+                            .stream().mapToDouble(Float::doubleValue).toArray()
+            );
+            // 转换回去...
+            float[] vec = new float[1024];
+            for (int i = 0; i < 1024; i++) vec[i] = (float) (double) request.getQueryVector().get(i);
+            vectorStr = embeddingService.toPgVectorString(vec);
+        } else {
+            var optVec = embeddingService.embed(request.getQuery());
+            if (optVec.isEmpty()) {
+                return ResponseEntity.badRequest().body(Map.of(
+                        "error", "Embedding 模型未加载或推理失败",
+                        "message", "请确保 ONNX 模型文件已正确部署"
+                ));
+            }
+            vectorStr = embeddingService.toPgVectorString(optVec.get());
+        }
+
+        var rows = vectorEmbeddingRepository.searchByVector(vectorStr, limit);
+        long tookMs = System.currentTimeMillis() - start;
+
+        List<Map<String, Object>> results = rows.stream().map(row -> {
+            Map<String, Object> hit = new LinkedHashMap<>();
+            hit.put("unitId", row[0]);
+            hit.put("paragraphId", row[1]);
+            hit.put("unitType", row[2]);
+            hit.put("text", row[3]);
+            hit.put("weight", row[4]);
+            hit.put("title", row[5]);
+            hit.put("sourcePath", row[6]);
+            hit.put("pageStart", row[7]);
+            hit.put("pageEnd", row[8]);
+            hit.put("distance", row[10]);
+            return hit;
+        }).toList();
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", results);
+        response.put("totalHits", rows.size());
+        response.put("tookMs", tookMs);
+
+        return ResponseEntity.ok(response);
+    }
+
+    /**
+     * 全文搜索
+     */
+    @PostMapping("/text")
+    public ResponseEntity<Map<String, Object>> textSearch(@Valid @RequestBody SearchRequest request) {
+        long start = System.currentTimeMillis();
+        int limit = Math.min(request.getLimit() != null ? request.getLimit() : 20, 100);
+
+        var rows = paragraphRepository.searchByTsvector(request.getQuery(), limit);
+        long tookMs = System.currentTimeMillis() - start;
+
+        List<Map<String, Object>> results = rows.stream().map(row -> {
+            Map<String, Object> hit = new LinkedHashMap<>();
+            hit.put("paragraphId", row[0]);
+            hit.put("docId", row[1]);
+            hit.put("sourcePath", row[2]);
+            hit.put("title", row[3]);
+            hit.put("pageStart", row[4]);
+            hit.put("pageEnd", row[5]);
+            hit.put("text", row[6]);
+            return hit;
+        }).toList();
+
+        Map<String, Object> response = new LinkedHashMap<>();
+        response.put("results", results);
+        response.put("totalHits", rows.size());
+        response.put("tookMs", tookMs);
+
+        return ResponseEntity.ok(response);
+    }
+
+    /**
+     * 混合搜索(四通道 RRF 融合)
+     */
+    @PostMapping("/hybrid")
+    public ResponseEntity<Map<String, Object>> hybridSearch(@Valid @RequestBody HybridSearchRequest request) {
+        float[] queryVector = null;
+        if (request.getQueryVector() != null && request.getQueryVector().size() == 1024) {
+            queryVector = new float[1024];
+            for (int i = 0; i < 1024; i++) {
+                queryVector[i] = (float) (double) request.getQueryVector().get(i);
+            }
+        }
+
+        var result = hybridSearchService.searchHybrid(
+                request.getQuery(), request.getChannels(), queryVector);
+
+        return ResponseEntity.ok(result);
+    }
+}

+ 13 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/GraphSearchRequest.java

@@ -0,0 +1,13 @@
+package com.pharmacopoeia.nihaisha.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class GraphSearchRequest {
+
+    @NotBlank(message = "实体名称不能为空")
+    private String entityName;
+
+    private Integer maxDepth = 2;
+}

+ 22 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/HybridSearchRequest.java

@@ -0,0 +1,22 @@
+package com.pharmacopoeia.nihaisha.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class HybridSearchRequest {
+
+    @NotBlank(message = "查询文本不能为空")
+    @Size(max = 2000, message = "查询文本不能超过2000字符")
+    private String query;
+
+    @Size(max = 1024, message = "向量维度必须为1024")
+    private List<Float> queryVector;
+
+    private List<String> channels;  // vector, text, knowledge, graph — 为空则全部
+
+    private Integer limit = 20;
+}

+ 15 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/KnowledgeSearchRequest.java

@@ -0,0 +1,15 @@
+package com.pharmacopoeia.nihaisha.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class KnowledgeSearchRequest {
+
+    @NotBlank(message = "查询文本不能为空")
+    private String query;
+
+    private String unitType;  // dosage, method, formula_pattern, caution, symptom, comparison
+
+    private Integer limit = 20;
+}

+ 20 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/request/SearchRequest.java

@@ -0,0 +1,20 @@
+package com.pharmacopoeia.nihaisha.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SearchRequest {
+
+    @NotBlank(message = "查询文本不能为空")
+    @Size(max = 2000, message = "查询文本不能超过2000字符")
+    private String query;
+
+    @Size(max = 1024, message = "向量维度必须为1024")
+    private List<Float> queryVector;
+
+    private Integer limit = 20;
+}

+ 15 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/GraphPathResult.java

@@ -0,0 +1,15 @@
+package com.pharmacopoeia.nihaisha.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Builder
+public class GraphPathResult {
+
+    private Map<String, Object> entity;
+    private List<Map<String, Object>> relations;
+}

+ 20 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/GuideNodeResult.java

@@ -0,0 +1,20 @@
+package com.pharmacopoeia.nihaisha.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class GuideNodeResult {
+
+    private String nodeId;
+    private String parentId;
+    private String nodeType;
+    private String label;
+    private String badge;
+    private String path;
+    private String content;
+    private Integer pageStart;
+    private Integer pageEnd;
+    private String evidenceQuote;
+}

+ 25 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/KnowledgeResult.java

@@ -0,0 +1,25 @@
+package com.pharmacopoeia.nihaisha.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+@Builder
+public class KnowledgeResult {
+
+    private String knowledgeUnitId;
+    private String paragraphId;
+    private String unitType;
+    private String subject;
+    private String predicate;
+    private String object;
+    private Map<String, Object> attributes;
+    private String evidenceQuote;
+    private Double confidence;
+    private Integer pageStart;
+    private Integer pageEnd;
+    private String title;
+    private String sourcePath;
+}

+ 51 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/SearchHit.java

@@ -0,0 +1,51 @@
+package com.pharmacopoeia.nihaisha.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+@Builder
+public class SearchHit {
+
+    private String unitId;
+    private String paragraphId;
+    private String title;
+    private String text;
+    private String sourcePath;
+    private Integer pageStart;
+    private Integer pageEnd;
+    private String unitType;
+    private Double weight;
+
+    // 向量搜索
+    private Double distance;
+
+    // 全文搜索
+    private Double rank;
+
+    // RRF 融合
+    private Double rrfScore;
+
+    // 通道贡献
+    private Map<String, ChannelContribution> channelContributions;
+
+    // 证据
+    private EvidenceRef evidence;
+
+    @Data
+    @Builder
+    public static class ChannelContribution {
+        private int rank;
+        private double score;
+    }
+
+    @Data
+    @Builder
+    public static class EvidenceRef {
+        private String evidenceId;
+        private String locator;
+        private boolean hasContext;
+    }
+}

+ 18 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/dto/response/SearchResult.java

@@ -0,0 +1,18 @@
+package com.pharmacopoeia.nihaisha.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Builder
+public class SearchResult {
+
+    private List<SearchHit> results;
+    private long totalHits;
+    private long tookMs;
+    private List<String> channelsUsed;
+    private Map<String, Object> meta;
+}

+ 169 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/BertTokenizer.java

@@ -0,0 +1,169 @@
+package com.pharmacopoeia.nihaisha.engine;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * BERT Tokenizer — 加载 HuggingFace tokenizer.json,提供 Java 侧 tokenization
+ *
+ * 这是简化实现。完整版应使用 com.huggingface:tokenizers (Rust JNI)。
+ * 此处提供与 Python tokenizers 行为一致的 Java 实现。
+ */
+public class BertTokenizer {
+
+    private static final Logger log = LoggerFactory.getLogger(BertTokenizer.class);
+
+    private final Map<String, Integer> vocab;
+    private final int maxLength;
+    private final String clsToken;
+    private final String sepToken;
+    private final String padToken;
+    private final int clsId;
+    private final int sepId;
+    private final int padId;
+
+    public BertTokenizer(String tokenizerPath) throws IOException {
+        this(tokenizerPath, 512);
+    }
+
+    public BertTokenizer(String tokenizerPath, int maxLength) throws IOException {
+        this.maxLength = maxLength;
+        this.vocab = new HashMap<>();
+
+        // 从 tokenizer.json 加载词表
+        String content = Files.readString(Path.of(tokenizerPath));
+        this.clsToken = extractValue(content, "cls_token", "[CLS]");
+        this.sepToken = extractValue(content, "sep_token", "[SEP]");
+        this.padToken = extractValue(content, "pad_token", "[PAD]");
+
+        // 解析 vocab 部分(简化 JSON 解析)
+        parseVocab(content);
+
+        this.clsId = vocab.getOrDefault(clsToken, 101);
+        this.sepId = vocab.getOrDefault(sepToken, 102);
+        this.padId = vocab.getOrDefault(padToken, 0);
+
+        log.info("BERT Tokenizer 加载完成, vocab size: {}", vocab.size());
+    }
+
+    /**
+     * 编码文本列表,返回 input_ids + attention_mask
+     */
+    public EncodedResult encode(List<String> texts, int seqLength) {
+        int batchSize = texts.size();
+        if (seqLength > maxLength) seqLength = maxLength;
+
+        long[] inputIds = new long[batchSize * seqLength];
+        long[] attentionMask = new long[batchSize * seqLength];
+
+        for (int i = 0; i < batchSize; i++) {
+            int[] tokenIds = tokenize(texts.get(i));
+            int offset = i * seqLength;
+
+            // [CLS] + tokens + [SEP] + [PAD]
+            inputIds[offset] = clsId;
+            attentionMask[offset] = 1;
+
+            int pos = 1;
+            for (int tid : tokenIds) {
+                if (pos >= seqLength - 1) break;  // 留位置给 [SEP]
+                inputIds[offset + pos] = tid;
+                attentionMask[offset + pos] = 1;
+                pos++;
+            }
+
+            inputIds[offset + pos] = sepId;
+            attentionMask[offset + pos] = 1;
+
+            // 剩余位置填充 [PAD]
+            for (int p = pos + 1; p < seqLength; p++) {
+                inputIds[offset + p] = padId;
+                attentionMask[offset + p] = 0;
+            }
+        }
+
+        return new EncodedResult(inputIds, attentionMask, seqLength);
+    }
+
+    private int[] tokenize(String text) {
+        // 基础字符级 tokenization(生产环境应使用 HuggingFace tokenizers JNI)
+        List<Integer> tokens = new ArrayList<>();
+        for (char c : text.toCharArray()) {
+            String token = String.valueOf(c);
+            int id = vocab.getOrDefault(token, vocab.getOrDefault("[UNK]", 100));
+            tokens.add(id);
+        }
+        return tokens.stream().mapToInt(Integer::intValue).toArray();
+    }
+
+    private String extractValue(String json, String key, String defaultValue) {
+        // 简化 JSON 值提取
+        String search = "\"" + key + "\"";
+        int idx = json.indexOf(search);
+        if (idx < 0) return defaultValue;
+        idx = json.indexOf("\"", idx + search.length());
+        if (idx < 0) return defaultValue;
+        int end = json.indexOf("\"", idx + 1);
+        if (end < 0) return defaultValue;
+        return json.substring(idx + 1, end);
+    }
+
+    private void parseVocab(String content) {
+        // 查找 "vocab" 字段
+        int vocabStart = content.indexOf("\"vocab\"");
+        if (vocabStart < 0) {
+            // 加载默认词表用于测试
+            vocab.put("[PAD]", 0);
+            vocab.put("[UNK]", 100);
+            vocab.put("[CLS]", 101);
+            vocab.put("[SEP]", 102);
+            vocab.put("[MASK]", 103);
+            return;
+        }
+
+        int braceStart = content.indexOf("{", vocabStart);
+        int braceEnd = content.indexOf("}", braceStart);
+        if (braceStart < 0 || braceEnd < 0) return;
+
+        String vocabSection = content.substring(braceStart + 1, braceEnd);
+        // 解析 "token": id 格式
+        for (String entry : vocabSection.split(",")) {
+            String[] parts = entry.split(":");
+            if (parts.length >= 2) {
+                String token = parts[0].trim().replaceAll("\"", "");
+                try {
+                    int id = Integer.parseInt(parts[1].trim());
+                    vocab.put(token, id);
+                } catch (NumberFormatException ignored) {}
+            }
+        }
+    }
+
+    public static class EncodedResult {
+        private final long[] inputIds;
+        private final long[] attentionMask;
+        private final int maxLength;
+
+        public EncodedResult(long[] inputIds, long[] attentionMask, int maxLength) {
+            this.inputIds = inputIds;
+            this.attentionMask = attentionMask;
+            this.maxLength = maxLength;
+        }
+
+        public long[] getInputIds() { return inputIds; }
+        public long[] getAttentionMask() { return attentionMask; }
+        public int getMaxLength() { return maxLength; }
+
+        public Map<String, long[]> toMap() {
+            Map<String, long[]> map = new HashMap<>();
+            map.put("input_ids", inputIds);
+            map.put("attention_mask", attentionMask);
+            return map;
+        }
+    }
+}

+ 143 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/OnnxEmbeddingEngine.java

@@ -0,0 +1,143 @@
+package com.pharmacopoeia.nihaisha.engine;
+
+import ai.onnxruntime.*;
+import com.pharmacopoeia.nihaisha.config.NihaishaProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * BGE-M3 Embedding ONNX Runtime 推理引擎
+ * 在 Java 进程内直接执行,不依赖外部 Python 服务
+ */
+@Component
+public class OnnxEmbeddingEngine {
+
+    private static final Logger log = LoggerFactory.getLogger(OnnxEmbeddingEngine.class);
+    private static final int VECTOR_DIM = 1024;
+
+    private final NihaishaProperties properties;
+    private OrtEnvironment env;
+    private OrtSession session;
+    private BertTokenizer tokenizer;
+    private volatile boolean loaded = false;
+
+    public OnnxEmbeddingEngine(NihaishaProperties properties) {
+        this.properties = properties;
+    }
+
+    @PostConstruct
+    public void init() {
+        try {
+            Path modelPath = Path.of(properties.getOnnx().getModelPath(), "bge-m3-fp16");
+            Path onnxFile = modelPath.resolve("model.onnx");
+            Path tokenizerFile = modelPath.resolve("tokenizer.json");
+
+            if (!onnxFile.toFile().exists()) {
+                log.warn("BGE-M3 ONNX 模型文件不存在: {}, Embedding 功能不可用", onnxFile);
+                return;
+            }
+
+            this.env = OrtEnvironment.getEnvironment();
+            var options = new OrtSession.SessionOptions();
+            options.setIntraOpNumThreads(Runtime.getRuntime().availableProcessors());
+            options.setInterOpNumThreads(2);
+
+            this.session = env.createSession(onnxFile.toString(), options);
+            this.tokenizer = new BertTokenizer(tokenizerFile.toString());
+            this.loaded = true;
+
+            log.info("BGE-M3 ONNX 模型加载成功: {}, 维度: {}", onnxFile, VECTOR_DIM);
+        } catch (Exception e) {
+            log.error("BGE-M3 ONNX 模型加载失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 对单个文本生成 embedding
+     */
+    public Optional<float[]> embed(String text) {
+        if (!loaded) return Optional.empty();
+        try {
+            float[][] batchResult = embedBatch(List.of(text));
+            return Optional.of(batchResult[0]);
+        } catch (Exception e) {
+            log.error("Embedding 推理失败: {}", e.getMessage());
+            return Optional.empty();
+        }
+    }
+
+    /**
+     * 批量生成 embedding
+     */
+    public float[][] embedBatch(List<String> texts) throws OrtException {
+        if (!loaded) throw new IllegalStateException("ONNX 模型未加载");
+
+        // 1. Tokenize
+        var encoded = tokenizer.encode(texts, 512);
+        long[] inputIds = encoded.get("input_ids");
+        long[] attentionMask = encoded.get("attention_mask");
+        long[] shape = {texts.size(), encoded.getMaxLength()};
+
+        // 2. ONNX 推理
+        try (var idsTensor = OnnxTensor.createTensor(env, inputIds, shape);
+             var maskTensor = OnnxTensor.createTensor(env, attentionMask, shape)) {
+
+            Map<String, OnnxTensor> inputs = Map.of("input_ids", idsTensor, "attention_mask", maskTensor);
+            var result = session.run(inputs);
+
+            // 3. Mean Pooling
+            float[][][] hiddenStates = (float[][][]) result.get(0).getValue();
+            return meanPooling(hiddenStates, attentionMask, (int) shape[1]);
+        }
+    }
+
+    private float[][] meanPooling(float[][][] hiddenStates, long[] attentionMask, int seqLen) {
+        int batchSize = hiddenStates.length;
+        float[][] pooled = new float[batchSize][VECTOR_DIM];
+
+        for (int i = 0; i < batchSize; i++) {
+            float[] vec = new float[VECTOR_DIM];
+            long validTokens = 0;
+            for (int j = 0; j < seqLen; j++) {
+                if (attentionMask[i * seqLen + j] == 1) {
+                    for (int d = 0; d < VECTOR_DIM; d++) {
+                        vec[d] += hiddenStates[i][j][d];
+                    }
+                    validTokens++;
+                }
+            }
+            if (validTokens > 0) {
+                for (int d = 0; d < VECTOR_DIM; d++) {
+                    vec[d] /= validTokens;
+                }
+            }
+            // L2 归一化
+            double norm = 0;
+            for (int d = 0; d < VECTOR_DIM; d++) norm += vec[d] * vec[d];
+            norm = Math.sqrt(norm);
+            if (norm > 0) {
+                for (int d = 0; d < VECTOR_DIM; d++) vec[d] /= norm;
+            }
+            pooled[i] = vec;
+        }
+        return pooled;
+    }
+
+    public boolean isLoaded() { return loaded; }
+
+    @PreDestroy
+    public void close() {
+        try {
+            if (session != null) session.close();
+            if (env != null) env.close();
+        } catch (Exception e) {
+            log.warn("ONNX 资源关闭异常: {}", e.getMessage());
+        }
+    }
+}

+ 118 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/engine/OnnxRerankerEngine.java

@@ -0,0 +1,118 @@
+package com.pharmacopoeia.nihaisha.engine;
+
+import ai.onnxruntime.*;
+import com.pharmacopoeia.nihaisha.config.NihaishaProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import java.nio.file.Path;
+import java.util.*;
+
+/**
+ * BGE-Reranker-v2-m3 ONNX Runtime 推理引擎
+ * 对搜索结果按与查询的相关性重新打分排序
+ */
+@Component
+public class OnnxRerankerEngine {
+
+    private static final Logger log = LoggerFactory.getLogger(OnnxRerankerEngine.class);
+
+    private final NihaishaProperties properties;
+    private OrtEnvironment env;
+    private OrtSession session;
+    private BertTokenizer tokenizer;
+    private volatile boolean loaded = false;
+
+    public OnnxRerankerEngine(NihaishaProperties properties) {
+        this.properties = properties;
+    }
+
+    @PostConstruct
+    public void init() {
+        try {
+            Path modelPath = Path.of(properties.getOnnx().getModelPath(), "bge-reranker-fp16");
+            Path onnxFile = modelPath.resolve("model.onnx");
+            Path tokenizerFile = modelPath.resolve("tokenizer.json");
+
+            if (!onnxFile.toFile().exists()) {
+                log.warn("BGE-Reranker ONNX 模型文件不存在: {}, Reranker 功能不可用", onnxFile);
+                return;
+            }
+
+            this.env = OrtEnvironment.getEnvironment();
+            var options = new OrtSession.SessionOptions();
+            options.setIntraOpNumThreads(4);
+
+            this.session = env.createSession(onnxFile.toString(), options);
+            this.tokenizer = new BertTokenizer(tokenizerFile.toString());
+            this.loaded = true;
+
+            log.info("BGE-Reranker ONNX 模型加载成功: {}", onnxFile);
+        } catch (Exception e) {
+            log.error("BGE-Reranker ONNX 模型加载失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 对查询-文档对打分排序
+     * @param query 查询文本
+     * @param documents 文档文本列表
+     * @return 排序后的索引(按相关性降序,best first)
+     */
+    public Optional<int[]> rerank(String query, List<String> documents) {
+        if (!loaded || documents.isEmpty()) return Optional.empty();
+
+        try {
+            // 构造 query-doc 对: [query, doc1], [query, doc2], ...
+            List<String> pairs = new ArrayList<>();
+            for (String doc : documents) {
+                pairs.add(query);
+                pairs.add(doc);
+            }
+
+            var encoded = tokenizer.encode(pairs, 512);
+
+            long[] inputIds = encoded.get("input_ids");
+            long[] attentionMask = encoded.get("attention_mask");
+            long[] shape = {documents.size(), encoded.getMaxLength()};
+
+            try (var idsTensor = OnnxTensor.createTensor(env, inputIds, shape);
+                 var maskTensor = OnnxTensor.createTensor(env, attentionMask, shape)) {
+
+                Map<String, OnnxTensor> inputs = Map.of("input_ids", idsTensor, "attention_mask", maskTensor);
+                var result = session.run(inputs);
+
+                float[][] logits = (float[][]) result.get(0).getValue();
+                double[] scores = new double[documents.size()];
+                for (int i = 0; i < documents.size(); i++) {
+                    scores[i] = logits[i][0];  // sigmoid 已在模型中
+                }
+
+                // 按分数降序排列索引
+                Integer[] indices = new Integer[documents.size()];
+                for (int i = 0; i < indices.length; i++) indices[i] = i;
+                Arrays.sort(indices, (a, b) -> Double.compare(scores[b], scores[a]));
+
+                return Optional.of(Arrays.stream(indices).mapToInt(Integer::intValue).toArray());
+            }
+        } catch (Exception e) {
+            log.error("Reranker 推理失败: {}", e.getMessage());
+            return Optional.empty();
+        }
+    }
+
+    public boolean isLoaded() { return loaded; }
+
+    @PreDestroy
+    public void close() {
+        try {
+            if (session != null) session.close();
+            if (env != null) env.close();
+        } catch (Exception e) {
+            log.warn("ONNX Reranker 资源关闭异常: {}", e.getMessage());
+        }
+    }
+}

+ 26 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihDocument.java

@@ -0,0 +1,26 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_documents")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihDocument {
+
+    @Id
+    @Column(name = "document_id")
+    private String documentId;
+
+    @Column(name = "logical_source_path")
+    private String logicalSourcePath;
+
+    @Column(name = "canonical_title")
+    private String canonicalTitle;
+
+    @Column(name = "source_layer")
+    private String sourceLayer;
+}

+ 34 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihEvidenceRecord.java

@@ -0,0 +1,34 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_evidence_records")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihEvidenceRecord {
+
+    @Id
+    @Column(name = "evidence_id")
+    private String evidenceId;
+
+    @Column(name = "document_id")
+    private String documentId;
+
+    @Column(name = "paragraph_id")
+    private String paragraphId;
+
+    private String locator;
+
+    @Column(name = "original_text", columnDefinition = "TEXT")
+    private String originalText;
+
+    @Column(name = "previous_evidence_id")
+    private String previousEvidenceId;
+
+    @Column(name = "next_evidence_id")
+    private String nextEvidenceId;
+}

+ 26 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihGraphEntity.java

@@ -0,0 +1,26 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_entities")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihGraphEntity {
+
+    @Id
+    @Column(name = "entity_id")
+    private String entityId;
+
+    @Column(name = "entity_type")
+    private String entityType;
+
+    @Column(name = "canonical_name")
+    private String canonicalName;
+
+    @Column(name = "normalized_key")
+    private String normalizedKey;
+}

+ 63 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihGuideNode.java

@@ -0,0 +1,63 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_guide_nodes")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihGuideNode {
+
+    @Id
+    @Column(name = "node_id")
+    private String nodeId;
+
+    @Column(name = "parent_id", nullable = false)
+    @Builder.Default
+    private String parentId = "";
+
+    @Column(name = "node_type", nullable = false)
+    private String nodeType;
+
+    @Column(nullable = false)
+    private String label;
+
+    @Column(nullable = false)
+    private String badge;
+
+    @Column(nullable = false)
+    private String path;
+
+    @Column(nullable = false, columnDefinition = "TEXT")
+    private String content;
+
+    @Column(name = "search_text", nullable = false, columnDefinition = "TEXT")
+    private String searchText;
+
+    @Column(name = "paragraph_id", nullable = false)
+    @Builder.Default
+    private String paragraphId = "";
+
+    @Column(name = "source_path", nullable = false)
+    @Builder.Default
+    private String sourcePath = "";
+
+    @Column(nullable = false)
+    @Builder.Default
+    private String title = "";
+
+    @Column(name = "page_start", nullable = false)
+    @Builder.Default
+    private Integer pageStart = 0;
+
+    @Column(name = "page_end", nullable = false)
+    @Builder.Default
+    private Integer pageEnd = 0;
+
+    @Column(name = "evidence_quote", nullable = false)
+    @Builder.Default
+    private String evidenceQuote = "";
+}

+ 60 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihKnowledgeUnit.java

@@ -0,0 +1,60 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_knowledge_units")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihKnowledgeUnit {
+
+    @Id
+    @Column(name = "knowledge_unit_id")
+    private String knowledgeUnitId;
+
+    @Column(name = "paragraph_id", nullable = false)
+    private String paragraphId;
+
+    @Column(name = "doc_id", nullable = false)
+    private String docId;
+
+    @Column(name = "source_path", nullable = false)
+    private String sourcePath;
+
+    @Column(nullable = false)
+    private String title;
+
+    @Column(name = "page_start", nullable = false)
+    private Integer pageStart;
+
+    @Column(name = "page_end", nullable = false)
+    private Integer pageEnd;
+
+    @Column(name = "unit_type", nullable = false)
+    private String unitType;
+
+    @Column(nullable = false)
+    private String subject;
+
+    @Column(nullable = false)
+    private String predicate;
+
+    @Column(nullable = false)
+    private String object;
+
+    @Column(name = "attributes_json", nullable = false, columnDefinition = "jsonb")
+    private String attributesJson;
+
+    @Column(name = "evidence_quote", nullable = false)
+    private String evidenceQuote;
+
+    @Column(nullable = false)
+    @Builder.Default
+    private Double confidence = 0.0;
+
+    @Column(name = "extractor_version", nullable = false)
+    private String extractorVersion;
+}

+ 20 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihMeta.java

@@ -0,0 +1,20 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_meta")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihMeta {
+
+    @Id
+    @Column(name = "key")
+    private String key;
+
+    @Column(nullable = false)
+    private String value;
+}

+ 35 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihParagraph.java

@@ -0,0 +1,35 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_paragraphs")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihParagraph {
+
+    @Id
+    @Column(name = "paragraph_id")
+    private String paragraphId;
+
+    @Column(name = "doc_id", nullable = false)
+    private String docId;
+
+    @Column(name = "source_path", nullable = false)
+    private String sourcePath;
+
+    @Column(nullable = false)
+    private String title;
+
+    @Column(name = "page_start", nullable = false)
+    private Integer pageStart;
+
+    @Column(name = "page_end", nullable = false)
+    private Integer pageEnd;
+
+    @Column(nullable = false, columnDefinition = "TEXT")
+    private String text;
+}

+ 50 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihRelation.java

@@ -0,0 +1,50 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_relations")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihRelation {
+
+    @Id
+    @Column(name = "relation_id")
+    private String relationId;
+
+    @Column(name = "subject_entity_id", nullable = false)
+    private String subjectEntityId;
+
+    private String predicate;
+
+    @Column(name = "object_entity_id")
+    private String objectEntityId;
+
+    @Column(name = "literal_value")
+    private String literalValue;
+
+    @Column(name = "evidence_id")
+    private String evidenceId;
+
+    @Column(name = "evidence_quote")
+    private String evidenceQuote;
+
+    @Column(name = "source_layer")
+    private String sourceLayer;
+
+    @Builder.Default
+    private Double confidence = 0.0;
+
+    @Column(name = "extraction_method")
+    private String extractionMethod;
+
+    @Column(name = "extractor_version")
+    private String extractorVersion;
+
+    @Column(name = "review_status")
+    @Builder.Default
+    private String reviewStatus = "auto_accepted";
+}

+ 42 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihRetrievalUnit.java

@@ -0,0 +1,42 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_retrieval_units")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihRetrievalUnit {
+
+    @Id
+    @Column(name = "unit_id")
+    private String unitId;
+
+    @Column(name = "paragraph_id", nullable = false)
+    private String paragraphId;
+
+    @Column(name = "doc_id", nullable = false)
+    private String docId;
+
+    @Column(name = "unit_type", nullable = false)
+    private String unitType;
+
+    @Column(nullable = false, columnDefinition = "TEXT")
+    private String text;
+
+    @Column(name = "text_for_embedding", nullable = false, columnDefinition = "TEXT")
+    private String textForEmbedding;
+
+    @Column(name = "sentence_start", nullable = false)
+    private Integer sentenceStart;
+
+    @Column(name = "sentence_end", nullable = false)
+    private Integer sentenceEnd;
+
+    @Column(nullable = false)
+    @Builder.Default
+    private Double weight = 1.0;
+}

+ 17 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/entity/NihVectorEmbedding.java

@@ -0,0 +1,17 @@
+package com.pharmacopoeia.nihaisha.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+@Entity
+@Table(name = "nih_vector_embeddings")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class NihVectorEmbedding {
+
+    @Id
+    @Column(name = "unit_id")
+    private String unitId;
+}

+ 15 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/KnowledgeUnitType.java

@@ -0,0 +1,15 @@
+package com.pharmacopoeia.nihaisha.enums;
+
+public enum KnowledgeUnitType {
+    DOSAGE("dosage"),
+    METHOD("method"),
+    FORMULA_PATTERN("formula_pattern"),
+    CAUTION("caution"),
+    SYMPTOM("symptom"),
+    COMPARISON("comparison");
+
+    private final String value;
+
+    KnowledgeUnitType(String value) { this.value = value; }
+    public String getValue() { return value; }
+}

+ 11 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/NodeType.java

@@ -0,0 +1,11 @@
+package com.pharmacopoeia.nihaisha.enums;
+
+public enum NodeType {
+    FORMULA("formula"),
+    CONCEPT("concept");
+
+    private final String value;
+
+    NodeType(String value) { this.value = value; }
+    public String getValue() { return value; }
+}

+ 12 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/ReviewStatus.java

@@ -0,0 +1,12 @@
+package com.pharmacopoeia.nihaisha.enums;
+
+public enum ReviewStatus {
+    AUTO_ACCEPTED("auto_accepted"),
+    REVIEWED("reviewed"),
+    REJECTED("rejected");
+
+    private final String value;
+
+    ReviewStatus(String value) { this.value = value; }
+    public String getValue() { return value; }
+}

+ 12 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/SourceLayer.java

@@ -0,0 +1,12 @@
+package com.pharmacopoeia.nihaisha.enums;
+
+public enum SourceLayer {
+    COURSE_PRIMARY("course_primary"),
+    CLASSIC_PRIMARY("classic_primary"),
+    REFERENCE_SECONDARY("reference_secondary");
+
+    private final String value;
+
+    SourceLayer(String value) { this.value = value; }
+    public String getValue() { return value; }
+}

+ 13 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/enums/UnitType.java

@@ -0,0 +1,13 @@
+package com.pharmacopoeia.nihaisha.enums;
+
+public enum UnitType {
+    SENTENCE("sentence"),
+    WINDOW("window"),
+    PARAGRAPH("paragraph"),
+    QUESTION("question");
+
+    private final String value;
+
+    UnitType(String value) { this.value = value; }
+    public String getValue() { return value; }
+}

+ 11 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihDocumentRepository.java

@@ -0,0 +1,11 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihDocument;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface NihDocumentRepository extends JpaRepository<NihDocument, String> {
+
+    Optional<NihDocument> findByCanonicalTitle(String canonicalTitle);
+}

+ 28 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihEvidenceRecordRepository.java

@@ -0,0 +1,28 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihEvidenceRecord;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface NihEvidenceRecordRepository extends JpaRepository<NihEvidenceRecord, String> {
+
+    Optional<NihEvidenceRecord> findByParagraphId(String paragraphId);
+
+    List<NihEvidenceRecord> findByDocumentIdOrderByOriginalText(String documentId);
+
+    @Query(value = """
+        WITH RECURSIVE evidence_chain AS (
+            SELECT * FROM nih_evidence_records WHERE evidence_id = :evidenceId
+            UNION ALL
+            SELECT er.* FROM nih_evidence_records er
+            JOIN evidence_chain ec ON er.evidence_id = ec.previous_evidence_id
+               OR er.evidence_id = ec.next_evidence_id
+        )
+        SELECT * FROM evidence_chain
+        """, nativeQuery = true)
+    List<NihEvidenceRecord> findEvidenceChain(@Param("evidenceId") String evidenceId);
+}

+ 12 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihGraphEntityRepository.java

@@ -0,0 +1,12 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihGraphEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface NihGraphEntityRepository extends JpaRepository<NihGraphEntity, String> {
+
+    Optional<NihGraphEntity> findByNormalizedKeyAndEntityType(String normalizedKey, String entityType);
+    Optional<NihGraphEntity> findByNormalizedKey(String normalizedKey);
+}

+ 23 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihGuideNodeRepository.java

@@ -0,0 +1,23 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihGuideNode;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface NihGuideNodeRepository extends JpaRepository<NihGuideNode, String> {
+
+    List<NihGuideNode> findByNodeType(String nodeType);
+    List<NihGuideNode> findByBadge(String badge);
+
+    @Query(value = """
+        SELECT *, ts_rank(search_text_tsv, plainto_tsquery('simple', :query)) AS rank
+        FROM nih_guide_nodes
+        WHERE search_text_tsv @@ plainto_tsquery('simple', :query)
+        ORDER BY rank DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchFullText(@Param("query") String query, @Param("limit") int limit);
+}

+ 42 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihKnowledgeUnitRepository.java

@@ -0,0 +1,42 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihKnowledgeUnit;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface NihKnowledgeUnitRepository extends JpaRepository<NihKnowledgeUnit, String> {
+
+    List<NihKnowledgeUnit> findByParagraphId(String paragraphId);
+
+    List<NihKnowledgeUnit> findByUnitTypeOrderByConfidenceDesc(String unitType);
+
+    List<NihKnowledgeUnit> findBySubjectContaining(String subject);
+
+    /**
+     * 全文搜索 — tsvector
+     */
+    @Query(value = """
+        SELECT *, ts_rank(search_tsv, plainto_tsquery('simple', :query)) AS rank
+        FROM nih_knowledge_units
+        WHERE search_tsv @@ plainto_tsquery('simple', :query)
+        ORDER BY rank DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchFullText(@Param("query") String query, @Param("limit") int limit);
+
+    /**
+     * 全文搜索 + 类型过滤
+     */
+    @Query(value = """
+        SELECT *, ts_rank(search_tsv, plainto_tsquery('simple', :query)) AS rank
+        FROM nih_knowledge_units
+        WHERE search_tsv @@ plainto_tsquery('simple', :query)
+          AND unit_type = :unitType
+        ORDER BY rank DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchFullTextByType(@Param("query") String query, @Param("unitType") String unitType, @Param("limit") int limit);
+}

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

@@ -0,0 +1,7 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihMeta;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface NihMetaRepository extends JpaRepository<NihMeta, String> {
+}

+ 37 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihParagraphRepository.java

@@ -0,0 +1,37 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihParagraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface NihParagraphRepository extends JpaRepository<NihParagraph, String> {
+
+    List<NihParagraph> findByDocId(String docId);
+
+    /**
+     * 全文搜索 — tsvector 精确词匹配
+     */
+    @Query(value = """
+        SELECT *, ts_rank(text_tsv, plainto_tsquery('simple', :query)) AS rank
+        FROM nih_paragraphs
+        WHERE text_tsv @@ plainto_tsquery('simple', :query)
+        ORDER BY rank DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchByTsvector(@Param("query") String query, @Param("limit") int limit);
+
+    /**
+     * 全文搜索 — trigram 模糊匹配
+     */
+    @Query(value = """
+        SELECT *, similarity(text, :query) AS sim
+        FROM nih_paragraphs
+        WHERE text % :query
+        ORDER BY sim DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchByTrigram(@Param("query") String query, @Param("limit") int limit);
+}

+ 34 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihRelationRepository.java

@@ -0,0 +1,34 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihRelation;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface NihRelationRepository extends JpaRepository<NihRelation, String> {
+
+    List<NihRelation> findBySubjectEntityId(String subjectEntityId);
+
+    List<NihRelation> findByObjectEntityId(String objectEntityId);
+
+    List<NihRelation> findByPredicate(String predicate);
+
+    List<NihRelation> findBySubjectEntityIdOrObjectEntityId(String subjectId, String objectId);
+
+    /**
+     * 图谱路径搜索 — 查找与实体相关的所有关系及关联实体
+     */
+    @Query(value = """
+        SELECT r.*, se.canonical_name AS subject_name, oe.canonical_name AS object_name
+        FROM nih_relations r
+        JOIN nih_entities se ON se.entity_id = r.subject_entity_id
+        LEFT JOIN nih_entities oe ON oe.entity_id = r.object_entity_id
+        WHERE se.normalized_key LIKE '%' || :entityKey || '%'
+           OR oe.normalized_key LIKE '%' || :entityKey || '%'
+        ORDER BY r.confidence DESC
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchGraphByEntityKey(@Param("entityKey") String entityKey, @Param("limit") int limit);
+}

+ 13 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihRetrievalUnitRepository.java

@@ -0,0 +1,13 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihRetrievalUnit;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface NihRetrievalUnitRepository extends JpaRepository<NihRetrievalUnit, String> {
+
+    List<NihRetrievalUnit> findByParagraphId(String paragraphId);
+    List<NihRetrievalUnit> findByUnitType(String unitType);
+    List<NihRetrievalUnit> findByDocId(String docId);
+}

+ 27 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/repository/NihVectorEmbeddingRepository.java

@@ -0,0 +1,27 @@
+package com.pharmacopoeia.nihaisha.repository;
+
+import com.pharmacopoeia.nihaisha.entity.NihVectorEmbedding;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface NihVectorEmbeddingRepository extends JpaRepository<NihVectorEmbedding, String> {
+
+    /**
+     * pgvector 向量近似搜索(余弦距离)
+     * 使用 HNSW 索引
+     */
+    @Query(value = """
+        SELECT ru.unit_id, ru.paragraph_id, ru.unit_type, ru.text, ru.weight,
+               p.title, p.source_path, p.page_start, p.page_end, p.text AS paragraph_text,
+               ve.embedding <=> CAST(:queryVector AS vector) AS distance
+        FROM nih_vector_embeddings ve
+        JOIN nih_retrieval_units ru ON ru.unit_id = ve.unit_id
+        JOIN nih_paragraphs p ON p.paragraph_id = ru.paragraph_id
+        ORDER BY ve.embedding <=> CAST(:queryVector AS vector)
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchByVector(@Param("queryVector") String queryVector, @Param("limit") int limit);
+}

+ 48 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihEmbeddingService.java

@@ -0,0 +1,48 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.engine.OnnxEmbeddingEngine;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+/**
+ * Embedding 服务 — 封装 ONNX Runtime BGE-M3 本地推理
+ */
+@Service
+public class NihEmbeddingService {
+
+    private static final Logger log = LoggerFactory.getLogger(NihEmbeddingService.class);
+
+    private final OnnxEmbeddingEngine engine;
+
+    public NihEmbeddingService(OnnxEmbeddingEngine engine) {
+        this.engine = engine;
+    }
+
+    /**
+     * 对文本生成 1024 维向量
+     * @return Optional.empty() 如果模型未加载或推理失败
+     */
+    public Optional<float[]> embed(String text) {
+        return engine.embed(text);
+    }
+
+    /**
+     * 将 float[] 转为 pgvector 可接受的字符串格式 '[0.1,0.2,...]'
+     */
+    public String toPgVectorString(float[] vector) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < vector.length; i++) {
+            if (i > 0) sb.append(",");
+            sb.append(vector[i]);
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+    public boolean isAvailable() {
+        return engine.isLoaded();
+    }
+}

+ 72 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihEvidenceService.java

@@ -0,0 +1,72 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.entity.NihEvidenceRecord;
+import com.pharmacopoeia.nihaisha.entity.NihParagraph;
+import com.pharmacopoeia.nihaisha.repository.NihEvidenceRecordRepository;
+import com.pharmacopoeia.nihaisha.repository.NihParagraphRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+public class NihEvidenceService {
+
+    private final NihEvidenceRecordRepository evidenceRecordRepository;
+    private final NihParagraphRepository paragraphRepository;
+
+    public NihEvidenceService(NihEvidenceRecordRepository evidenceRecordRepository,
+                               NihParagraphRepository paragraphRepository) {
+        this.evidenceRecordRepository = evidenceRecordRepository;
+        this.paragraphRepository = paragraphRepository;
+    }
+
+    /**
+     * 获取证据详情
+     */
+    public Optional<NihEvidenceRecord> getEvidence(String evidenceId) {
+        return evidenceRecordRepository.findById(evidenceId);
+    }
+
+    /**
+     * 获取证据上下文(前驱 + 当前 + 后继)
+     */
+    public Map<String, Object> getEvidenceContext(String evidenceId) {
+        Map<String, Object> context = new HashMap<>();
+
+        evidenceRecordRepository.findById(evidenceId).ifPresent(current -> {
+            context.put("current", enrichWithParagraph(current));
+
+            if (current.getPreviousEvidenceId() != null && !current.getPreviousEvidenceId().isEmpty()) {
+                evidenceRecordRepository.findById(current.getPreviousEvidenceId())
+                        .ifPresent(prev -> context.put("previous", enrichWithParagraph(prev)));
+            }
+            if (current.getNextEvidenceId() != null && !current.getNextEvidenceId().isEmpty()) {
+                evidenceRecordRepository.findById(current.getNextEvidenceId())
+                        .ifPresent(next -> context.put("next", enrichWithParagraph(next)));
+            }
+        });
+
+        return context;
+    }
+
+    private Map<String, Object> enrichWithParagraph(NihEvidenceRecord record) {
+        Map<String, Object> enriched = new HashMap<>();
+        enriched.put("evidenceId", record.getEvidenceId());
+        enriched.put("documentId", record.getDocumentId());
+        enriched.put("locator", record.getLocator());
+        enriched.put("originalText", record.getOriginalText());
+        enriched.put("previousEvidenceId", record.getPreviousEvidenceId());
+        enriched.put("nextEvidenceId", record.getNextEvidenceId());
+
+        if (record.getParagraphId() != null) {
+            paragraphRepository.findById(record.getParagraphId()).ifPresent(p -> {
+                enriched.put("title", p.getTitle());
+                enriched.put("sourcePath", p.getSourcePath());
+                enriched.put("pageStart", p.getPageStart());
+                enriched.put("pageEnd", p.getPageEnd());
+            });
+        }
+
+        return enriched;
+    }
+}

+ 90 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihGraphService.java

@@ -0,0 +1,90 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.dto.response.GraphPathResult;
+import com.pharmacopoeia.nihaisha.entity.NihGraphEntity;
+import com.pharmacopoeia.nihaisha.entity.NihRelation;
+import com.pharmacopoeia.nihaisha.repository.NihGraphEntityRepository;
+import com.pharmacopoeia.nihaisha.repository.NihRelationRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+public class NihGraphService {
+
+    private final NihGraphEntityRepository entityRepository;
+    private final NihRelationRepository relationRepository;
+
+    public NihGraphService(NihGraphEntityRepository entityRepository,
+                            NihRelationRepository relationRepository) {
+        this.entityRepository = entityRepository;
+        this.relationRepository = relationRepository;
+    }
+
+    /**
+     * 根据实体名称搜索图谱
+     */
+    public GraphPathResult searchGraph(String entityName, int maxDepth) {
+        // 1. 模糊匹配实体
+        String normalizedKey = entityName.toLowerCase().trim();
+        List<NihGraphEntity> entities = new ArrayList<>();
+
+        entityRepository.findByNormalizedKey(normalizedKey).ifPresent(entities::add);
+
+        if (entities.isEmpty()) {
+            // 尝试模糊搜索
+            List<NihGraphEntity> all = entityRepository.findAll();
+            for (NihGraphEntity e : all) {
+                if (e.getNormalizedKey() != null && e.getNormalizedKey().contains(normalizedKey)) {
+                    entities.add(e);
+                }
+            }
+        }
+
+        Map<String, Object> entityMap = new HashMap<>();
+        List<Map<String, Object>> relationList = new ArrayList<>();
+
+        if (!entities.isEmpty()) {
+            NihGraphEntity entity = entities.get(0);
+            entityMap.put("entityId", entity.getEntityId());
+            entityMap.put("canonicalName", entity.getCanonicalName());
+            entityMap.put("entityType", entity.getEntityType());
+            entityMap.put("normalizedKey", entity.getNormalizedKey());
+
+            // 查找直接关联关系
+            List<NihRelation> relations = relationRepository
+                    .findBySubjectEntityIdOrObjectEntityId(entity.getEntityId(), entity.getEntityId());
+
+            for (NihRelation rel : relations) {
+                Map<String, Object> relMap = new HashMap<>();
+                relMap.put("relationId", rel.getRelationId());
+                relMap.put("predicate", rel.getPredicate());
+                relMap.put("evidenceQuote", rel.getEvidenceQuote());
+                relMap.put("confidence", rel.getConfidence());
+                relMap.put("sourceLayer", rel.getSourceLayer());
+                relMap.put("reviewStatus", rel.getReviewStatus());
+
+                // 解析关联实体
+                String targetId = rel.getSubjectEntityId().equals(entity.getEntityId())
+                        ? rel.getObjectEntityId() : rel.getSubjectEntityId();
+                if (targetId != null) {
+                    entityRepository.findById(targetId).ifPresent(target -> {
+                        Map<String, Object> targetMap = new HashMap<>();
+                        targetMap.put("entityId", target.getEntityId());
+                        targetMap.put("canonicalName", target.getCanonicalName());
+                        targetMap.put("entityType", target.getEntityType());
+                        relMap.put("targetEntity", targetMap);
+                    });
+                }
+
+                relMap.put("literalValue", rel.getLiteralValue());
+                relationList.add(relMap);
+            }
+        }
+
+        return GraphPathResult.builder()
+                .entity(entityMap)
+                .relations(relationList)
+                .build();
+    }
+}

+ 74 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihGuideNodeService.java

@@ -0,0 +1,74 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.dto.response.GuideNodeResult;
+import com.pharmacopoeia.nihaisha.repository.NihGuideNodeRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class NihGuideNodeService {
+
+    private final NihGuideNodeRepository repository;
+
+    public NihGuideNodeService(NihGuideNodeRepository repository) {
+        this.repository = repository;
+    }
+
+    /**
+     * 全文搜索导航节点
+     */
+    public List<GuideNodeResult> search(String query, int limit) {
+        return repository.searchFullText(query, limit).stream()
+                .map(this::mapToResult)
+                .toList();
+    }
+
+    /**
+     * 按类型列出导航节点
+     */
+    public List<GuideNodeResult> listByType(String nodeType) {
+        return repository.findByNodeType(nodeType).stream()
+                .map(this::mapToResult)
+                .toList();
+    }
+
+    /**
+     * 按标签列出导航节点
+     */
+    public List<GuideNodeResult> listByBadge(String badge) {
+        return repository.findByBadge(badge).stream()
+                .map(this::mapToResult)
+                .toList();
+    }
+
+    private GuideNodeResult mapToResult(Object[] row) {
+        return GuideNodeResult.builder()
+                .nodeId((String) row[0])
+                .parentId((String) row[1])
+                .nodeType((String) row[2])
+                .label((String) row[3])
+                .badge((String) row[4])
+                .path((String) row[5])
+                .content((String) row[6])
+                .pageStart((Integer) row[9])
+                .pageEnd((Integer) row[10])
+                .evidenceQuote((String) row[11])
+                .build();
+    }
+
+    private GuideNodeResult mapToResult(com.pharmacopoeia.nihaisha.entity.NihGuideNode node) {
+        return GuideNodeResult.builder()
+                .nodeId(node.getNodeId())
+                .parentId(node.getParentId())
+                .nodeType(node.getNodeType())
+                .label(node.getLabel())
+                .badge(node.getBadge())
+                .path(node.getPath())
+                .content(node.getContent())
+                .pageStart(node.getPageStart())
+                .pageEnd(node.getPageEnd())
+                .evidenceQuote(node.getEvidenceQuote())
+                .build();
+    }
+}

+ 223 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihHybridSearchService.java

@@ -0,0 +1,223 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.config.NihaishaProperties;
+import com.pharmacopoeia.nihaisha.dto.response.SearchHit;
+import com.pharmacopoeia.nihaisha.repository.NihVectorEmbeddingRepository;
+import com.pharmacopoeia.nihaisha.repository.NihParagraphRepository;
+import com.pharmacopoeia.nihaisha.repository.NihKnowledgeUnitRepository;
+import com.pharmacopoeia.nihaisha.repository.NihRelationRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+
+/**
+ * 混合搜索服务 — 四通道并行检索 + RRF 融合
+ */
+@Service
+public class NihHybridSearchService {
+
+    private static final Logger log = LoggerFactory.getLogger(NihHybridSearchService.class);
+
+    private final NihVectorEmbeddingRepository vectorEmbeddingRepository;
+    private final NihParagraphRepository paragraphRepository;
+    private final NihKnowledgeUnitRepository knowledgeUnitRepository;
+    private final NihRelationRepository relationRepository;
+    private final NihEmbeddingService embeddingService;
+    private final NihRerankerService rerankerService;
+    private final NihaishaProperties properties;
+
+    public NihHybridSearchService(NihVectorEmbeddingRepository vectorEmbeddingRepository,
+                                   NihParagraphRepository paragraphRepository,
+                                   NihKnowledgeUnitRepository knowledgeUnitRepository,
+                                   NihRelationRepository relationRepository,
+                                   NihEmbeddingService embeddingService,
+                                   NihRerankerService rerankerService,
+                                   NihaishaProperties properties) {
+        this.vectorEmbeddingRepository = vectorEmbeddingRepository;
+        this.paragraphRepository = paragraphRepository;
+        this.knowledgeUnitRepository = knowledgeUnitRepository;
+        this.relationRepository = relationRepository;
+        this.embeddingService = embeddingService;
+        this.rerankerService = rerankerService;
+        this.properties = properties;
+    }
+
+    /**
+     * 混合搜索(四通道并行 + RRF 融合 + Reranker 重排序)
+     */
+    public Map<String, Object> searchHybrid(String query, List<String> channels, float[] providedVector) {
+        long start = System.currentTimeMillis();
+        int limit = properties.getSearch().getDefaultLimit();
+        int rrfK = properties.getSearch().getRrfK();
+        int topK = limit * 3;  // 每个通道多取一些用于融合
+
+        Set<String> enabledChannels = channels == null || channels.isEmpty()
+                ? Set.of("vector", "text", "knowledge", "graph")
+                : new HashSet<>(channels);
+
+        // 1. 本地 ONNX Runtime 生成 query embedding(如果没有提供)
+        CompletableFuture<List<SearchHit>> vectorFuture;
+        if (enabledChannels.contains("vector") && embeddingService.isAvailable()) {
+            float[] queryVector = providedVector;
+            if (queryVector == null || queryVector.length != 1024) {
+                var optVec = embeddingService.embed(query);
+                queryVector = optVec.orElse(null);
+            }
+            String vectorStr = queryVector != null ? embeddingService.toPgVectorString(queryVector) : null;
+            vectorFuture = CompletableFuture.supplyAsync(() ->
+                    vectorStr != null ? mapVectorResults(vectorEmbeddingRepository.searchByVector(vectorStr, topK)) : List.of());
+        } else {
+            vectorFuture = CompletableFuture.completedFuture(List.of());
+        }
+
+        // 2. 全文搜索
+        var textFuture = CompletableFuture.supplyAsync(() ->
+                enabledChannels.contains("text")
+                        ? mapTextResults(paragraphRepository.searchByTsvector(query, topK))
+                        : List.of());
+
+        // 3. 知识三元组搜索
+        var knowledgeFuture = CompletableFuture.supplyAsync(() ->
+                enabledChannels.contains("knowledge")
+                        ? mapKnowledgeResults(knowledgeUnitRepository.searchFullText(query, topK))
+                        : List.of());
+
+        // 4. 知识图谱搜索
+        var graphFuture = CompletableFuture.supplyAsync(() ->
+                enabledChannels.contains("graph")
+                        ? mapGraphResults(relationRepository.searchGraphByEntityKey(query, topK))
+                        : List.of());
+
+        // 等待全部完成
+        var vectorResults = vectorFuture.join();
+        var textResults = textFuture.join();
+        var knowledgeResults = knowledgeFuture.join();
+        var graphResults = graphFuture.join();
+
+        // 5. RRF 融合
+        List<SearchHit> fused = fuseRrf(vectorResults, textResults, knowledgeResults, graphResults, rrfK);
+
+        // 6. Reranker 重排序
+        if (rerankerService.isAvailable() && fused.size() > 1) {
+            fused = rerankerService.rerank(query, fused);
+        }
+
+        if (fused.size() > limit) {
+            fused = fused.subList(0, limit);
+        }
+
+        long tookMs = System.currentTimeMillis() - start;
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("results", fused);
+        result.put("totalHits", fused.size());
+        result.put("tookMs", tookMs);
+        result.put("channelsUsed", enabledChannels.stream().sorted().toList());
+
+        log.info("混合搜索完成: query={}, channels={}, results={}, tookMs={}",
+                query.length() > 50 ? query.substring(0, 50) + "..." : query,
+                enabledChannels, fused.size(), tookMs);
+
+        return result;
+    }
+
+    /**
+     * Reciprocal Rank Fusion
+     */
+    List<SearchHit> fuseRrf(List<SearchHit> vector, List<SearchHit> text,
+                              List<SearchHit> knowledge, List<SearchHit> graph, int k) {
+
+        // 按 paragraph_id 聚合,记录各通道排名
+        Map<String, Map<String, Integer>> channelRanks = new HashMap<>();
+        Map<String, SearchHit> bestHit = new HashMap<>();
+
+        addChannelRanks(channelRanks, bestHit, vector, "vector");
+        addChannelRanks(channelRanks, bestHit, text, "text");
+        addChannelRanks(channelRanks, bestHit, knowledge, "knowledge");
+        addChannelRanks(channelRanks, bestHit, graph, "graph");
+
+        // 计算 RRF 分数
+        List<Map.Entry<String, Map<String, Integer>>> entries = new ArrayList<>(channelRanks.entrySet());
+        entries.sort((a, b) -> {
+            double scoreA = calcRrfScore(a.getValue(), k);
+            double scoreB = calcRrfScore(b.getValue(), k);
+            return Double.compare(scoreB, scoreA);
+        });
+
+        return entries.stream().map(entry -> {
+            SearchHit hit = bestHit.get(entry.getKey());
+            Map<String, SearchHit.ChannelContribution> contributions = new HashMap<>();
+            entry.getValue().forEach((ch, rank) ->
+                    contributions.put(ch, SearchHit.ChannelContribution.builder()
+                            .rank(rank).score(1.0 / (k + rank)).build()));
+            hit.setRrfScore(calcRrfScore(entry.getValue(), k));
+            hit.setChannelContributions(contributions);
+            return hit;
+        }).toList();
+    }
+
+    private void addChannelRanks(Map<String, Map<String, Integer>> channelRanks,
+                                  Map<String, SearchHit> bestHit,
+                                  List<SearchHit> hits, String channel) {
+        for (int i = 0; i < hits.size(); i++) {
+            SearchHit hit = hits.get(i);
+            String key = hit.getParagraphId() != null ? hit.getParagraphId() : hit.getUnitId();
+            channelRanks.computeIfAbsent(key, k -> new HashMap<>()).put(channel, i + 1);
+            bestHit.putIfAbsent(key, hit);
+        }
+    }
+
+    private double calcRrfScore(Map<String, Integer> ranks, int k) {
+        return ranks.values().stream().mapToDouble(r -> 1.0 / (k + r)).sum();
+    }
+
+    // --- Result mappers ---
+
+    private List<SearchHit> mapVectorResults(List<Object[]> rows) {
+        return rows.stream().map(row -> SearchHit.builder()
+                .unitId((String) row[0])
+                .paragraphId((String) row[1])
+                .unitType((String) row[2])
+                .text((String) row[3])
+                .weight((Double) row[4])
+                .title((String) row[5])
+                .sourcePath((String) row[6])
+                .pageStart((Integer) row[7])
+                .pageEnd((Integer) row[8])
+                .distance((Double) row[10])
+                .build()).toList();
+    }
+
+    private List<SearchHit> mapTextResults(List<Object[]> rows) {
+        return rows.stream().map(row -> SearchHit.builder()
+                .paragraphId((String) row[0])
+                .title((String) row[4])
+                .text((String) row[6])
+                .sourcePath((String) row[2])
+                .pageStart((Integer) row[5])
+                .build()).toList();
+    }
+
+    private List<SearchHit> mapKnowledgeResults(List<Object[]> rows) {
+        return rows.stream().map(row -> SearchHit.builder()
+                .paragraphId((String) row[2])
+                .title((String) row[6])
+                .text((String) row[10])
+                .sourcePath((String) row[4])
+                .pageStart((Integer) row[7])
+                .pageEnd((Integer) row[8])
+                .build()).toList();
+    }
+
+    private List<SearchHit> mapGraphResults(List<Object[]> rows) {
+        return rows.stream().map(row -> SearchHit.builder()
+                .paragraphId(null)
+                .title(row[8] != null ? row[8].toString() : "")
+                .text(row[5] != null ? row[5].toString() : "")
+                .build()).toList();
+    }
+}

+ 69 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihKnowledgeService.java

@@ -0,0 +1,69 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.dto.response.KnowledgeResult;
+import com.pharmacopoeia.nihaisha.entity.NihKnowledgeUnit;
+import com.pharmacopoeia.nihaisha.repository.NihKnowledgeUnitRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+@Service
+public class NihKnowledgeService {
+
+    private final NihKnowledgeUnitRepository repository;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public NihKnowledgeService(NihKnowledgeUnitRepository repository) {
+        this.repository = repository;
+    }
+
+    public Optional<NihKnowledgeUnit> getById(String id) {
+        return repository.findById(id);
+    }
+
+    public List<NihKnowledgeUnit> getByType(String unitType) {
+        return repository.findByUnitTypeOrderByConfidenceDesc(unitType);
+    }
+
+    /**
+     * 全文搜索知识三元组
+     */
+    public List<KnowledgeResult> search(String query, String unitType, int limit) {
+        List<Object[]> rows;
+        if (unitType != null && !unitType.isEmpty()) {
+            rows = repository.searchFullTextByType(query, unitType, limit);
+        } else {
+            rows = repository.searchFullText(query, limit);
+        }
+
+        return rows.stream().map(this::mapToResult).toList();
+    }
+
+    @SuppressWarnings("unchecked")
+    private KnowledgeResult mapToResult(Object[] row) {
+        return KnowledgeResult.builder()
+                .knowledgeUnitId((String) row[0])
+                .paragraphId((String) row[1])
+                .unitType((String) row[7])
+                .subject((String) row[8])
+                .predicate((String) row[9])
+                .object((String) row[10])
+                .attributes(parseJson((String) row[11]))
+                .evidenceQuote((String) row[12])
+                .confidence((Double) row[13])
+                .pageStart((Integer) row[5])
+                .pageEnd((Integer) row[6])
+                .title((String) row[4])
+                .sourcePath((String) row[3])
+                .build();
+    }
+
+    private Map<String, Object> parseJson(String json) {
+        try {
+            return objectMapper.readValue(json, Map.class);
+        } catch (Exception e) {
+            return Map.of();
+        }
+    }
+}

+ 139 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihQueryRewriterService.java

@@ -0,0 +1,139 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.config.NihaishaProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.client.reactive.ReactorClientHttpConnector;
+import org.springframework.stereotype.Service;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+import reactor.netty.http.client.HttpClient;
+
+import java.time.Duration;
+import java.util.*;
+
+/**
+ * 查询改写服务 — 本地 Ollama 主路径 + 百炼 API 降级
+ */
+@Service
+public class NihQueryRewriterService {
+
+    private static final Logger log = LoggerFactory.getLogger(NihQueryRewriterService.class);
+
+    private final NihaishaProperties properties;
+    private final WebClient webClient;
+
+    public NihQueryRewriterService(NihaishaProperties properties) {
+        this.properties = properties;
+        this.webClient = WebClient.builder()
+                .clientConnector(new ReactorClientHttpConnector(
+                        HttpClient.create().responseTimeout(Duration.ofSeconds(30))))
+                .build();
+    }
+
+    /**
+     * 改写查询 — 调用 LLM 生成多个变体
+     * 主路径:本地 Ollama,降级:百炼 API
+     */
+    public List<String> rewriteQuery(String originalQuery) {
+        var primary = properties.getLlm().getPrimary();
+        var fallback = properties.getLlm().getFallback();
+
+        // 尝试主路径(本地 Ollama)
+        if (primary.getUrl() != null && !primary.getUrl().isEmpty()) {
+            try {
+                return callLlm(primary.getUrl(), primary.getApiKey(), primary.getModel(), originalQuery);
+            } catch (Exception e) {
+                log.warn("主 LLM (Ollama) 不可用: {}, 降级到百炼 API", e.getMessage());
+            }
+        }
+
+        // 降级路径(百炼 API)
+        if (fallback.getUrl() != null && !fallback.getUrl().isEmpty()
+                && fallback.getApiKey() != null && !fallback.getApiKey().isEmpty()) {
+            try {
+                return callLlm(fallback.getUrl(), fallback.getApiKey(), fallback.getModel(), originalQuery);
+            } catch (Exception e) {
+                log.warn("降级 LLM (百炼) 也不可用: {}, 返回原始查询", e.getMessage());
+            }
+        }
+
+        return List.of(originalQuery);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> callLlm(String url, String apiKey, String model, String query) {
+        Map<String, Object> body = buildRequestBody(model, query);
+
+        Map<String, Object> response;
+        try {
+            var request = webClient.post()
+                    .uri(url + "/chat/completions")
+                    .header("Content-Type", "application/json");
+
+            if (apiKey != null && !apiKey.isEmpty()) {
+                request.header("Authorization", "Bearer " + apiKey);
+            }
+
+            response = request.bodyValue(body)
+                    .retrieve()
+                    .bodyToMono(Map.class)
+                    .block(properties.getLlm().getTimeout());
+        } catch (WebClientResponseException e) {
+            throw new RuntimeException("LLM API 调用失败: " + e.getStatusCode() + " " + e.getResponseBodyAsString());
+        } catch (Exception e) {
+            throw new RuntimeException("LLM API 调用失败: " + e.getMessage());
+        }
+
+        if (response == null || !response.containsKey("choices")) {
+            return List.of(query);
+        }
+
+        List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
+        if (choices.isEmpty()) return List.of(query);
+
+        Map<String, Object> choice = choices.get(0);
+        Map<String, Object> message = (Map<String, Object>) choice.get("message");
+        String content = (String) message.get("content");
+
+        // 解析 LLM 返回的多个查询变体(按行分割)
+        List<String> rewrites = new ArrayList<>();
+        rewrites.add(query);  // 始终包含原始查询
+        if (content != null) {
+            for (String line : content.split("\n")) {
+                String trimmed = line.replaceAll("^[\\d\\.\\-\\* ]+", "").trim();
+                if (!trimmed.isEmpty() && !trimmed.equals(query) && trimmed.length() < 2000) {
+                    rewrites.add(trimmed);
+                }
+            }
+        }
+
+        log.debug("查询改写: {} -> {} 个变体", query.length() > 30 ? query.substring(0, 30) + "..." : query, rewrites.size());
+        return rewrites;
+    }
+
+    private Map<String, Object> buildRequestBody(String model, String query) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("model", model);
+
+        List<Map<String, Object>> messages = new ArrayList<>();
+
+        Map<String, Object> systemMsg = new HashMap<>();
+        systemMsg.put("role", "system");
+        systemMsg.put("content", """
+            你是中医检索专家。请将用户的问题改写为2-3个语义相同但表述不同的查询,
+            用于提高检索召回率。每个改写一行,不要编号,不要解释,只输出查询文本。""");
+        messages.add(systemMsg);
+
+        Map<String, Object> userMsg = new HashMap<>();
+        userMsg.put("role", "user");
+        userMsg.put("content", query);
+        messages.add(userMsg);
+
+        body.put("messages", messages);
+        body.put("temperature", 0.3);
+        body.put("max_tokens", 200);
+
+        return body;
+    }
+}

+ 55 - 0
backend-java/src/main/java/com/pharmacopoeia/nihaisha/service/NihRerankerService.java

@@ -0,0 +1,55 @@
+package com.pharmacopoeia.nihaisha.service;
+
+import com.pharmacopoeia.nihaisha.dto.response.SearchHit;
+import com.pharmacopoeia.nihaisha.engine.OnnxRerankerEngine;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Reranker 服务 — 封装 ONNX Runtime BGE-Reranker 本地推理
+ */
+@Service
+public class NihRerankerService {
+
+    private static final Logger log = LoggerFactory.getLogger(NihRerankerService.class);
+
+    private final OnnxRerankerEngine engine;
+
+    public NihRerankerService(OnnxRerankerEngine engine) {
+        this.engine = engine;
+    }
+
+    /**
+     * 对搜索结果重排序
+     * @return 排序后的结果(原顺序不变如果重排序失败)
+     */
+    public List<SearchHit> rerank(String query, List<SearchHit> hits) {
+        if (hits.size() <= 1) return hits;
+
+        List<String> documents = hits.stream()
+                .map(h -> h.getText() != null ? h.getText() : "")
+                .toList();
+
+        Optional<int[]> orderOpt = engine.rerank(query, documents);
+        if (orderOpt.isEmpty()) {
+            log.warn("Reranker 不可用,返回原始排序");
+            return hits;
+        }
+
+        int[] order = orderOpt.get();
+        List<SearchHit> reranked = new ArrayList<>(hits.size());
+        for (int idx : order) {
+            if (idx < hits.size()) reranked.add(hits.get(idx));
+        }
+        return reranked;
+    }
+
+    public boolean isAvailable() {
+        return engine.isLoaded();
+    }
+}

+ 2 - 1
backend-java/src/main/java/com/pharmacopoeia/security/SecurityConfig.java

@@ -39,7 +39,8 @@ public class SecurityConfig {
 
             // 生产模式:所有 /api/** 需要鉴权,公开路径仅限登录和静态资源
         http.authorizeHttpRequests(auth -> auth
-                .requestMatchers("/health", "/api/v1/auth/**", "/api/v1/yaodian/**").permitAll()
+                .requestMatchers("/health", "/api/v1/auth/**", "/api/v1/yaodian/**",
+                        "/api/v1/nihaisha/health").permitAll()
                 .requestMatchers("/static/**", "/").permitAll()
                 .requestMatchers("/api/**").authenticated()
                 .anyRequest().permitAll());

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

@@ -93,6 +93,26 @@ rate-limit:
   per-hour: 999999    # 极大=不限
   per-day: 999999     # 极大=不限
 
+# ============================================
+# 倪海厦 RAG 配置
+# ============================================
+nihaisha:
+  onnx:
+    model-path: ${ONNX_MODEL_PATH:models}
+  llm:
+    primary:
+      url: ${LLM_PRIMARY_URL:http://localhost:11434/v1}
+      model: ${LLM_PRIMARY_MODEL:qwen2.5:14b}
+    fallback:
+      url: ${LLM_FALLBACK_URL:https://dashscope.aliyuncs.com/compatible-mode/v1}
+      api-key: ${DASHSCOPE_API_KEY:}
+      model: ${LLM_FALLBACK_MODEL:qwen-plus}
+    timeout: 30s
+  search:
+    default-limit: 20
+    max-limit: 100
+    rrf-k: 60
+
 logging:
   level:
     com.pharmacopoeia: INFO

+ 5 - 0
backend-java/src/main/resources/db/migration/V3__init_nihaisha_extensions.sql

@@ -0,0 +1,5 @@
+-- V3__init_nihaisha_extensions.sql
+-- 倪海厦 RAG 依赖的 PostgreSQL 扩展
+
+CREATE EXTENSION IF NOT EXISTS vector;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;

+ 52 - 0
backend-java/src/main/resources/db/migration/V4__init_nihaisha_core.sql

@@ -0,0 +1,52 @@
+-- V4__init_nihaisha_core.sql
+-- 倪海厦 RAG 核心表
+
+-- 系统元数据
+CREATE TABLE nih_meta (
+    key    TEXT PRIMARY KEY,
+    value  TEXT NOT NULL
+);
+
+-- 文档元数据
+CREATE TABLE nih_documents (
+    document_id          TEXT PRIMARY KEY,
+    logical_source_path  TEXT,
+    canonical_title      TEXT,
+    source_layer         TEXT
+);
+COMMENT ON COLUMN nih_documents.source_layer IS 'course_primary | classic_primary | reference_secondary';
+
+-- 段落(核心枢纽表)
+CREATE TABLE nih_paragraphs (
+    paragraph_id  TEXT PRIMARY KEY,
+    doc_id        TEXT    NOT NULL,
+    source_path   TEXT    NOT NULL,
+    title         TEXT    NOT NULL,
+    page_start    INTEGER NOT NULL,
+    page_end      INTEGER NOT NULL,
+    text          TEXT    NOT NULL
+);
+CREATE INDEX idx_nih_paragraphs_doc_id ON nih_paragraphs(doc_id);
+CREATE INDEX idx_nih_paragraphs_title  ON nih_paragraphs(title);
+
+-- 检索单元(元数据)
+CREATE TABLE nih_retrieval_units (
+    unit_id              TEXT PRIMARY KEY,
+    paragraph_id         TEXT    NOT NULL REFERENCES nih_paragraphs(paragraph_id),
+    doc_id               TEXT    NOT NULL,
+    unit_type            TEXT    NOT NULL,
+    text                 TEXT    NOT NULL,
+    text_for_embedding   TEXT    NOT NULL,
+    sentence_start       INTEGER NOT NULL,
+    sentence_end         INTEGER NOT NULL,
+    weight               REAL    NOT NULL DEFAULT 1.0
+);
+CREATE INDEX idx_nih_retrieval_units_paragraph_id ON nih_retrieval_units(paragraph_id);
+CREATE INDEX idx_nih_retrieval_units_unit_type    ON nih_retrieval_units(unit_type);
+CREATE INDEX idx_nih_retrieval_units_doc_id       ON nih_retrieval_units(doc_id);
+
+-- 向量嵌入(pgvector)
+CREATE TABLE nih_vector_embeddings (
+    unit_id      TEXT PRIMARY KEY REFERENCES nih_retrieval_units(unit_id),
+    embedding    vector(1024) NOT NULL
+);

+ 45 - 0
backend-java/src/main/resources/db/migration/V5__init_nihaisha_knowledge.sql

@@ -0,0 +1,45 @@
+-- V5__init_nihaisha_knowledge.sql
+-- 知识三元组 + 导航节点
+
+CREATE TABLE nih_knowledge_units (
+    knowledge_unit_id  TEXT PRIMARY KEY,
+    paragraph_id       TEXT    NOT NULL REFERENCES nih_paragraphs(paragraph_id),
+    doc_id             TEXT    NOT NULL,
+    source_path        TEXT    NOT NULL,
+    title              TEXT    NOT NULL,
+    page_start         INTEGER NOT NULL,
+    page_end           INTEGER NOT NULL,
+    unit_type          TEXT    NOT NULL,
+    subject            TEXT    NOT NULL,
+    predicate          TEXT    NOT NULL,
+    object             TEXT    NOT NULL,
+    attributes_json    JSONB   NOT NULL DEFAULT '{}',
+    evidence_quote     TEXT    NOT NULL,
+    confidence         REAL    NOT NULL DEFAULT 0.0,
+    extractor_version  TEXT    NOT NULL
+);
+CREATE INDEX idx_nih_knowledge_units_paragraph_id ON nih_knowledge_units(paragraph_id);
+CREATE INDEX idx_nih_knowledge_units_unit_type    ON nih_knowledge_units(unit_type);
+CREATE INDEX idx_nih_knowledge_units_subject      ON nih_knowledge_units(subject);
+CREATE INDEX idx_nih_knowledge_units_confidence   ON nih_knowledge_units(confidence DESC);
+CREATE INDEX idx_nih_knowledge_units_attributes   ON nih_knowledge_units USING GIN (attributes_json);
+
+CREATE TABLE nih_guide_nodes (
+    node_id         TEXT PRIMARY KEY,
+    parent_id       TEXT    NOT NULL DEFAULT '',
+    node_type       TEXT    NOT NULL,
+    label           TEXT    NOT NULL,
+    badge           TEXT    NOT NULL,
+    path            TEXT    NOT NULL,
+    content         TEXT    NOT NULL,
+    search_text     TEXT    NOT NULL,
+    paragraph_id    TEXT    NOT NULL DEFAULT '',
+    source_path     TEXT    NOT NULL DEFAULT '',
+    title           TEXT    NOT NULL DEFAULT '',
+    page_start      INTEGER NOT NULL DEFAULT 0,
+    page_end        INTEGER NOT NULL DEFAULT 0,
+    evidence_quote  TEXT    NOT NULL DEFAULT ''
+);
+CREATE INDEX idx_nih_guide_nodes_node_type ON nih_guide_nodes(node_type);
+CREATE INDEX idx_nih_guide_nodes_parent_id  ON nih_guide_nodes(parent_id);
+CREATE INDEX idx_nih_guide_nodes_badge      ON nih_guide_nodes(badge);

+ 56 - 0
backend-java/src/main/resources/db/migration/V6__init_nihaisha_graph.sql

@@ -0,0 +1,56 @@
+-- V6__init_nihaisha_graph.sql
+-- 知识图谱:证据记录 + 实体 + 关系
+
+CREATE TABLE nih_evidence_records (
+    evidence_id          TEXT PRIMARY KEY,
+    document_id          TEXT,
+    paragraph_id         TEXT,
+    locator              TEXT,
+    original_text        TEXT,
+    previous_evidence_id TEXT,
+    next_evidence_id     TEXT
+);
+CREATE INDEX idx_nih_evidence_records_document_id  ON nih_evidence_records(document_id);
+CREATE INDEX idx_nih_evidence_records_paragraph_id ON nih_evidence_records(paragraph_id);
+
+CREATE TABLE nih_entities (
+    entity_id       TEXT PRIMARY KEY,
+    entity_type     TEXT,
+    canonical_name  TEXT,
+    normalized_key  TEXT
+);
+CREATE INDEX idx_nih_entities_normalized_key ON nih_entities(normalized_key);
+CREATE INDEX idx_nih_entities_entity_type    ON nih_entities(entity_type);
+CREATE UNIQUE INDEX idx_nih_entities_unique_key ON nih_entities(normalized_key, entity_type);
+
+CREATE TABLE nih_relations (
+    relation_id         TEXT PRIMARY KEY,
+    subject_entity_id   TEXT NOT NULL REFERENCES nih_entities(entity_id),
+    predicate           TEXT,
+    object_entity_id    TEXT REFERENCES nih_entities(entity_id),
+    literal_value       TEXT,
+    evidence_id         TEXT REFERENCES nih_evidence_records(evidence_id),
+    evidence_quote      TEXT,
+    source_layer        TEXT,
+    confidence          REAL DEFAULT 0.0,
+    extraction_method   TEXT,
+    extractor_version   TEXT,
+    review_status       TEXT DEFAULT 'auto_accepted'
+);
+CREATE INDEX idx_nih_relations_subject   ON nih_relations(subject_entity_id);
+CREATE INDEX idx_nih_relations_object    ON nih_relations(object_entity_id);
+CREATE INDEX idx_nih_relations_predicate ON nih_relations(predicate);
+CREATE INDEX idx_nih_relations_evidence  ON nih_relations(evidence_id);
+CREATE INDEX idx_nih_relations_review    ON nih_relations(review_status);
+COMMENT ON COLUMN nih_relations.review_status IS 'auto_accepted | reviewed | rejected';
+
+-- 可选评测用例表
+CREATE TABLE nih_eval_cases (
+    case_id                  TEXT PRIMARY KEY,
+    query                    TEXT    NOT NULL,
+    task_type                TEXT    NOT NULL,
+    relevant_paragraph_ids   TEXT[]  NOT NULL DEFAULT '{}',
+    forbidden_paragraph_ids  TEXT[]  NOT NULL DEFAULT '{}'
+);
+CREATE INDEX idx_nih_eval_cases_task_type ON nih_eval_cases(task_type);
+COMMENT ON COLUMN nih_eval_cases.task_type IS 'dosage | source_lookup | comparison | entity_fact';

+ 40 - 0
backend-java/src/main/resources/db/migration/V7__init_nihaisha_fulltext.sql

@@ -0,0 +1,40 @@
+-- V7__init_nihaisha_fulltext.sql
+-- 全文搜索索引:tsvector + trigram + 向量索引
+
+-- ===== paragraphs 全文搜索 =====
+ALTER TABLE nih_paragraphs ADD COLUMN text_tsv tsvector
+    GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(text, ''))) STORED;
+CREATE INDEX idx_nih_paragraphs_tsv ON nih_paragraphs USING GIN (text_tsv);
+CREATE INDEX idx_nih_paragraphs_text_trgm ON nih_paragraphs USING GIN (text gin_trgm_ops);
+CREATE INDEX idx_nih_paragraphs_title_trgm ON nih_paragraphs USING GIN (title gin_trgm_ops);
+
+-- ===== knowledge_units 全文搜索 =====
+ALTER TABLE nih_knowledge_units ADD COLUMN search_tsv tsvector
+    GENERATED ALWAYS AS (
+        to_tsvector('simple',
+            coalesce(subject, '') || ' ' ||
+            coalesce(predicate, '') || ' ' ||
+            coalesce(object, '') || ' ' ||
+            coalesce(evidence_quote, '')
+        )
+    ) STORED;
+CREATE INDEX idx_nih_knowledge_units_tsv ON nih_knowledge_units USING GIN (search_tsv);
+CREATE INDEX idx_nih_knowledge_units_subject_trgm ON nih_knowledge_units USING GIN (subject gin_trgm_ops);
+CREATE INDEX idx_nih_knowledge_units_object_trgm  ON nih_knowledge_units USING GIN (object gin_trgm_ops);
+
+-- ===== guide_nodes 全文搜索 =====
+ALTER TABLE nih_guide_nodes ADD COLUMN search_text_tsv tsvector
+    GENERATED ALWAYS AS (
+        to_tsvector('simple',
+            coalesce(label, '') || ' ' ||
+            coalesce(path, '') || ' ' ||
+            coalesce(content, '') || ' ' ||
+            coalesce(search_text, '')
+        )
+    ) STORED;
+CREATE INDEX idx_nih_guide_nodes_tsv ON nih_guide_nodes USING GIN (search_text_tsv);
+CREATE INDEX idx_nih_guide_nodes_label_trgm ON nih_guide_nodes USING GIN (label gin_trgm_ops);
+
+-- ===== 向量索引 (HNSW, 与药典 drug_chunks 保持一致) =====
+CREATE INDEX idx_nih_vector_embeddings_hnsw ON nih_vector_embeddings
+    USING hnsw (embedding vector_cosine_ops);

+ 614 - 0
docs/nihaisha-deployment-analysis.md

@@ -0,0 +1,614 @@
+# Nihaisha RAG — 合并部署与水平扩容分析
+
+> **版本**: v1.0
+> **日期**: 2026-07-28
+> **目标**: 将倪海厦 RAG 代码合并到药典助手项目 `D:\project\AIyaodianzhushou`,分析部署方案和扩容路径
+
+---
+
+## 目录
+
+1. [现有项目对比](#1-现有项目对比)
+2. [合并策略](#2-合并策略)
+3. [代码合并方案](#3-代码合并方案)
+4. [部署拓扑](#4-部署拓扑)
+5. [水平扩容路径](#5-水平扩容路径)
+6. [数据库规划](#6-数据库规划)
+7. [API 设计](#7-api-设计)
+
+---
+
+## 1. 现有项目对比
+
+| 维度 | 药典助手 (AIyaodianzhushou) | 倪海厦 RAG (nihaisha-nishi-tcm) |
+|------|:---:|:---:|
+| **数据依赖** | 2025 年版中国药典 | 倪海厦中医课程资料(22 个 PDF)|
+| **框架** | Spring Boot 3.3 + JPA/Hibernate | 计划 Spring Boot 3.3 + MyBatis |
+| **Java** | 21 | 21 |
+| **数据库** | PostgreSQL 16 + pgvector | PostgreSQL 16 + pgvector(新) |
+| **缓存** | Redis 7 | Redis 7(可共用) |
+| **LLM** | 百炼 DashScope (Qwen) | Ollama 主 + 百炼降级 |
+| **向量嵌入** | DashScope text-embedding-v3 (1024维) | BGE-M3 ONNX Runtime 本地 (1024维) |
+| **重排序** | 规则型 Reranker(关键词+权重) | BGE-Reranker ONNX Runtime 本地 |
+| **鉴权** | Spring Security + JWT | 需共用药典的鉴权体系 |
+| **端口** | 9000 | 计划 8080 |
+| **构建** | 单模块 Maven | 计划单模块 Maven |
+| **部署** | Standalone JAR + supervisord + nginx | — |
+| **ORM** | JPA/Hibernate | MyBatis |
+
+### 关键冲突点
+
+| 冲突 | 分析 |
+|------|------|
+| **ORM 不一致** | 药典用 JPA,倪海厦计划用 MyBatis。同一项目混用两套 ORM 维护成本高 |
+| **Embedding 模型不同** | 药典用 DashScope API(远程),倪海厦用 ONNX(本地)。两个模型是独立的,不冲突 |
+| **端口不同** | 如果不合并,需要两个端口、两个 JAR、两套部署 |
+
+---
+
+## 2. 合并策略
+
+### 2.1 推荐方案:统一 ORM + 独立模块
+
+```
+AIyaodianzhushou/
+├── backend-java/                          # 现有项目(保持 JPA)
+│   ├── pom.xml
+│   ├── src/main/java/com/pharmacopoeia/
+│   │   ├── PharmacopoeiaApplication.java
+│   │   ├── entity/          # 药典实体(JPA)
+│   │   ├── repository/      # JPA Repository
+│   │   ├── service/         # 药典服务
+│   │   ├── controller/      # 药典 Controller
+│   │   ├── config/          # 共用配置
+│   │   ├── security/        # 共用鉴权
+│   │   │
+│   │   └── nihaisha/        # ★ 新增:倪海厦 RAG 子包
+│   │       ├── entity/      # 倪海厦实体(JPA,统一风格)
+│   │       ├── repository/  # JPA Repository
+│   │       ├── service/     # 倪海厦检索服务
+│   │       ├── controller/  # 倪海厦 Controller
+│   │       ├── engine/      # ONNX Runtime 引擎
+│   │       └── config/      # 倪海厦专属配置
+│   │
+│   └── src/main/resources/
+│       ├── application.yml
+│       └── db/migration/    # Flyway 迁移(新增倪海厦表)
+```
+
+### 2.2 为什么改 JPA 而不是 MyBatis
+
+| 维度 | 用 JPA(推荐) | 混用 MyBatis + JPA |
+|------|:---:|:---:|
+| 维护成本 | ✅ 一套规范,团队只学一个 | ❌ 两套规范,排查问题分裂 |
+| 代码一致性 | ✅ entity/repository 模式统一 | ❌ entity + mapper.xml 双模式 |
+| 连接池 | ✅ 共用 HikariCP | ✅ 可共用 |
+| 事务管理 | ✅ 统一 `@Transactional` | ⚠️ 需注意跨 ORM 事务 |
+| Spring Data 生态 | ✅ 审计、分页、Specification 统一 | ❌ 各用各的 |
+| 动态查询 | JPA Criteria / QueryDSL | MyBatis XML 更灵活(但药典已有 JPA) |
+
+**结论**:药典项目已经用 JPA 跑了生产,强行引入 MyBatis 会增加认知负担。倪海厦的 11 张表查询用 JPA + Native Query(向量搜索、全文搜索本来就是写 SQL 的)完全够用。
+
+### 2.3 共用 vs 独立
+
+| 共用 | 独立 |
+|------|------|
+| Spring Security + JWT 鉴权 | ONNX Runtime 引擎(BGE-M3 + Reranker) |
+| Redis 缓存 | 数据层(独立的表,不跨库 join) |
+| HikariCP 连接池 | Controller(独立路径 `/api/v1/nihaisha/`) |
+| Flyway 迁移 | 100 MB 日志文件(追加到 logback) |
+| 日志框架 (Logback) | |
+| 同一个 PostgreSQL 实例 | |
+| 同一个 Nginx 反向代理 | |
+| 同一个 supervisord 进程 | |
+
+---
+
+## 3. 代码合并方案
+
+### 3.1 目录结构
+
+```
+backend-java/
+├── pom.xml                                 # 加入 ONNX Runtime 依赖
+├── src/main/java/com/pharmacopoeia/
+│   ├── PharmacopoeiaApplication.java
+│   │
+│   ├── config/                             # 现有共用配置
+│   │   ├── SecurityConfig.java
+│   │   ├── WebConfig.java
+│   │   └── RedisConfig.java
+│   │
+│   ├── entity/                             # 药典实体 (不变)
+│   ├── repository/                         # 药典 Repository (不变)
+│   ├── service/                            # 药典服务 (不变)
+│   ├── controller/                         # 药典 Controller (不变)
+│   │
+│   ├── nihaisha/                           # ★ 倪海厦 RAG
+│   │   ├── entity/
+│   │   │   ├── NihMeta.java
+│   │   │   ├── NihDocument.java
+│   │   │   ├── NihParagraph.java
+│   │   │   ├── NihRetrievalUnit.java
+│   │   │   ├── NihVectorEmbedding.java
+│   │   │   ├── NihKnowledgeUnit.java
+│   │   │   ├── NihGuideNode.java
+│   │   │   ├── NihEvidenceRecord.java
+│   │   │   ├── NihGraphEntity.java
+│   │   │   └── NihRelation.java
+│   │   │
+│   │   ├── repository/
+│   │   │   ├── NihMetaRepository.java
+│   │   │   ├── NihDocumentRepository.java
+│   │   │   ├── NihParagraphRepository.java
+│   │   │   ├── NihRetrievalUnitRepository.java
+│   │   │   ├── NihVectorEmbeddingRepository.java
+│   │   │   ├── NihKnowledgeUnitRepository.java
+│   │   │   ├── NihGuideNodeRepository.java
+│   │   │   ├── NihEvidenceRecordRepository.java
+│   │   │   ├── NihGraphEntityRepository.java
+│   │   │   └── NihRelationRepository.java
+│   │   │
+│   │   ├── service/
+│   │   │   ├── NihSearchService.java           # 单通道检索
+│   │   │   ├── NihHybridSearchService.java     # 混合检索 + RRF
+│   │   │   ├── NihKnowledgeService.java        # 知识三元组
+│   │   │   ├── NihGraphService.java             # 知识图谱
+│   │   │   ├── NihEvidenceService.java          # 证据链
+│   │   │   ├── NihGuideNodeService.java         # 导航节点
+│   │   │   ├── NihQueryRewriterService.java     # 查询改写
+│   │   │   ├── NihEmbeddingService.java         # ONNX BGE-M3
+│   │   │   └── NihRerankerService.java          # ONNX BGE-Reranker
+│   │   │
+│   │   ├── controller/
+│   │   │   ├── NihSearchController.java     # /api/v1/nihaisha/search
+│   │   │   ├── NihKnowledgeController.java  # /api/v1/nihaisha/knowledge
+│   │   │   ├── NihGuideController.java      # /api/v1/nihaisha/guide
+│   │   │   ├── NihGraphController.java      # /api/v1/nihaisha/graph
+│   │   │   └── NihEvidenceController.java   # /api/v1/nihaisha/evidence
+│   │   │
+│   │   ├── engine/
+│   │   │   ├── OnnxEmbeddingEngine.java      # BGE-M3 ONNX 会话
+│   │   │   ├── OnnxRerankerEngine.java       # BGE-Reranker ONNX 会话
+│   │   │   └── BertTokenizer.java            # HuggingFace Tokenizer
+│   │   │
+│   │   ├── dto/
+│   │   │   ├── request/
+│   │   │   └── response/
+│   │   │
+│   │   ├── enums/
+│   │   │   ├── UnitType.java
+│   │   │   ├── KnowledgeUnitType.java
+│   │   │   └── ...
+│   │   │
+│   │   └── config/
+│   │       └── OnnxRuntimeConfig.java
+│   │
+│   └── security/                            # 共用鉴权(需补充倪海厦路径)
+│
+├── src/main/resources/
+│   ├── application.yml
+│   ├── application-dev.yml
+│   ├── application-prod.yml
+│   ├── logback-spring.xml
+│   └── db/migration/
+│       ├── V1__init_core.sql                # 现有药典表
+│       ├── V2__init_exam.sql
+│       ├── V3__init_nihaisha_extensions.sql  # ★ pgvector + pg_trgm
+│       ├── V4__init_nihaisha_core.sql        # ★ meta, documents, paragraphs
+│       ├── V5__init_nihaisha_retrieval.sql   # ★ retrieval_units, vector_embeddings
+│       ├── V6__init_nihaisha_knowledge.sql   # ★ knowledge_units, guide_nodes
+│       ├── V7__init_nihaisha_graph.sql       # ★ evidence_records, entities, relations
+│       └── V8__init_nihaisha_fulltext.sql    # ★ tsvector + trigram 索引
+│
+├── models/                                   # ONNX 模型文件
+│   ├── bge-m3-fp16/
+│   │   ├── model.onnx
+│   │   └── tokenizer.json
+│   └── bge-reranker-fp16/
+│       ├── model.onnx
+│       └── tokenizer.json
+│
+└── Dockerfile                                # ★ 新增:应用容器化
+```
+
+### 3.2 ORM 选择:JPA + Native Query
+
+向量搜索和全文搜索需要写原生 SQL,用 `@Query(nativeQuery=true)` 或 `JdbcTemplate`。药典项目已经在 `RetrieverService` 中这样做了(pgvector `<=>` 通过 JdbcTemplate 执行)。
+
+```java
+// NihVectorEmbeddingRepository.java
+public interface NihVectorEmbeddingRepository extends JpaRepository<NihVectorEmbedding, String> {
+
+    // pgvector 近似搜索 — 原生 SQL
+    @Query(value = """
+        SELECT ru.*, p.title, p.text, p.source_path, p.page_start, p.page_end,
+               ve.embedding <=> CAST(:queryVector AS vector) AS distance
+        FROM nih_vector_embeddings ve
+        JOIN nih_retrieval_units ru ON ru.unit_id = ve.unit_id
+        JOIN nih_paragraphs p ON p.paragraph_id = ru.paragraph_id
+        ORDER BY ve.embedding <=> CAST(:queryVector AS vector)
+        LIMIT :limit
+        """, nativeQuery = true)
+    List<Object[]> searchByVector(String queryVector, int limit);
+}
+```
+
+### 3.3 表名前缀
+
+为避免与药典现有表冲突,倪海厦表统一加 `nih_` 前缀:
+
+| 原始表名 | 实际表名 |
+|----------|----------|
+| `meta` | `nih_meta` |
+| `documents` | `nih_documents` |
+| `paragraphs` | `nih_paragraphs` |
+| `retrieval_units` | `nih_retrieval_units` |
+| `vector_embeddings` | `nih_vector_embeddings` |
+| `knowledge_units` | `nih_knowledge_units` |
+| `guide_nodes` | `nih_guide_nodes` |
+| `evidence_records` | `nih_evidence_records` |
+| `entities` | `nih_entities` |
+| `relations` | `nih_relations` |
+| `eval_cases` | `nih_eval_cases` |
+
+> 药典已有 `entities` 相关概念?检查后没有冲突,但仍加前缀隔离,便于未来分库。
+
+---
+
+## 4. 部署拓扑
+
+### 4.1 单机部署(当前)
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                     线上服务器                            │
+│                                                         │
+│  ┌──────────────────────────────────────────────────┐  │
+│  │                   Nginx (:80/:443)                 │  │
+│  │  /api/v1/chat/*     → localhost:9000 (药典对话)    │  │
+│  │  /api/v1/drug/*     → localhost:9000 (药典药品)    │  │
+│  │  /api/v1/nihaisha/* → localhost:9000 (倪海厦 RAG)  │  │
+│  │  /api/v1/exam/*     → localhost:9000 (药典考试)    │  │
+│  └──────────────────────┬───────────────────────────┘  │
+│                         │                               │
+│  ┌──────────────────────┴───────────────────────────┐  │
+│  │         pharmacopoeia-ai.jar (:9000)              │  │
+│  │                                                   │  │
+│  │  ┌─────────────┐  ┌────────────────────────────┐ │  │
+│  │  │ 药典服务      │  │ 倪海厦 RAG (nihaisha/)      │ │  │
+│  │  │ ├ Chat       │  │ ├ Search (四通道)          │ │  │
+│  │  │ ├ Drug       │  │ ├ Knowledge               │ │  │
+│  │  │ ├ Exam       │  │ ├ Graph                   │ │  │
+│  │  │ └ Admin      │  │ ├ Evidence                │ │  │
+│  │  └──────┬───────┘  │ └───────────┬──────────────┘ │  │
+│  │         │          │             │                 │  │
+│  │  ┌──────┴──────────┴─────────────┴──────────────┐ │  │
+│  │  │              共用基础设施                      │ │  │
+│  │  │  ├ Spring Security (JWT 鉴权)                 │ │  │
+│  │  │  ├ Redis (QA 缓存 + Embedding 缓存)           │ │  │
+│  │  │  ├ Flyway (数据库迁移)                        │ │  │
+│  │  │  ├ ONNX Runtime (BGE-M3 + Reranker)          │ │  │
+│  │  │  └ HikariCP (连接池)                          │ │  │
+│  │  └────────────────────┬──────────────────────────┘ │  │
+│  └───────────────────────┼────────────────────────────┘  │
+│                          │                               │
+│  ┌───────────────────────┴──────────────────────────┐   │
+│  │          PostgreSQL 16 + pgvector                 │   │
+│  │  ├ pharmacopoeia 库(药典: drugs, drug_chunks...)│   │
+│  │  └ nih_* 表(倪海厦: 11 张表)                    │   │
+│  └──────────────────────────────────────────────────┘   │
+│                                                         │
+│  ┌──────────┐  ┌──────────┐                             │
+│  │ Redis 7  │  │ 模型文件  │                             │
+│  │ (缓存)    │  │ bge-m3/  │                             │
+│  │ :6379    │  │ bge-reranker/                           │
+│  └──────────┘  └──────────┘                             │
+└─────────────────────────────────────────────────────────┘
+         │
+         │ HTTP (查询改写)
+         ▼
+┌─────────────────────────┐
+│  本地 GPU 服务器          │
+│  Ollama                 │
+│  ├ qwen2.5:14b (主)     │
+│  └ :11434               │
+└─────────────────────────┘
+         │ 降级
+         ▼
+┌─────────────────────────┐
+│  百炼 API (阿里云)       │
+│  qwen-plus (兜底)       │
+└─────────────────────────┘
+```
+
+### 4.2 Nginx 路由配置
+
+```nginx
+# 倪海厦 RAG API
+location /api/v1/nihaisha/ {
+    proxy_pass http://127.0.0.1:9000;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_read_timeout 60s;          # 混合搜索可能较慢
+}
+
+# 药典对话(现有,不变)
+location /api/v1/chat/ {
+    proxy_pass http://127.0.0.1:9000;
+    proxy_buffering off;             # SSE 流式
+    proxy_read_timeout 300s;
+}
+```
+
+### 4.3 资源配置
+
+| 组件 | 内存 | 说明 |
+|------|------|------|
+| JVM 堆 | **3 GB**(原 2GB) | 新增 ONNX 模型 + 倪海厦检索 |
+| ONNX 模型 (FP16) | ~1.7 GB | BGE-M3 + Reranker 常驻内存 |
+| PostgreSQL | ~2 GB | shared_buffers |
+| Redis | ~0.5 GB | 缓存 |
+| OS | ~1 GB | |
+| **合计** | **~8 GB** | 线上服务器需 8-16 GB 内存 |
+
+---
+
+## 5. 水平扩容路径
+
+### 5.1 阶段 1:单体(当前 → 上线初期)
+
+```
+                        Nginx
+                          │
+              ┌───────────┴───────────┐
+              │        :9000          │
+              │  pharmacopoeia-ai.jar │
+              │  (药典 + 倪海厦)       │
+              └───────────┬───────────┘
+                          │
+              ┌───────────┴───────────┐
+              │   PostgreSQL (单机)    │
+              └───────────────────────┘
+```
+
+- **适用**:QPS < 100,数据量 < 100 万条
+- **操作**:无需任何改动,合并代码部署即可
+
+### 5.2 阶段 2:读写分离(流量增长)
+
+```
+                        Nginx
+                          │
+              ┌───────────┴───────────┐
+              │    应用服务器 × 2      │
+              │  (药典 + 倪海厦)       │
+              │  :9000  :9000         │
+              └───────────┬───────────┘
+                          │
+          ┌───────────────┼───────────────┐
+          │               │               │
+  ┌───────┴───────┐ ┌─────┴─────┐ ┌───────┴───────┐
+  │ PG 主库 (写)   │ │ PG 从库 1 │ │ PG 从库 2     │
+  │               │ │ (只读)    │ │ (只读)        │
+  └───────────────┘ └───────────┘ └───────────────┘
+```
+
+**Java 侧改动**:Spring 读写分离数据源配置
+
+```yaml
+spring:
+  datasource:
+    master:
+      url: jdbc:postgresql://pg-master:5432/pharmacopoeia
+    slaves:
+      - url: jdbc:postgresql://pg-slave-1:5432/pharmacopoeia
+      - url: jdbc:postgresql://pg-slave-2:5432/pharmacopoeia
+```
+
+- 搜索请求走从库(倪海厦 RAG 是读密集型)
+- 对话记录写入走主库
+- **适用**:QPS 100-500,读多写少
+
+### 5.3 阶段 3:服务拆分 + 独立扩容
+
+```
+                        Nginx / API Gateway
+                          │
+          ┌───────────────┼───────────────┐
+          │               │               │
+  ┌───────┴───────┐ ┌─────┴──────┐ ┌──────┴──────┐
+  │  药典对话服务  │ │ 倪海厦 RAG │ │  考试服务   │
+  │  × 2 实例     │ │  × 3 实例  │ │  × 1 实例   │
+  │  :9001        │ │  :9002     │ │  :9003      │
+  └───────┬───────┘ └─────┬──────┘ └──────┬──────┘
+          │               │               │
+          └───────────────┼───────────────┘
+                          │
+              ┌───────────┴───────────┐
+              │   PostgreSQL 集群      │
+              │   + Redis 集群         │
+              └───────────────────────┘
+```
+
+**拆分方式**(与当前单模块不冲突,渐进式演进):
+
+```
+backend-java/                          # 当前单体
+├── pharmacopoeia-chat/                # 未来:拆为独立模块
+├── pharmacopoeia-exam/                # 未来:拆为独立模块
+├── nihaisha-rag/                      # 未来:拆为独立模块
+└── pharmacopoeia-common/              # 共用:entity, security, config
+```
+
+**拆分触发条件**:
+
+| 指标 | 阈值 | 说明 |
+|------|------|------|
+| 倪海厦 RAG QPS | > 200 | 单独扩容倪海厦实例 |
+| 药典对话 QPS | > 100 | 单独扩容药典实例 |
+| ONNX 模型内存压力 | JVM 堆 > 4GB | 倪海厦独立部署,独占 ONNX 模型 |
+| 数据库连接数 | > 80 | 加从库或分库 |
+
+### 5.4 分库路径
+
+倪海厦和药典的数据天然隔离(不跨库 join),随时可分库:
+
+```yaml
+# 未来分库配置
+spring:
+  datasource:
+    pharmacopoeia:
+      url: jdbc:postgresql://pg-pharma:5432/pharmacopoeia
+    nihaisha:
+      url: jdbc:postgresql://pg-nihaisha:5432/nihaisha_rag
+```
+
+当前阶段放在同一个 PostgreSQL 实例、同一个 database 内(`nih_` 前缀隔离),减少运维复杂度。
+
+---
+
+## 6. 数据库规划
+
+### 6.1 当前数据库
+
+```
+PostgreSQL 16 (pgvector)
+└── pharmacopoeia 库
+    ├── 药典表(现有 9 张)
+    │   ├── conversations
+    │   ├── messages
+    │   ├── drugs
+    │   ├── drug_chunks
+    │   ├── users
+    │   ├── knowledge_points
+    │   ├── questions
+    │   ├── answer_records
+    │   └── user_progress
+    │
+    └── 倪海厦表(新增 11 张,nih_ 前缀)
+        ├── nih_meta
+        ├── nih_documents
+        ├── nih_paragraphs
+        ├── nih_retrieval_units
+        ├── nih_vector_embeddings      # pgvector 向量
+        ├── nih_knowledge_units
+        ├── nih_guide_nodes
+        ├── nih_evidence_records
+        ├── nih_entities
+        ├── nih_relations
+        └── nih_eval_cases
+```
+
+### 6.2 存储增量
+
+| 数据 | 磁盘占用 |
+|------|----------|
+| 药典现有数据 | ~2 GB |
+| 倪海厦段落 + 检索单元 | ~3 GB |
+| 倪海厦向量 (1024维 × 50万) | ~2 GB |
+| 倪海厦索引 (HNSW + GIN + trigram) | ~2 GB |
+| **总计(新增)** | **~7 GB** |
+| **数据库总大小** | **~9 GB** |
+
+---
+
+## 7. API 设计
+
+### 7.1 路径规划
+
+| 领域 | 路径前缀 | 鉴权 |
+|------|----------|:---:|
+| 药典对话 | `/api/v1/chat/**` | ✅ JWT |
+| 药典药品 | `/api/v1/drug/**` | ✅ JWT |
+| 药典考试 | `/api/v1/exam/**` | ✅ JWT |
+| 药典管理 | `/api/v1/admin/**` | ✅ JWT + admin-token |
+| 药典快问 | `/api/v1/yaodian/**` | ❌ 公开 |
+| **倪海厦搜索** | **`/api/v1/nihaisha/search/**`** | **✅ JWT** |
+| **倪海厦知识** | **`/api/v1/nihaisha/knowledge/**`** | **✅ JWT** |
+| **倪海厦导航** | **`/api/v1/nihaisha/guide/**`** | **✅ JWT** |
+| **倪海厦图谱** | **`/api/v1/nihaisha/graph/**`** | **✅ JWT** |
+| **倪海厦证据** | **`/api/v1/nihaisha/evidence/**`** | **✅ JWT** |
+| 健康检查 | `/health` | ❌ 公开 |
+
+### 7.2 Security 配置补充
+
+在现有 `SecurityConfig` 中补充倪海厦路径:
+
+```java
+// 现有公开路径
+"/health", "/api/v1/auth/**", "/api/v1/yaodian/**", "/static/**"
+
+// 倪海厦搜索暂不单独公开,走 JWT 鉴权(与药典对话一致)
+// 所有 /api/v1/nihaisha/** 需要 Bearer Token
+```
+
+### 7.3 前端问答框模型选择
+
+前端需要一个问答框,支持选择模型。在现有药典对话系统基础上扩展:
+
+```
+POST /api/v1/nihaisha/search/hybrid
+Authorization: Bearer <jwt>
+
+Request:
+{
+  "query": "桂枝汤的组成和用法",
+  "model": "nihaisha",           // ★ 新增字段
+  "channels": ["vector", "text", "knowledge", "graph"],
+  "limit": 20
+}
+
+Response:
+{
+  "results": [...],
+  "tookMs": 380,
+  "model": "nihaisha"            // 回显模型选择
+}
+```
+
+前端对话页可以加一个下拉选择:
+- "药典助手" → 走 `/api/v1/chat/stream`(现有)
+- "倪海厦中医" → 走 `/api/v1/nihaisha/search/hybrid`(新增)
+
+---
+
+## 附录 A:pom.xml 新增依赖
+
+在现有 `backend-java/pom.xml` 中追加:
+
+```xml
+<!-- ONNX Runtime — BGE-M3 Embedding + Reranker 本地推理 -->
+<dependency>
+    <groupId>com.microsoft.onnxruntime</groupId>
+    <artifactId>onnxruntime</artifactId>
+    <version>1.18.0</version>
+</dependency>
+
+<!-- HuggingFace Tokenizers — 加载 tokenizer.json(Rust JNI) -->
+<dependency>
+    <groupId>com.huggingface</groupId>
+    <artifactId>tokenizers</artifactId>
+    <version>0.21.0</version>
+</dependency>
+```
+
+> 不需要加 MyBatis 依赖,统一用 JPA;不需要加 Flyway 依赖,药典项目已有。
+
+---
+
+## 附录 B:扩容决策树
+
+```
+QPS < 100 且 内存 < 8GB?
+  ├── 是 → 阶段 1:单体,不做任何拆分
+  └── 否 → 倪海厦 RAG QPS > 200?
+           ├── 是 → 阶段 3:拆分倪海厦为独立服务
+           └── 否 → 数据库 CPU > 70%?
+                    ├── 是 → 阶段 2:加 PG 从库,读写分离
+                    └── 否 → 阶段 2:加应用实例 + 负载均衡
+```
+
+---
+
+> **文档版本**: v1.0 | **下一步**: 确认方案后开始写代码