NihQueryRewriterService.java 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. package com.pharmacopoeia.nihaisha.service;
  2. import com.pharmacopoeia.nihaisha.config.NihaishaProperties;
  3. import org.slf4j.Logger;
  4. import org.slf4j.LoggerFactory;
  5. import org.springframework.http.client.reactive.ReactorClientHttpConnector;
  6. import org.springframework.stereotype.Service;
  7. import org.springframework.web.reactive.function.client.WebClient;
  8. import org.springframework.web.reactive.function.client.WebClientResponseException;
  9. import reactor.netty.http.client.HttpClient;
  10. import java.time.Duration;
  11. import java.util.*;
  12. /**
  13. * 查询改写服务 — 本地 Ollama 主路径 + 百炼 API 降级
  14. */
  15. @Service
  16. public class NihQueryRewriterService {
  17. private static final Logger log = LoggerFactory.getLogger(NihQueryRewriterService.class);
  18. private final NihaishaProperties properties;
  19. private final WebClient webClient;
  20. public NihQueryRewriterService(NihaishaProperties properties) {
  21. this.properties = properties;
  22. this.webClient = WebClient.builder()
  23. .clientConnector(new ReactorClientHttpConnector(
  24. HttpClient.create().responseTimeout(Duration.ofSeconds(30))))
  25. .build();
  26. }
  27. /**
  28. * 改写查询 — 调用 LLM 生成多个变体
  29. * 主路径:本地 Ollama,降级:百炼 API
  30. */
  31. public List<String> rewriteQuery(String originalQuery) {
  32. var primary = properties.getLlm().getPrimary();
  33. var fallback = properties.getLlm().getFallback();
  34. // 尝试主路径(本地 Ollama)
  35. if (primary.getUrl() != null && !primary.getUrl().isEmpty()) {
  36. try {
  37. return callLlm(primary.getUrl(), primary.getApiKey(), primary.getModel(), originalQuery);
  38. } catch (Exception e) {
  39. log.warn("主 LLM (Ollama) 不可用: {}, 降级到百炼 API", e.getMessage());
  40. }
  41. }
  42. // 降级路径(百炼 API)
  43. if (fallback.getUrl() != null && !fallback.getUrl().isEmpty()
  44. && fallback.getApiKey() != null && !fallback.getApiKey().isEmpty()) {
  45. try {
  46. return callLlm(fallback.getUrl(), fallback.getApiKey(), fallback.getModel(), originalQuery);
  47. } catch (Exception e) {
  48. log.warn("降级 LLM (百炼) 也不可用: {}, 返回原始查询", e.getMessage());
  49. }
  50. }
  51. return List.of(originalQuery);
  52. }
  53. @SuppressWarnings("unchecked")
  54. private List<String> callLlm(String url, String apiKey, String model, String query) {
  55. Map<String, Object> body = buildRequestBody(model, query);
  56. Map<String, Object> response;
  57. try {
  58. var request = webClient.post()
  59. .uri(url + "/chat/completions")
  60. .header("Content-Type", "application/json");
  61. if (apiKey != null && !apiKey.isEmpty()) {
  62. request.header("Authorization", "Bearer " + apiKey);
  63. }
  64. response = request.bodyValue(body)
  65. .retrieve()
  66. .bodyToMono(Map.class)
  67. .block(properties.getLlm().getTimeout());
  68. } catch (WebClientResponseException e) {
  69. throw new RuntimeException("LLM API 调用失败: " + e.getStatusCode() + " " + e.getResponseBodyAsString());
  70. } catch (Exception e) {
  71. throw new RuntimeException("LLM API 调用失败: " + e.getMessage());
  72. }
  73. if (response == null || !response.containsKey("choices")) {
  74. return List.of(query);
  75. }
  76. List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
  77. if (choices.isEmpty()) return List.of(query);
  78. Map<String, Object> choice = choices.get(0);
  79. Map<String, Object> message = (Map<String, Object>) choice.get("message");
  80. String content = (String) message.get("content");
  81. // 解析 LLM 返回的多个查询变体(按行分割)
  82. List<String> rewrites = new ArrayList<>();
  83. rewrites.add(query); // 始终包含原始查询
  84. if (content != null) {
  85. for (String line : content.split("\n")) {
  86. String trimmed = line.replaceAll("^[\\d\\.\\-\\* ]+", "").trim();
  87. if (!trimmed.isEmpty() && !trimmed.equals(query) && trimmed.length() < 2000) {
  88. rewrites.add(trimmed);
  89. }
  90. }
  91. }
  92. log.debug("查询改写: {} -> {} 个变体", query.length() > 30 ? query.substring(0, 30) + "..." : query, rewrites.size());
  93. return rewrites;
  94. }
  95. private Map<String, Object> buildRequestBody(String model, String query) {
  96. Map<String, Object> body = new HashMap<>();
  97. body.put("model", model);
  98. List<Map<String, Object>> messages = new ArrayList<>();
  99. Map<String, Object> systemMsg = new HashMap<>();
  100. systemMsg.put("role", "system");
  101. systemMsg.put("content", """
  102. 你是中医检索专家。请将用户的问题改写为2-3个语义相同但表述不同的查询,
  103. 用于提高检索召回率。每个改写一行,不要编号,不要解释,只输出查询文本。""");
  104. messages.add(systemMsg);
  105. Map<String, Object> userMsg = new HashMap<>();
  106. userMsg.put("role", "user");
  107. userMsg.put("content", query);
  108. messages.add(userMsg);
  109. body.put("messages", messages);
  110. body.put("temperature", 0.3);
  111. body.put("max_tokens", 200);
  112. return body;
  113. }
  114. }