bm25_search.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. # 导入 BM25 算法
  2. from rank_bm25 import BM25Okapi
  3. # 导入数值计算库
  4. import numpy as np
  5. # 导入文本预处理
  6. from mysql_qa.utils.preprocess import preprocess_text
  7. # 导入日志
  8. from base import logger
  9. class BM25Search:
  10. def __init__(self, redis_client, mysql_client):
  11. # 初始化日志与数据客户端
  12. self.logger = logger
  13. self.redis_client = redis_client
  14. self.mysql_client = mysql_client
  15. self.bm25 = None
  16. self.questions = None
  17. self.original_questions = None
  18. self._load_data()
  19. def _load_data(self):
  20. # Redis 键的数据结构:str
  21. original_key = "qa_original_questions"
  22. tokenized_key = "qa_tokenized_questions"
  23. # Redis 中的值由 set_data() 使用 json.dumps() 序列化,因此实际存储的是 JSON 字符串:
  24. # qa_original_questions -> '["如何修改密码?", "如何申请退款?"]'
  25. # qa_tokenized_questions -> '[["如何", "修改密码", "?"], ["如何", "申请", "退款", "?"]]'
  26. # get_data() 使用 json.loads() 反序列化后,恢复为下面对应的 Python 列表结构。
  27. # 优先从 Redis 获取问题及其分词结果
  28. # 数据结构:list[str] | None
  29. # 示例:["如何修改密码?", "如何申请退款?"]
  30. # {"qa_original_questions":""}
  31. # {"qa_tokenized_questions":""}
  32. self.original_questions = self.redis_client.get_data(original_key)
  33. # 数据结构:list[list[str]] | None
  34. # 示例:[["如何", "修改密码", "?"], ["如何", "申请", "退款", "?"]]
  35. tokenized_questions = self.redis_client.get_data(tokenized_key)
  36. # Redis 中没有数据时,从 MySQL 加载
  37. if not self.original_questions or not tokenized_questions:
  38. # 默认 PyMySQL Cursor.fetchall() 的数据结构:tuple[tuple[str], ...]
  39. # 因为 SQL 只查询 question 一列,所以每一行都是仅包含一个问题字符串的元组
  40. # 示例:rows = (("如何修改密码?",), ("如何申请退款?",), ("忘记密码怎么办?",))
  41. rows = self.mysql_client.fetch_questions()
  42. if not rows:
  43. self.logger.warning("未加载到问题")
  44. return
  45. # 逐行取出第 1 列的字符串,将“元组的集合”转换成“问题字符串列表”
  46. # row[0]:从 ("如何修改密码?",) 中取出 "如何修改密码?"
  47. # isinstance 判断用于兼容 row 为 tuple、list 或已经是字符串的情况
  48. self.original_questions = [
  49. row[0] if isinstance(row, (tuple, list)) else row for row in rows
  50. ]
  51. # 转换后的数据结构:list[str]
  52. # 示例:self.original_questions = ["如何修改密码?", "如何申请退款?", "忘记密码怎么办?"]
  53. # 遍历 list[str] 类型的原始问题,每个问题经 preprocess_text() 转成 list[str]
  54. # 最终数据结构:list[list[str]],外层每个元素对应一个问题的分词结果
  55. # 示例(具体分词结果由 Jieba 词典决定):
  56. # self.original_questions = ["如何修改密码?", "如何申请退款?"]
  57. # tokenized_questions = [["如何", "修改密码", "?"], ["如何", "申请", "退款", "?"]]
  58. tokenized_questions = [
  59. preprocess_text(question) for question in self.original_questions
  60. ]
  61. self.redis_client.set_data(original_key, self.original_questions)
  62. self.redis_client.set_data(tokenized_key, tokenized_questions)
  63. # 数据结构:list[list[str]],外层列表对应所有问题,内层列表是单个问题的分词结果
  64. # 示例:[["如何", "修改", "密码"], ["如何", "申请", "退款"], ["忘记", "密码", "怎么办"]]
  65. self.questions = tokenized_questions
  66. # 使用全部分词问题构建 BM25 检索模型,供后续计算查询与各问题的相关性分数
  67. self.bm25 = BM25Okapi(self.questions)
  68. self.logger.info("BM25 模型初始化完成")
  69. def _softmax(self, scores):
  70. # scores:numpy.ndarray,形状为 (问题总数,),保存每个问题的 BM25 原始分数
  71. # 示例:scores = np.array([0.3, 1.2, 3.5])
  72. # 先减去最大值,避免指数运算时数值过大:[-3.2, -2.3, 0.0]
  73. # np.exp() 会对数组中的每个元素计算自然指数 e^x,其中 e ≈ 2.71828
  74. # 公式:exp_scores[i] = e ** (scores[i] - max(scores))
  75. # 示例:[e^-3.2, e^-2.3, e^0] ≈ [0.041, 0.100, 1.000]
  76. # exp_scores 的数据结构仍为 numpy.ndarray,形状与 scores 相同
  77. exp_scores = np.exp(scores - np.max(scores))
  78. # 返回相同形状的 numpy.ndarray;各元素之和为 1
  79. # 示例:[0.036, 0.088, 0.876]
  80. return exp_scores / exp_scores.sum()
  81. def search(self, query, threshold=0.85):
  82. if not query or not isinstance(query, str):
  83. self.logger.error("无效查询")
  84. return None, False
  85. cached_answer = self.redis_client.get_answer(query)
  86. if cached_answer:
  87. return cached_answer, False
  88. if self.bm25 is None or not self.original_questions:
  89. self.logger.warning("问题库为空,无法执行检索")
  90. return None, True
  91. try:
  92. # 数据结构:list[str],表示查询语句的分词结果
  93. # 示例:query = "忘记密码怎么办" -> query_tokens = ["忘记", "密码", "怎么办"]
  94. query_tokens = preprocess_text(query)
  95. # 数据结构:numpy.ndarray,形状为 (问题总数,)
  96. # 每个元素是查询与对应问题的 BM25 原始相关性分数
  97. # 示例:scores = np.array([0.3, 1.2, 3.5])
  98. scores = self.bm25.get_scores(query_tokens)
  99. # 数据结构:numpy.ndarray,形状不变;所有元素之和为 1
  100. # 与上面的示例对应:softmax_scores ≈ np.array([0.036, 0.088, 0.876])
  101. softmax_scores = self._softmax(scores)
  102. # 数据结构:numpy 整数标量(通常为 numpy.int64)
  103. # argmax() 返回最大分数的位置;示例中 best_idx = 2,表示第 3 个问题
  104. best_idx = softmax_scores.argmax()
  105. # 数据结构:numpy 浮点标量(通常为 numpy.float64)
  106. # 根据最佳位置取出最高分;示例中 best_score ≈ 0.876
  107. best_score = softmax_scores[best_idx]
  108. if best_score >= threshold:
  109. original_question = self.original_questions[best_idx]
  110. answer = self.redis_client.get_answer(original_question)
  111. if answer:
  112. self.logger.info(f"从 Redis 中获取到了问题的答案: {answer}")
  113. else:
  114. answer = self.mysql_client.fetch_answer(original_question)
  115. self.logger.info(f"从 MySQL 中获取到了问题的答案: {answer}")
  116. if answer:
  117. self.redis_client.set_data(f"answer:{query}", answer)
  118. self.logger.info(
  119. f"搜索成功,Softmax 相似度: {best_score:.3f}"
  120. )
  121. return answer, False
  122. self.logger.info(
  123. f"未找到可靠答案,最高 Softmax 相似度: {best_score:.3f}"
  124. )
  125. return None, True
  126. except Exception as exc:
  127. self.logger.error(f"搜索失败: {exc}")
  128. return None, True