Files
msgbot/knowledge_base.py
T
youpaishidifu 1441278082 fix(kb): 混合评分策略修复短查询匹配失败
TF-IDF 余弦相似度单独使用对短中文查询过于严格(3-5 个词 vs 数百个词的长文档)。
改为混合评分:0.4×TF-IDF + 0.6×关键词命中率,让短查询只要命中关键词就能返回结果。

同时降低 auto_reply_min_score 从 0.35→0.30,适配混合评分的数值范围。
2026-07-27 12:30:31 +08:00

187 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地知识库 — 加载 Markdown/TXT 文件,jieba 分词 + TF-IDF 搜索"""
import os
import re
import logging
from pathlib import Path
from typing import List, Tuple, Optional
import jieba
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
class KnowledgeBase:
"""知识库引擎"""
def __init__(self, root_path: str = ""):
self.root_path: str = root_path
self.docs: List[dict] = [] # [{path, title, content, segments}]
self._vectorizer = TfidfVectorizer(tokenizer=self._tokenize, lowercase=False)
self._tfidf_matrix = None
self._ready = False
@staticmethod
def _tokenize(text: str) -> List[str]:
"""jieba 分词"""
return list(jieba.cut(text))
@staticmethod
def _parse_qa_segments(content: str) -> list:
"""解析 Q&A 格式文件为独立问答段。
格式:问题[N]:xxx\\n答:xxx
返回 [{"question": str, "answer": str, "raw": str}]
"""
segments = []
cur_q = []
cur_a = []
state = None # None, 'q', 'a'
def flush():
if not cur_q and not cur_a:
return
q = '\n'.join(cur_q).strip()
a = '\n'.join(cur_a).strip()
if q or a:
segments.append({
"question": q,
"answer": a,
"raw": f"问题:{q}\n答:{a}",
})
cur_q.clear()
cur_a.clear()
for line in content.split('\n'):
qm = re.match(r'^问题\d*\s*[::]\s*(.*)', line)
am = re.match(r'^答\s*[::]\s*(.*)', line)
if qm:
flush()
state = 'q'
cur_q.append(qm.group(1))
elif am:
state = 'a'
cur_a.append(am.group(1))
elif state == 'q':
cur_q.append(line)
elif state == 'a':
cur_a.append(line)
# else: 文件头空白/注释,忽略
flush()
return segments
def set_path(self, path: str):
self.root_path = path
self.reload()
def reload(self):
"""重新加载知识库文件"""
self.docs = []
if not self.root_path or not os.path.isdir(self.root_path):
self._ready = False
return
root = Path(self.root_path)
extensions = (".md", ".txt", ".markdown")
for fp in sorted(root.rglob("*")):
if fp.suffix.lower() in extensions and fp.is_file():
try:
content = fp.read_text("utf-8", errors="ignore")
title = fp.stem
# 解析 Q&A 段
segments = self._parse_qa_segments(content)
if segments:
for seg in segments:
self.docs.append({
"path": str(fp),
"title": title,
"content": seg["raw"],
"answer": seg["answer"],
"question": seg["question"],
})
else:
# 非 Q&A 格式,整文件作为一段
self.docs.append({
"path": str(fp),
"title": title,
"content": content,
})
except Exception:
continue
if not self.docs:
self._ready = False
return
# 构建 TF-IDF
corpus = [d["content"] for d in self.docs]
try:
self._tfidf_matrix = self._vectorizer.fit_transform(corpus)
self._ready = True
except Exception:
self._ready = False
@property
def is_ready(self) -> bool:
return self._ready and len(self.docs) > 0
def search(self, query: str, top_k: int = 3, min_score: float = 0.05) -> List[Tuple[dict, float]]:
"""搜索知识库,返回 (doc, score) 列表。
混合评分策略:
1) TF-IDF 余弦相似度(权重 0.4)— 适合长查询,对短查询偏严
2) 关键词命中率(权重 0.6)— 适合短查询,对关键词命中敏感
最终得分 = 0.4 * tfidf_score + 0.6 * keyword_hit_ratio
这种混合方式让短查询(3-5 个词)只要命中关键词就能返回结果,
同时长查询仍能利用 TF-IDF 的语义区分能力。
"""
if not self.is_ready or not query.strip():
return []
# 短文本:至少 2 个字且至少有 1 个有效词才搜索
tokens = [t for t in jieba.cut(query.strip()) if len(t) >= 2]
if len(query.strip()) < 2 or len(tokens) == 0:
return []
query_stripped = query.strip()
query_vec = self._vectorizer.transform([query_stripped])
tfidf_scores = cosine_similarity(query_vec, self._tfidf_matrix)[0]
# 关键词命中率:查询 tokens 中有多少个出现在文档内容中
query_tokens_set = set(tokens)
keyword_ratios = []
for doc in self.docs:
content = doc["content"]
hit = sum(1 for t in query_tokens_set if t in content)
keyword_ratios.append(hit / max(len(query_tokens_set), 1))
keyword_ratios = np.array(keyword_ratios)
# 混合评分
combined_scores = 0.4 * tfidf_scores + 0.6 * keyword_ratios
top_indices = np.argsort(combined_scores)[::-1][:top_k]
results = []
for idx in top_indices:
score = float(combined_scores[idx])
if score < min_score:
continue
results.append((self.docs[idx], score))
if results:
return results
# Fallback: 子串匹配(查询包含在文档标题或内容中)
q = query_stripped
if len(q) >= 4:
for doc in self.docs:
if q in doc["content"] or q in doc["title"]:
logger = logging.getLogger("msgbot.kb")
logger.debug(f"子串匹配: \"{q}\" → {doc['title']}")
return [(doc, min_score)]
return results
def get_doc_count(self) -> int:
return len(self.docs)