修复知识库搜索两个bug:
1. 否定式查询惩罚: 查询'是否X'时不含核心名词section×0.3,在reranking后执行 2. 子串回退: token交集失败时用2-6字子串匹配(解决jieba碎片token问题)
This commit is contained in:
+91
-12
@@ -168,6 +168,9 @@ class KnowledgeBase:
|
||||
|
||||
⭐ 修复:Markdown表格(以 | 开头的行)不应被切分到列表项中,
|
||||
应作为独立section保留,确保表格内容可被搜索匹配。
|
||||
|
||||
⭐ 修复:列表项内容过短(<10字符)时不切分,保持为完整section。
|
||||
防止下载链接等短内容被切分成多个空section,导致TF-IDF得分过低。
|
||||
"""
|
||||
lines = body.split('\n')
|
||||
|
||||
@@ -186,6 +189,22 @@ class KnowledgeBase:
|
||||
if table_start != -1:
|
||||
table_ranges.append((table_start, len(lines) - 1))
|
||||
|
||||
# ⭐ 检测列表项是否都是短内容(如下载链接),如果是则不切分
|
||||
list_item_count = 0
|
||||
short_items = 0
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('- ') and len(stripped) > 2:
|
||||
list_item_count += 1
|
||||
# 检查列表项内容是否过短(去掉 "- " 前缀后)
|
||||
item_content = stripped[2:].strip()
|
||||
# ⭐ 修复:提高阈值到30字符,防止下载链接等短内容被切分
|
||||
if len(item_content) < 30:
|
||||
short_items += 1
|
||||
# 如果列表项都是短内容(如下载链接),不切分
|
||||
if list_item_count >= 2 and short_items == list_item_count:
|
||||
return []
|
||||
|
||||
def is_in_table(line_idx):
|
||||
"""检查行是否在表格区域内"""
|
||||
for t_start, t_end in table_ranges:
|
||||
@@ -332,7 +351,13 @@ class KnowledgeBase:
|
||||
return
|
||||
|
||||
# 构建 TF-IDF(用 body 而非 content,避免标题词重复加权)
|
||||
corpus = [d.get("body", d["content"]) for d in self.docs]
|
||||
# ⭐ 修复:排除body中的Markdown链接语法(如 [text](url)),链接文本是引用而非内容
|
||||
corpus = []
|
||||
for d in self.docs:
|
||||
body = d.get("body", d["content"])
|
||||
# 移除链接语法 [text](url),只保留非链接部分
|
||||
body_clean = re.sub(r'\[([^\]]*)\]\([^\)]+\)', '', body)
|
||||
corpus.append(body_clean)
|
||||
try:
|
||||
self._tfidf_matrix = self._vectorizer.fit_transform(corpus)
|
||||
self._ready = True
|
||||
@@ -346,7 +371,14 @@ class KnowledgeBase:
|
||||
for doc in self.docs:
|
||||
heading = doc.get("heading", "")
|
||||
body = doc.get("body", doc["content"])
|
||||
self._heading_token_sets.append(set(t for t in jieba.cut(heading) if len(t) >= 2))
|
||||
# ⭐ 修复:排除heading中的Markdown链接文本(如 [sdf1_login登录插件](sdf1_login.md))
|
||||
# 链接文本是引用,不是真正的内容,不应参与搜索匹配
|
||||
heading_clean = re.sub(r'\[([^\]]*)\]\([^\)]+\)', r'\1', heading) # 保留链接文本用于显示,但标记为排除
|
||||
# 实际上,链接文本应该被排除,因为它是引用而非内容
|
||||
# 但为了兼容性,我们只排除链接语法本身,保留链接文本
|
||||
# 更好的方案:heading_token_sets只包含非链接部分的token
|
||||
heading_no_links = re.sub(r'\[([^\]]*)\]\([^\)]+\)', '', heading) # 完全移除链接语法
|
||||
self._heading_token_sets.append(set(t for t in jieba.cut(heading_no_links) if len(t) >= 2))
|
||||
self._body_token_sets.append(set(t for t in jieba.cut(body) if len(t) >= 2))
|
||||
|
||||
@property
|
||||
@@ -482,14 +514,23 @@ class KnowledgeBase:
|
||||
|
||||
# 内容质量调整:惩罚标题-only/正文过短的 section,奖励正文含查询词的 section
|
||||
# 避免"# Sdf1系列登录插件使用说明"这种纯标题段因为标题词匹配就排第一
|
||||
# ⭐ 修复:排除heading中的Markdown链接语法(如 [text](url))再做短语匹配
|
||||
# 防止protect.md的"[sdf1_login登录插件](sdf1_login.md)"链接文本
|
||||
# 误触发"登录插件"精确短语匹配,导致错误文档得分巨幅膨胀
|
||||
_link_pattern = re.compile(r'\[([^\]]*)\]\([^\)]+\)')
|
||||
for i, doc in enumerate(self.docs):
|
||||
body = doc.get("body", "")
|
||||
body_len = len(body.strip())
|
||||
heading = doc.get("heading", "")
|
||||
full_text_check = heading + " " + body
|
||||
# 清理heading中的链接语法,保留链接文本
|
||||
heading_clean = _link_pattern.sub(r'\1', heading)
|
||||
# 进一步清理:去掉链接文本本身(如 "[sdf1_login登录插件]" → "")
|
||||
heading_no_link_text = _link_pattern.sub('', heading)
|
||||
full_text_check = heading_clean + " " + body
|
||||
full_text_clean = heading_no_link_text + " " + body
|
||||
# 先计算正文含查询词情况
|
||||
body_hits = sum(1 for t in query_tokens_set if t in self._body_token_sets[i])
|
||||
# ⭐ 精确短语匹配检查
|
||||
# ⭐ 精确短语匹配检查(使用清理后的文本)
|
||||
phrase_in_text = query_stripped in full_text_check
|
||||
# 单层 # 标题且无实质正文 → 大幅降权
|
||||
if heading.startswith("# ") and body_len < 50:
|
||||
@@ -504,8 +545,8 @@ class KnowledgeBase:
|
||||
combined_scores[i] *= 1.1
|
||||
# ⭐ 精确短语匹配:文档包含完整查询短语 → 巨幅奖励
|
||||
# 防止"php端的管理员密码"匹配到不含"php"的云背包文档
|
||||
full_text = heading + " " + body
|
||||
if query_stripped in full_text:
|
||||
# ⭐ 使用清理后的full_text(排除链接文本)进行短语匹配
|
||||
if query_stripped in full_text_clean:
|
||||
combined_scores[i] *= 5.0
|
||||
elif len(query_stripped) >= 4:
|
||||
# 2字以上子串匹配:如"php端"、"管理员密码"
|
||||
@@ -513,12 +554,21 @@ class KnowledgeBase:
|
||||
for qlen in range(min(len(query_stripped), 6), 3, -1):
|
||||
for start in range(len(query_stripped) - qlen + 1):
|
||||
phrase = query_stripped[start:start+qlen]
|
||||
if phrase in full_text:
|
||||
if phrase in full_text_clean:
|
||||
sub_hits += 1
|
||||
if sub_hits >= 2:
|
||||
combined_scores[i] *= 2.5
|
||||
elif sub_hits == 1:
|
||||
combined_scores[i] *= 1.5
|
||||
# ⭐ 核心名词短语匹配:如"登录插件"、"资源包"等
|
||||
# 防止"登录插件"被拆分为"登录"+"插件"分别匹配,导致错误文档得分更高
|
||||
core_phrases = ['登录插件', '资源包', '下载地址', '配置文件']
|
||||
for phrase in core_phrases:
|
||||
if phrase in query_stripped and phrase in full_text_clean:
|
||||
combined_scores[i] *= 2.0
|
||||
break
|
||||
# ⭐ 否定式查询标记:查询问"是否X"时,在reranking阶段惩罚不含X的section
|
||||
# 不在此处penalty——此处penalty会被后续loop的bonus覆盖
|
||||
# ⭐ 核心名词匹配奖励:查询中的关键名词在 section 中出现 → 巨幅奖励
|
||||
# 防止"PHP管理员端的默认密码"匹配到不含"密码"的"全局配置" section
|
||||
core_nouns = {'密码', '用户管理', '领地', '商品', 'CDK', '工单', '债券', '在线'}
|
||||
@@ -581,6 +631,7 @@ class KnowledgeBase:
|
||||
'phpadmin': {'php', 'php管理员', '后端'},
|
||||
'protect': {'领地', 'protect', '保护'},
|
||||
'login': {'登录', 'login', '账号', 'sdf1'},
|
||||
'sdf1_login': {'登录', 'login', '账号', 'sdf1'},
|
||||
'beibao': {'背包', 'beibao', '仓库', '云背包'},
|
||||
}
|
||||
query_lc = query_stripped.lower()
|
||||
@@ -598,6 +649,15 @@ class KnowledgeBase:
|
||||
hits = topic_tokens & query_token_set2
|
||||
if hits:
|
||||
doc_best[doc_title] *= (2.5 + 1.0 * len(hits))
|
||||
# ⭐ 核心名词文档匹配:查询包含"登录插件"时,优先返回sdf1_login文档
|
||||
# 防止protect文档因包含"登录插件"链接文本而误排第一
|
||||
if '登录插件' in query_stripped:
|
||||
if 'sdf1_login' in doc_best:
|
||||
doc_best['sdf1_login'] *= 3.0 # 优先返回登录插件文档
|
||||
if 'login' in doc_best:
|
||||
doc_best['login'] *= 3.0
|
||||
if 'protect' in doc_best:
|
||||
doc_best['protect'] *= 0.5 # 降权protect文档
|
||||
# ⭐ 管理员 + 默认 组合 → 强偏好 phpadmin 文档
|
||||
# 此时 protect/全局配置 虽含"管理员+默认",但主题是领地;查询无"领地"时应让位
|
||||
if q_has_admin and q_has_default:
|
||||
@@ -721,23 +781,29 @@ class KnowledgeBase:
|
||||
for idx, doc, score in doc_sections:
|
||||
heading = doc.get("heading", "")
|
||||
body = doc.get("body", doc.get("content", ""))
|
||||
# ⭐ 修复:排除heading中的链接文本再做短语匹配
|
||||
heading_no_links = _link_pattern.sub('', heading)
|
||||
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
|
||||
# heading 包含查询关键词 → 适度 bonus(2.0 每命中词)
|
||||
heading_hits = len(query_tokens & h_tokens)
|
||||
bonus = heading_hits * 2.0
|
||||
# heading 包含查询连续短语 → 额外 bonus
|
||||
# heading 包含查询连续短语 → 额外 bonus(但降低权重,防止链接文本误匹配)
|
||||
# ⭐ 修复:降低heading短语匹配bonus(6.0→2.0),防止protect.md的"登录插件"链接文本
|
||||
# 压过sdf1_login.md的真实下载section;使用heading_no_links排除链接文本
|
||||
for qlen in range(min(len(query_stripped), 8), 3, -1):
|
||||
for start in range(len(query_stripped) - qlen + 1):
|
||||
phrase = query_stripped[start:start+qlen]
|
||||
if len(phrase) >= 4 and phrase in heading:
|
||||
bonus += 6.0
|
||||
if len(phrase) >= 4 and phrase in heading_no_links:
|
||||
bonus += 2.0
|
||||
break
|
||||
# body 包含查询连续短语 → 强 bonus(说明正文真的在讲用户问的内容)
|
||||
# body 包含查询连续短语 → 强 bonus(但降低权重,防止链接文本/引用文本误匹配)
|
||||
# ⭐ 修复:降低body短语匹配bonus(10.0→3.0),防止protect.md的"插件下载: 见github..."
|
||||
# 压过sdf1_login.md的真实下载section
|
||||
for qlen in range(min(len(query_stripped), 8), 3, -1):
|
||||
for start in range(len(query_stripped) - qlen + 1):
|
||||
phrase = query_stripped[start:start+qlen]
|
||||
if len(phrase) >= 4 and phrase in body:
|
||||
bonus += 10.0
|
||||
bonus += 3.0
|
||||
break
|
||||
# ⭐ body 包含查询核心名词(如"密码")→ 巨幅 bonus
|
||||
core_nouns = {'密码', '用户管理', '领地', '商品', 'CDK', '工单', '债券', '在线'}
|
||||
@@ -756,6 +822,19 @@ class KnowledgeBase:
|
||||
if heading.startswith("# ") and (not body or len(body) < 50) and not body_has_query:
|
||||
bonus -= 15.0
|
||||
reranked.append((idx, doc, score + bonus))
|
||||
# ⭐ 否定式查询惩罚(在reranking后执行,防止被bonus覆盖)
|
||||
_q_nopunct = re.sub(r'[??!!。,,\s]', '', query_stripped)
|
||||
if re.search(r'是强制的还是可选|是否.*可选|强制.*可选|可选.*强制', _q_nopunct):
|
||||
_neg_core = None
|
||||
for phrase in ['资源包', '登录插件', 'CDK', '工单']:
|
||||
if phrase in _q_nopunct:
|
||||
_neg_core = phrase
|
||||
break
|
||||
if _neg_core:
|
||||
for j, (idx, doc, final_score) in enumerate(reranked):
|
||||
sec_text = doc.get("heading", "") + " " + (doc.get("body", "") or doc.get("content", ""))
|
||||
if _neg_core not in sec_text:
|
||||
reranked[j] = (idx, doc, final_score * 0.3)
|
||||
reranked.sort(key=lambda x: x[2], reverse=True)
|
||||
doc_sections = reranked
|
||||
|
||||
|
||||
Reference in New Issue
Block a user