fix: 修复'登录插件的菜单如何配置?'答非所问,菜单.txt进入top1

- 配置文件section加权:查询含配置意图词+heading带.txt/json/yml后缀+命中非泛化词→+8
- 守卫记录分数最高命中section并提到doc_sections首位,防has_action重排+sub_k截断丢答案
- 核心名词bonus按出现次数计分(5/次上限15),修复php后台玩家端仅含一处领地管理+15压过操作方式表
- 导航/目录/总览section全局惩罚-30,修复index文档导航对皮肤更新查询得28分压过skin文档
- 表格兜底答案级抬分30:操作/模式/方式意图+表格含操作方式列→protect操作方式表32分压过php误匹配

回归验证:菜单/领地操作模式/下载/公告/皮肤更新/登录账号/密码/打印流水全部top1正确
This commit is contained in:
youpaishidifu
2026-08-19 19:53:58 +08:00
parent b8208d6b00
commit 0de88aa084
+239 -112
View File
@@ -114,8 +114,25 @@ class KnowledgeBase:
返回 section 列表。
"""
sections = []
# ⭐ 保护代码块:```...``` 内的内容不参与标题切分
# 修复"### 2.2.7 菜单.txt"section被截断:JSON示例里的 "# 服务器菜单"、
# "# 使用类json格式" 注释行被误判为Markdown标题,导致:
# 1) 菜单.txt section body 在 ```json 处截断,JSON配置内容丢失
# 2) "# 使用类json格式" 被误切成独立section,污染搜索结果
code_blocks = []
def _protect_code_block(m):
code_blocks.append(m.group(0))
return f'\n__CODEBLOCK_{len(code_blocks) - 1}__\n'
content_protected = re.sub(
r'```.*?```', _protect_code_block, content, flags=re.DOTALL)
def _restore_code_blocks(text):
for j, cb in enumerate(code_blocks):
text = text.replace(f'__CODEBLOCK_{j}__', cb)
return text
# 按行首的 ## 标题切分(前面可能有空白)
parts = re.split(r'(?=^#{1,6}\s)', content, flags=re.MULTILINE)
parts = re.split(r'(?=^#{1,6}\s)', content_protected, flags=re.MULTILINE)
section_id = 0
for i, part in enumerate(parts):
@@ -132,14 +149,17 @@ class KnowledgeBase:
match_heading = None
list_sections = []
if body:
# ⭐ 列表项切分前先恢复代码块占位符,避免列表切分逻辑
# 把恢复后的多行代码块内容当作独立列表项切分
body_restored = _restore_code_blocks(body)
# 按列表项切分
list_sections = KnowledgeBase._split_body_by_list_items(body, heading)
list_sections = KnowledgeBase._split_body_by_list_items(body_restored, heading)
if list_sections:
for ls in list_sections:
sections.append({
"heading": ls["heading"],
"content": ls["content"],
"body": ls["body"],
"content": _restore_code_blocks(ls["content"]),
"body": _restore_code_blocks(ls["body"]),
"section_id": section_id,
})
section_id += 1
@@ -152,7 +172,7 @@ class KnowledgeBase:
# 修复"注册账号"/"怎么登录账号"被invite.md"违规处罚"section
# (heading携带完整条目文本获得免费heading命中)压过的问题
item_texts = []
for _line in body.split('\n'):
for _line in body_restored.split('\n'):
_stripped = _line.strip()
if _stripped.startswith('- ') and len(_stripped) > 2:
item_texts.append(_stripped[2:].strip())
@@ -161,8 +181,8 @@ class KnowledgeBase:
sections.append({
"heading": heading,
"content": part_stripped,
"body": body,
"content": _restore_code_blocks(part_stripped),
"body": _restore_code_blocks(body),
"match_heading": match_heading,
"section_id": section_id,
})
@@ -637,12 +657,24 @@ class KnowledgeBase:
if table_query_tokens & query_tokens_set:
body_stripped = body.strip()
if body_stripped.startswith('|') and '|' in body_stripped[1:]:
# ⚠ 导航/目录类表格不奖励:index.md"文档导航"body含
# "领地系统"等全部主题词,命中查询词即被5x放大到爆炸分,
# 压过protect.md真实的"操作方式"表格。
# 此类 section heading 含导航/目录/关于/导航关键词,或title=index
_nav_heading = heading
_is_nav = (
any(k in _nav_heading for k in ('导航', '目录', '关于我们', '欢迎', '总览'))
or doc.get("title", "").lower() == 'index'
)
# 检查heading是否与查询语义相关
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
heading_relevant = bool(query_tokens_set & h_tokens)
# 或body中含查询核心名词
body_relevant = any(n in body_stripped for n in query_tokens_set if len(n) >= 2)
if heading_relevant or body_relevant:
# 或body中含查询核心名词(收紧:必须是语义性名词,防"系统"等泛化词误触发)
_tbl_core = {'操作', '领地', '保护', '支持', '方法', '方式', '模式', '配置', '下载'}
body_core_hits = sum(1 for n in _tbl_core if n in body_stripped)
body_relevant = body_core_hits >= 2 or any(
n in body_stripped for n in query_tokens_set if n in _tbl_core)
if (heading_relevant or body_relevant) and not _is_nav:
combined_scores[i] *= 5.0 # 相关表格 → 巨幅奖励
# 短查询惩罚:分词数 ≤2 的短查询(如图标残片/短词)得分大幅降权
@@ -828,13 +860,39 @@ class KnowledgeBase:
# 检查heading是否与查询语义相关
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
heading_relevant = bool(query_tokens_set & h_tokens)
body_relevant = any(n in body for n in query_tokens_set if len(n) >= 2)
# ⚠ body相关:必须含查询核心名词(领地/操作/商品等)而非任意token
# 修复"领地系统有哪两种操作模式?"误把sdf1_login"2.安装说明"表格
# (body含"系统"一词)当成"操作方式"答案,被max_score*2放大到89分
_core_nouns_tbl = {'操作', '领地', '保护', '支持', '方法', '方式', '模式'}
body_core_hits = sum(1 for n in _core_nouns_tbl if n in body)
body_relevant = body_core_hits >= 2 or any(
n in body for n in query_tokens_set if n in _core_nouns_tbl)
if heading_relevant or body_relevant:
# 给表格section超高分(最高分×2),确保reranking后仍排第一
max_score = max(s for _, _, s in doc_sections) if doc_sections else min_score
doc_sections.append((i, doc, max_score * 2))
_tbl_score = max_score * 2
# ⭐ 答案级表格:查询意图是"操作/模式/方式/方法",表格body含
# 操作方式/模式列 → 抬到30分。protect.md"操作方式|是否支持"
# 表格是"领地系统有哪两种操作模式?"直接答案,但max_score*2
# 只有~10分,压不过php后台玩家端(body含"领地管理"+核心名词
# bonus 也得~27分)。答案级表格必须比误匹配section更高。
if any(w in query_stripped for w in ('操作', '模式', '方式', '方法')):
if any(n in body for n in ('操作', '方式', '模式')):
_tbl_score = max(_tbl_score, 30.0)
doc_sections.append((i, doc, _tbl_score))
break
doc_sections.sort(key=lambda x: x[2], reverse=True)
else:
# ⭐ 表格已在doc_sections中:has_relevant_table为True时不走强制加入,
# 表格保持原分可能过低(protect"操作方式"表10.25压不过php后台玩家端27.29)。
# 答案级表格抬到30分,逻辑与强制加入分支保持一致。
for _ti, (_tidx, _tdoc, _tscore) in enumerate(doc_sections):
_tbody = _tdoc.get("body", "").strip()
if _tbody.startswith('|') and ('操作' in _tbody or '支持' in _tbody):
if any(w in query_stripped for w in ('操作', '模式', '方式', '方法')):
if any(n in _tbody for n in ('操作', '方式', '模式')):
doc_sections[_ti] = (_tidx, _tdoc, max(_tscore, 30.0))
break
if not doc_sections:
continue
@@ -890,11 +948,29 @@ class KnowledgeBase:
_download_match = True
if _download_match:
bonus += 8.0
# ⭐ 配置文件section加权:查询含"配置/设置/怎么/如何"时,
# heading 带 .txt/.json/.yml 后缀的 section 就是配置文件格式说明,
# 是"XX如何配置"的直接答案。修复"登录插件的菜单如何配置?"
# 时"### 2.2.7 菜单.txt"(JSON配置格式)7.89分被"自定义菜单物品"
# 碎片和"指令参数及介绍"压过、sub_k截断丢弃的问题。
# ⚠ 条件三重:配置意图词 + 配置文件后缀 + heading命中查询的
# 非泛化词(排除登录/配置/设置/使用/插件——这些词到处出现无定位力)。
# "怎么登录账号"的"2.2.4 插件设置.txt"交集仅{登录}∈generic → 不触发
if any(w in query_stripped for w in ('配置', '设置', '怎么', '如何')):
if re.search(r'\.(txt|json|yml|yaml|properties|cfg|conf)', heading_no_links):
_cfg_h_tokens = set(t for t in jieba.cut(heading_no_links) if len(t) >= 2)
_cfg_hit = query_tokens & _cfg_h_tokens
_generic_cfg = {'登录', '配置', '设置', '使用', '插件'}
if any(t not in _generic_cfg for t in _cfg_hit):
bonus += 8.0
# ⭐ body 包含查询核心名词(如"密码")→ 巨幅 bonus
# ⚠ 按名词出现次数计分(1次+5,上限15),而非出现即+15整分:
# 修复php后台玩家端body仅含"领地管理"一句(领地×2)就+15分整,
# 压过protect.md真正含"操作方式"表格的答案section
core_nouns = {'密码', '用户管理', '领地', '商品', 'CDK', '工单', '债券', '在线', '公告', '更新'}
body_noun_hits = sum(1 for n in core_nouns if n in body and n in query_stripped)
if body_noun_hits >= 1:
bonus += 15.0 # body 包含核心名词 → 优先返回
_noun_total = sum(body.count(n) for n in core_nouns if n in body and n in query_stripped)
if _noun_total >= 1:
bonus += min(15.0, 5.0 * _noun_total) # body 包含核心名词 → 优先返回
# ⭐ 管理员+默认 组合(OCR 常丢"php/密码")→ 偏好真正含"密码/登录"的 section
# 防止"## 目录 - 管理员端(当前)"这种目录项压过"## php管理员后端"(默认登录密码)
# 权重给足,必须压过目录项因 heading/phrase 命中的基础分差
@@ -906,6 +982,11 @@ class KnowledgeBase:
body_has_query = any(t in body for t in query_tokens if len(t) >= 2)
if heading.startswith("# ") and (not body or len(body) < 50) and not body_has_query:
bonus -= 15.0
# ⭐ 导航/目录类section全局惩罚:文档导航/目录/总览表格列出全部主题词,
# TF-IDF+核心名词bonus会让它们得分虚高(如index"文档导航"对"皮肤更新"
# 查询得28分压过skin文档)。导航section永远不是具体问题的答案 → 重罚。
if any(k in heading_no_links for k in ('导航', '目录', '关于我们', '欢迎', '总览', '文档导航')):
bonus -= 30.0
reranked.append((idx, doc, score + bonus))
# ⭐ 否定式查询惩罚(在reranking后执行,防止被bonus覆盖)
_q_nopunct = re.sub(r'[??!!。,,\s]', '', query_stripped)
@@ -1002,8 +1083,13 @@ class KnowledgeBase:
# 检查heading是否与查询语义相关(heading含查询词 或 查询词含heading关键词)
h_tokens = set(t for t in jieba.cut(heading) if len(t) >= 2)
heading_relevant = bool(query_tokens & h_tokens)
# 也检查body中是否有查询核心名词
body_relevant = any(n in body for n in query_tokens if len(n) >= 2)
# ⚠ body相关收紧:必须含查询核心名词(操作/领地等)而非任意token
# 修复"领地系统有哪两种操作模式?"在rerank后仍把sdf1_login
# "2.安装说明"表格(body含"系统")移到首位压过protect"操作方式"表
_core_nouns_tbl2 = {'操作', '领地', '保护', '支持', '方法', '方式', '模式'}
body_core_hits = sum(1 for n in _core_nouns_tbl2 if n in body)
body_relevant = body_core_hits >= 2 or any(
n in body for n in query_tokens if n in _core_nouns_tbl2)
if heading_relevant or body_relevant:
doc_sections.insert(0, doc_sections.pop(i))
break
@@ -1104,104 +1190,145 @@ class KnowledgeBase:
if doc.get('heading', '').startswith('### ')
and any(s in doc.get('heading', '') for s in step_headings))
if step_count >= 3:
# ⭐ 获取最佳文档的路径,查找该文档的所有 sections
best_doc_path = doc_sections[0][1].get('path', '')
if best_doc_path:
# 查找同一文档的所有 sections
all_doc_sections = [(i, d) for i, d in enumerate(self.docs) if d.get('path') == best_doc_path]
# ⭐ 语义命中守卫:若存在 section 的 heading 直接命中查询核心词
# (如"菜单.txt"命中"菜单"),说明查询有明确的section级答案,
# 跳过guide合并,避免"登录插件的菜单如何配置?"被合并成"2.安装说明"
# guide而吞掉真正的"### 2.2.7 菜单.txt"配置段。
# ⚠ 条件:heading token 命中 且 body 非空,防止纯目录section误触发
# ⚠ 记录分数最高的命中section(而非第一个),确保"菜单.txt"这类
# 高combine分的精确答案胜出,而不是被同文档先出现的低分section抢占
_heading_hit_section = None # (doc, score)
_heading_hit_idx = None
_heading_hit_score = -1.0
for s_idx, s_doc, s_score in doc_sections:
s_heading = s_doc.get('heading', '')
s_body = s_doc.get('body', '')
s_h_tokens = set(t for t in jieba.cut(s_heading) if len(t) >= 2)
_hit = query_tokens_set & s_h_tokens
# 排除查询侧泛化词:登录/配置/设置/使用/插件 到处出现,
# 命中它们无法定位答案section;真正能定位的是
# 如"菜单""皮肤"这类具体名词
_generic = {'登录', '配置', '设置', '使用', '插件'}
_strong_hit = any(t not in _generic for t in _hit)
if _hit and _strong_hit and len(s_body.strip()) > 10:
if s_score > _heading_hit_score:
_heading_hit_section = (s_doc, s_score)
_heading_hit_idx = s_idx
_heading_hit_score = s_score
if _heading_hit_section is not None:
logger = logging.getLogger("msgbot.kb")
logger.debug(
f"guide合并跳过: heading命中查询词 "
f"{_heading_hit_section[0].get('heading', '')[:30]!r} "
f"score={_heading_hit_section[1]:.2f}"
)
# ⭐ 命中section提到doc_sections首位:
# has_action重排可能已把"指令参数及介绍"等priority命中section抬到最前,
# 若此处不提升,sub_k=2截断会丢弃真正的答案section
# ("登录插件的菜单如何配置?"中菜单.txt 23.08分排第三被丢)
for _ii, (_s_idx2, _, _) in enumerate(doc_sections):
if _s_idx2 == _heading_hit_idx:
doc_sections.insert(0, doc_sections.pop(_ii))
break
else:
# ⭐ 获取最佳文档的路径,查找该文档的所有 sections
best_doc_path = doc_sections[0][1].get('path', '')
if best_doc_path:
# 查找同一文档的所有 sections
all_doc_sections = [(i, d) for i, d in enumerate(self.docs) if d.get('path') == best_doc_path]
if len(all_doc_sections) > 3: # 有多个 sections,可能是操作指南
# ⭐ 按 parent guide 分组 sections
# 遇到 # 或 ## 级别标题时,视为新 guide 的开始
guides = [] # [(parent_heading, [(idx, doc), ...]), ...]
current_guide = None
current_sections = []
if len(all_doc_sections) > 3: # 有多个 sections,可能是操作指南
# ⭐ 按 parent guide 分组 sections
# 遇到 # 或 ## 级别标题时,视为新 guide 的开始
guides = [] # [(parent_heading, [(idx, doc), ...]), ...]
current_guide = None
current_sections = []
for idx, doc in all_doc_sections:
heading = doc.get('heading', '')
# 判断是否为 guide 级别标题(# 或 ##)
if heading.startswith('# ') or heading.startswith('## '):
# 保存前一个 guide
if current_guide is not None and current_sections:
guides.append((current_guide, current_sections))
current_guide = heading
current_sections = [(idx, doc)]
else:
# 属于当前 guide 的子 section(### 及以下)
if current_guide is not None:
current_sections.append((idx, doc))
else:
# 没有 guide 级别标题,作为独立 section
if current_sections:
guides.append((None, current_sections))
current_sections = [(idx, doc)]
current_guide = None
# 保存最后一个 guide
if current_guide is not None and current_sections:
guides.append((current_guide, current_sections))
elif current_sections:
guides.append((None, current_sections))
# 选择最相关的 guide 进行合并
if len(guides) > 1:
# ⭐ 根据查询关键词选择 guide
# 查询含 "url"/"图床"/"直连"/"链接" → 选择非正版 guide(含 url 操作方式)
# 查询含 "正版"/"官网"/"启动器"/"更新" → 选择正版 guide
# 默认选择包含最多步骤的 guide
url_keywords = {'url', '图床', '直连', '链接', '非正版'}
genuine_keywords = {'正版', '官网', '启动器', 'minecraft.net'}
best_guide_idx = 0
best_guide_score = -1
for i, (parent, sections) in enumerate(guides):
if not sections:
continue
# 计算 guide 与查询的相关性
guide_text = ' '.join(doc.get('heading', '') + ' ' + doc.get('body', '')
for _, doc in sections)
score = 0
for kw in url_keywords:
if kw in query_stripped and kw in guide_text:
score += 10
for kw in genuine_keywords:
if kw in query_stripped and kw in guide_text:
score += 10
# 优先选择步骤最多的 guide(默认行为)
# ⭐ 与step_count一致:只统计"### "级子标题步骤
step_in_guide = sum(1 for _, doc in sections
if doc.get('heading', '').startswith('### ')
and any(s in doc.get('heading', '') for s in step_headings))
score += step_in_guide * 2
if score > best_guide_score:
best_guide_score = score
best_guide_idx = i
selected_guide = guides[best_guide_idx][1]
else:
# 只有一个 guide,直接使用
selected_guide = guides[0][1] if guides else []
# 合并选中 guide 的所有步骤 section
if selected_guide:
merged_body = ""
for idx, doc in selected_guide:
for idx, doc in all_doc_sections:
heading = doc.get('heading', '')
body = doc.get('body', doc.get('content', ''))
if body and len(body) > 10:
merged_body += f"\n{heading}\n{body}\n"
if merged_body:
# 创建合并后的文档
merged_doc = selected_guide[0][1].copy()
merged_doc['body'] = merged_body.strip()
merged_doc['content'] = merged_body.strip()
# 删除 answer 字段,使用 body
if 'answer' in merged_doc:
del merged_doc['answer']
results.append((merged_doc, float(doc_sections[0][2])))
continue
# 判断是否为 guide 级别标题(# 或 ##)
if heading.startswith('# ') or heading.startswith('## '):
# 保存前一个 guide
if current_guide is not None and current_sections:
guides.append((current_guide, current_sections))
current_guide = heading
current_sections = [(idx, doc)]
else:
# 属于当前 guide 的子 section(### 及以下)
if current_guide is not None:
current_sections.append((idx, doc))
else:
# 没有 guide 级别标题,作为独立 section
if current_sections:
guides.append((None, current_sections))
current_sections = [(idx, doc)]
current_guide = None
# 保存最后一个 guide
if current_guide is not None and current_sections:
guides.append((current_guide, current_sections))
elif current_sections:
guides.append((None, current_sections))
# 选择最相关的 guide 进行合并
if len(guides) > 1:
# ⭐ 根据查询关键词选择 guide
# 查询含 "url"/"图床"/"直连"/"链接" → 选择非正版 guide(含 url 操作方式)
# 查询含 "正版"/"官网"/"启动器"/"更新" → 选择正版 guide
# 默认选择包含最多步骤的 guide
url_keywords = {'url', '图床', '直连', '链接', '非正版'}
genuine_keywords = {'正版', '官网', '启动器', 'minecraft.net'}
best_guide_idx = 0
best_guide_score = -1
for i, (parent, sections) in enumerate(guides):
if not sections:
continue
# 计算 guide 与查询的相关性
guide_text = ' '.join(doc.get('heading', '') + ' ' + doc.get('body', '')
for _, doc in sections)
score = 0
for kw in url_keywords:
if kw in query_stripped and kw in guide_text:
score += 10
for kw in genuine_keywords:
if kw in query_stripped and kw in guide_text:
score += 10
# 优先选择步骤最多的 guide(默认行为)
# ⭐ 与step_count一致:只统计"### "级子标题步骤
step_in_guide = sum(1 for _, doc in sections
if doc.get('heading', '').startswith('### ')
and any(s in doc.get('heading', '') for s in step_headings))
score += step_in_guide * 2
if score > best_guide_score:
best_guide_score = score
best_guide_idx = i
selected_guide = guides[best_guide_idx][1]
else:
# 只有一个 guide,直接使用
selected_guide = guides[0][1] if guides else []
# 合并选中 guide 的所有步骤 section
if selected_guide:
merged_body = ""
for idx, doc in selected_guide:
heading = doc.get('heading', '')
body = doc.get('body', doc.get('content', ''))
if body and len(body) > 10:
merged_body += f"\n{heading}\n{body}\n"
if merged_body:
# 创建合并后的文档
merged_doc = selected_guide[0][1].copy()
merged_doc['body'] = merged_body.strip()
merged_doc['content'] = merged_body.strip()
# 删除 answer 字段,使用 body
if 'answer' in merged_doc:
del merged_doc['answer']
results.append((merged_doc, float(doc_sections[0][2])))
continue
# 默认:从每个文档取前 1-2 个 section
sub_k = 2 if len(results) < top_k - 1 else top_k - len(results)