RetrieverService.java 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. package com.pharmacopoeia.service;
  2. import org.springframework.dao.DataAccessException;
  3. import org.springframework.data.redis.core.StringRedisTemplate;
  4. import org.springframework.jdbc.core.JdbcTemplate;
  5. import org.springframework.stereotype.Service;
  6. import java.nio.charset.StandardCharsets;
  7. import java.security.MessageDigest;
  8. import java.time.Duration;
  9. import java.util.*;
  10. import java.util.stream.Collectors;
  11. @Service
  12. public class RetrieverService {
  13. private final JdbcTemplate jdbc;
  14. private final LLMService llmService;
  15. private final StringRedisTemplate redis;
  16. private final boolean redisAvailable;
  17. // Embedding 缓存 key 前缀 + TTL(7 天,续期 3 次后不再续)
  18. private static final String EMBED_CACHE_PREFIX = "aiyaodian:embed:";
  19. private static final String EMBED_RENEW_PREFIX = "aiyaodian:embed:renew:";
  20. private static final Duration EMBED_CACHE_TTL = Duration.ofDays(7);
  21. private static final Duration EMBED_MAX_TTL = Duration.ofDays(30);
  22. private static final int MAX_RENEWALS = 3;
  23. private static final List<String> NEGATION_PATTERNS = List.of(
  24. "不是", "没有", "并非", "算不上", "怎么会是", "不可能", "不会",
  25. "没得", "没", "无", "不属", "不属于", "不是什么", "这不是"
  26. );
  27. public RetrieverService(JdbcTemplate jdbc, LLMService llmService,
  28. StringRedisTemplate redis) {
  29. this.jdbc = jdbc;
  30. this.llmService = llmService;
  31. this.redis = redis;
  32. // 探测 Redis 是否可用,不可用时降级为每次调用远程 API
  33. this.redisAvailable = pingRedis();
  34. }
  35. /** 启动时探测 Redis,失败则降级为无缓存模式,避免阻塞主流程 */
  36. private boolean pingRedis() {
  37. try {
  38. return "PONG".equals(redis.getConnectionFactory().getConnection().ping());
  39. } catch (Exception e) {
  40. return false;
  41. }
  42. }
  43. /** 计算 query 的 SHA-256 作为缓存 key */
  44. private String cacheKey(String text) {
  45. try {
  46. MessageDigest md = MessageDigest.getInstance("SHA-256");
  47. byte[] hash = md.digest(text.getBytes(StandardCharsets.UTF_8));
  48. StringBuilder sb = new StringBuilder(2 * hash.length);
  49. for (byte b : hash) sb.append(String.format("%02x", b));
  50. return EMBED_CACHE_PREFIX + sb;
  51. } catch (Exception e) {
  52. return EMBED_CACHE_PREFIX + text.hashCode();
  53. }
  54. }
  55. /** 将 List<Float> 序列化为逗号分隔字符串,便于存入 Redis */
  56. private String encodeVec(List<Float> vec) {
  57. StringBuilder sb = new StringBuilder(vec.size() * 8);
  58. for (int i = 0; i < vec.size(); i++) {
  59. if (i > 0) sb.append(',');
  60. sb.append(vec.get(i));
  61. }
  62. return sb.toString();
  63. }
  64. /** 反序列化 */
  65. private List<Float> decodeVec(String s) {
  66. if (s == null || s.isEmpty()) return List.of();
  67. String[] parts = s.split(",");
  68. List<Float> vec = new ArrayList<>(parts.length);
  69. for (String p : parts) vec.add(Float.parseFloat(p));
  70. return vec;
  71. }
  72. /** 带缓存的 Embedding 查询:相同 query 命中缓存则跳过远程 API 调用 */
  73. private List<Float> embedWithCache(String query) {
  74. if (!redisAvailable) return llmService.embed(query);
  75. String key = cacheKey(query);
  76. try {
  77. String cached = redis.opsForValue().get(key);
  78. if (cached != null) {
  79. // 滑动续期:最多续 3 次,总时长不超 30 天
  80. var renewKey = EMBED_RENEW_PREFIX + key.substring(EMBED_CACHE_PREFIX.length());
  81. try {
  82. var ops = redis.opsForValue();
  83. var cntVal = ops.get(renewKey);
  84. int renewals = cntVal != null ? Integer.parseInt(cntVal) : 0;
  85. if (renewals < MAX_RENEWALS) {
  86. redis.expire(key, EMBED_CACHE_TTL);
  87. ops.set(renewKey, String.valueOf(renewals + 1), EMBED_MAX_TTL);
  88. }
  89. } catch (Exception ignored) {}
  90. return decodeVec(cached);
  91. }
  92. } catch (DataAccessException ignored) {
  93. // Redis 异常时降级到远程调用
  94. }
  95. List<Float> vec = llmService.embed(query);
  96. try {
  97. redis.opsForValue().set(key, encodeVec(vec), EMBED_CACHE_TTL);
  98. } catch (DataAccessException ignored) {
  99. // 缓存写入失败不影响主流程
  100. }
  101. return vec;
  102. }
  103. /** 检测 query 中关键词之前是否存在否定词,避免误匹配 */
  104. private boolean hasNegation(String text, String keyword) {
  105. int idx = text.indexOf(keyword);
  106. if (idx < 0) return false;
  107. String prefix = text.substring(0, idx);
  108. for (String neg : NEGATION_PATTERNS) {
  109. if (prefix.endsWith(neg) || prefix.contains(neg)) return true;
  110. }
  111. return false;
  112. }
  113. /** 已知药物过敏检测:用户明确询问对某药物的过敏处理方法 */
  114. private static final List<String> KNOWN_ALLERGY_DRUGS = List.of(
  115. "青霉素", "头孢", "磺胺", "阿莫西林", "布洛芬",
  116. "阿司匹林", "链霉素", "庆大霉素", "四环素",
  117. "红霉素", "氯霉素", "万古霉素", "喹诺酮",
  118. "普鲁卡因", "利多卡因", "碘", "破伤风"
  119. );
  120. private static final java.util.regex.Pattern[] KNOWN_ALLERGY_REGEX = {
  121. java.util.regex.Pattern.compile("对.{1,6}过敏"), // 对青霉素过敏
  122. java.util.regex.Pattern.compile(".{1,6}药过敏"), // 头孢药过敏
  123. java.util.regex.Pattern.compile("药物过敏"), // 药物过敏
  124. java.util.regex.Pattern.compile(".+过敏(处理|怎么办|急救|替代|注意|救治|抢救|应急)"),
  125. };
  126. private boolean isKnownDrugAllergy(String query) {
  127. // 必须包含"过敏"
  128. if (!query.contains("过敏")) return false;
  129. // 精确匹配已知过敏药物清单
  130. for (String drug : KNOWN_ALLERGY_DRUGS) {
  131. if (query.contains(drug)) return true;
  132. }
  133. // 正则模式匹配(对XX过敏 / XX药过敏 / 药物过敏 / 过敏处理/急救等)
  134. for (var pattern : KNOWN_ALLERGY_REGEX) {
  135. if (pattern.matcher(query).find()) return true;
  136. }
  137. return false;
  138. }
  139. public String classifyIntent(String query) {
  140. String q = query.trim();
  141. // 0. 已知药物过敏检测(最高优先级,必须在 "过敏" 通用匹配之前)
  142. // 如 "青霉素过敏处理" "头孢过敏怎么办" → 用药安全咨询,非症状求药
  143. if (isKnownDrugAllergy(q)) {
  144. return "usage_guide";
  145. }
  146. // 1. 用药安全/用法用量(优先级最高,避免"过敏"与症状类冲突)
  147. if (anyMatch(q, "怎么吃", "吃多少", "怎么用", "怎么服用", "孕妇", "儿童用量",
  148. "副作用多大", "伤肝", "伤肾", "安全吗", "副作用", "不良反应",
  149. "禁忌", "过敏", "能不能", "可以吗", "用法", "用量", "剂量",
  150. "用药指导", "一天几次", "一次多少", "饭前", "饭后", "空腹",
  151. "能不能一起吃", "相互作用", "过量", "停用", "停药", "忌口",
  152. "饮酒", "肝功能", "肾功能")) {
  153. return "usage_guide";
  154. }
  155. // 2. 考试辅导(高优先级,关键词明确)
  156. if (anyMatch(q, "执业药师", "考点", "历年真题", "考试大纲", "高频考点",
  157. "药物化学", "药剂学", "药理学", "药分", "药物分析")) {
  158. return "exam_tutor";
  159. }
  160. // 3. 法规条款(关键词明确)
  161. if (anyMatch(q, "凡例", "通则规定", "制剂通则", "一般规定", "通则")) {
  162. return "regulation";
  163. }
  164. // 4. 症状用药建议(安全类关键词已在上方处理,"过敏"不会落到这里)
  165. if (anyMatch(q, "发烧", "咳嗽", "感冒", "腹泻", "头疼", "头痛", "嗓子疼",
  166. "吃了什么药", "吃什么药", "该吃", "推荐用药", "推荐下用药",
  167. "体温", "多少度", "退烧", "止痛", "止泻", "鼻塞", "流鼻涕",
  168. "头晕", "乏力", "呕吐", "腹痛", "咽痛", "打喷嚏")) {
  169. return "symptom_advice";
  170. }
  171. // 5. 兜底:药品查询
  172. return "drug_query";
  173. }
  174. /** 检索:仅 2025 年版药典,AI 兜底补充 */
  175. public List<Map<String, Object>> search(String query, String intent, int topK) {
  176. List<Float> vec = embedWithCache(query);
  177. if (vec.isEmpty()) return List.of();
  178. String vecStr = vec.stream()
  179. .map(String::valueOf)
  180. .collect(Collectors.joining(",", "[", "]"));
  181. String drugName = extractDrugName(query);
  182. return searchWithVersion(vecStr, drugName, "2025年版", topK);
  183. }
  184. /** 响应式检索:供 SSE 端点使用,避免 reactor 线程中 block() */
  185. public reactor.core.publisher.Mono<List<Map<String, Object>>> searchReactive(String query, String intent, int topK) {
  186. return llmService.embedReactive(query)
  187. .map(vec -> {
  188. if (vec.isEmpty()) return List.<Map<String, Object>>of();
  189. String vecStr = vec.stream()
  190. .map(String::valueOf)
  191. .collect(Collectors.joining(",", "[", "]"));
  192. String drugName = extractDrugName(query);
  193. return searchWithVersion(vecStr, drugName, "2025年版", topK);
  194. });
  195. }
  196. /** 带版本过滤的向量检索。version 为 null 时不限制版本。 */
  197. private List<Map<String, Object>> searchWithVersion(
  198. String vecStr, String drugName, String version, int topK) {
  199. String versionFilter = (version != null)
  200. ? " AND d.source_version = '" + version + "' "
  201. : " ";
  202. String sql;
  203. Object[] params;
  204. if (!drugName.isEmpty()) {
  205. // 精确匹配
  206. sql = """
  207. SELECT c.content, c.source, c.drug_id, c.section,
  208. d.name, d.category, d.source_version, d.source_volume,
  209. 1 - (c.vec <=> ?::vector) AS similarity
  210. FROM drug_chunks c
  211. JOIN drugs d ON d.drug_id = c.drug_id
  212. WHERE c.vec IS NOT NULL AND d.name = ?""" + versionFilter + """
  213. ORDER BY c.vec <=> ?::vector
  214. LIMIT ?
  215. """;
  216. params = new Object[]{vecStr, drugName, vecStr, topK};
  217. List<Map<String, Object>> results = jdbc.queryForList(sql, params);
  218. if (!results.isEmpty()) return results;
  219. // 前缀匹配兜底
  220. sql = """
  221. SELECT c.content, c.source, c.drug_id, c.section,
  222. d.name, d.category, d.source_version, d.source_volume,
  223. 1 - (c.vec <=> ?::vector) AS similarity
  224. FROM drug_chunks c
  225. JOIN drugs d ON d.drug_id = c.drug_id
  226. WHERE c.vec IS NOT NULL AND d.name LIKE ?""" + versionFilter + """
  227. ORDER BY c.vec <=> ?::vector
  228. LIMIT ?
  229. """;
  230. params = new Object[]{vecStr, drugName + "%", vecStr, topK};
  231. } else {
  232. sql = """
  233. SELECT c.content, c.source, c.drug_id, c.section,
  234. d.name, d.category, d.source_version, d.source_volume,
  235. 1 - (c.vec <=> ?::vector) AS similarity
  236. FROM drug_chunks c
  237. JOIN drugs d ON d.drug_id = c.drug_id
  238. WHERE c.vec IS NOT NULL""" + versionFilter + """
  239. ORDER BY c.vec <=> ?::vector
  240. LIMIT ?
  241. """;
  242. params = new Object[]{vecStr, vecStr, topK};
  243. }
  244. return jdbc.queryForList(sql, params);
  245. }
  246. // 剂型后缀(长后缀优先,避免"缓释胶囊"被错误截断为"缓释")
  247. private static final List<String> FORMULATION_SUFFIXES = List.of(
  248. "缓释胶囊", "缓释片", "肠溶胶囊", "肠溶片", "分散片", "咀嚼片",
  249. "口服混悬液", "口服液", "混悬液", "滴眼液", "注射液",
  250. "缓释", "肠溶", "胶囊", "颗粒", "糖浆", "软膏", "栓剂",
  251. "片", "剂", "栓"
  252. );
  253. /** 从 query 中提取已知药品名:查 drugs 表,支持剂型后缀剥离和模糊匹配 */
  254. private String extractDrugName(String query) {
  255. String cleaned = query.trim();
  256. // 第一步:去掉尾部常见修饰词
  257. String[] querySuffixes = {
  258. "的用法与用量", "的用法用量", "用法与用量", "用法用量", "的用量", "的用法",
  259. "的副作用", "不良反应", "的禁忌", "禁忌", "的注意事项", "注意事项",
  260. "是什么", "说明书", "怎么用", "怎么吃", "的用量", "用量", "的剂量", "剂量"
  261. };
  262. for (String s : querySuffixes) {
  263. if (cleaned.endsWith(s)) {
  264. cleaned = cleaned.substring(0, cleaned.length() - s.length()).trim();
  265. break;
  266. }
  267. }
  268. // 去掉问句前缀
  269. cleaned = cleaned.replaceAll("^(什么是|怎么|如何|告诉我|请问|查询|搜索|查一下)", "").trim();
  270. if (cleaned.length() < 2) return "";
  271. // 第二步:精确匹配原始 query(含剂型名如"布洛芬缓释胶囊")
  272. String exact = tryExactMatch(cleaned);
  273. if (!exact.isEmpty()) return exact;
  274. // 第三步:逐步剥剂型后缀再试("布洛芬缓释胶囊"→"布洛芬")
  275. for (String suffix : FORMULATION_SUFFIXES) {
  276. if (cleaned.endsWith(suffix)) {
  277. String base = cleaned.substring(0, cleaned.length() - suffix.length()).trim();
  278. if (base.length() >= 2) {
  279. String match = tryExactMatch(base);
  280. if (!match.isEmpty()) return match;
  281. }
  282. }
  283. }
  284. // 第四步:ILIKE 模糊匹配兜底
  285. return tryFuzzyMatch(cleaned);
  286. }
  287. private String tryExactMatch(String name) {
  288. try {
  289. List<String> matches = jdbc.queryForList(
  290. "SELECT name FROM drugs WHERE name = ? AND is_active = TRUE LIMIT 1",
  291. String.class, name);
  292. if (!matches.isEmpty()) return matches.get(0);
  293. } catch (Exception ignored) {}
  294. return "";
  295. }
  296. private String tryFuzzyMatch(String name) {
  297. try {
  298. List<String> matches = jdbc.queryForList(
  299. "SELECT name FROM drugs WHERE name ILIKE ? AND is_active = TRUE ORDER BY name LIMIT 1",
  300. String.class, "%" + name + "%");
  301. if (!matches.isEmpty()) return matches.get(0);
  302. } catch (Exception ignored) {}
  303. return "";
  304. }
  305. private boolean anyMatch(String text, String... keywords) {
  306. for (String kw : keywords) {
  307. int idx = text.indexOf(kw);
  308. if (idx >= 0 && !hasNegation(text, kw)) {
  309. return true;
  310. }
  311. }
  312. return false;
  313. }
  314. }