核心修复: - knowledge/base.py: BM25Index.add_documents 从覆盖改为追加+去重, 修复只有最后上传文件的 chunks 保留在 BM25 中的严重 bug (影响: 2.docx/3.docx/PDF 的 755 个 chunk 在 BM25 中完全缺失) 检索增强 (延续上次会话): - core/engine.py: section cluster boost + lexical match exemption - api/chat_routes.py: lexical/cluster rescue 层 + SSE 事件 - core/mmr.py: MMR 去重改进 评测体系: - tests/eval_dataset_v2.json: 62 题综合评测集 (9 种题型×4 文档) - scripts/eval_e2e.py: 推理模型 LLM 评分兼容 + 新数据集格式支持 - scripts/validate_eval_dataset.py: 数据集验证工具 其他: - parsers/mineru_parser.py: 解析器改进
253 lines
7.3 KiB
Python
253 lines
7.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
MMR(Max Marginal Relevance)去重模块
|
||
|
||
功能:
|
||
- 平衡相关性和多样性
|
||
- 避免重复内容占据 top_k 结果
|
||
- 前置到 rerank 之前,减少 rerank 输入量
|
||
|
||
使用场景:
|
||
召回 100 个 → MMR 去重取 30 个 → rerank 取 top_k
|
||
"""
|
||
|
||
import numpy as np
|
||
from typing import List, Dict, Tuple, Optional
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
|
||
"""计算两个向量的余弦相似度"""
|
||
norm1 = np.linalg.norm(vec1)
|
||
norm2 = np.linalg.norm(vec2)
|
||
if norm1 == 0 or norm2 == 0:
|
||
return 0.0
|
||
return float(np.dot(vec1, vec2) / (norm1 * norm2))
|
||
|
||
|
||
def mmr_rerank(
|
||
query_emb: np.ndarray,
|
||
candidates: List[Dict],
|
||
top_k: int = 30,
|
||
lambda_param: float = 0.5,
|
||
emb_key: str = 'embedding'
|
||
) -> List[Dict]:
|
||
"""
|
||
Max Marginal Relevance 去重
|
||
|
||
公式: MMR = λ * Relevance - (1-λ) * Max_Similarity
|
||
|
||
Args:
|
||
query_emb: 查询向量
|
||
candidates: 候选文档列表,每个文档需包含 embedding
|
||
top_k: 返回数量
|
||
lambda_param: 相关性/多样性权衡参数 (0-1)
|
||
- 1.0: 只考虑相关性
|
||
- 0.5: 平衡相关性和多样性
|
||
- 0.0: 只考虑多样性
|
||
emb_key: embedding 在候选文档中的 key
|
||
|
||
Returns:
|
||
去重后的候选文档列表
|
||
"""
|
||
if not candidates:
|
||
return []
|
||
|
||
if len(candidates) <= top_k:
|
||
return candidates
|
||
|
||
selected = []
|
||
remaining = candidates.copy()
|
||
|
||
while len(selected) < top_k and remaining:
|
||
mmr_scores = []
|
||
|
||
for i, cand in enumerate(remaining):
|
||
# 获取候选文档的 embedding
|
||
cand_emb = cand.get(emb_key)
|
||
if cand_emb is None:
|
||
# 没有 embedding,跳过或使用默认分数
|
||
mmr_scores.append(-float('inf'))
|
||
continue
|
||
|
||
cand_emb = np.array(cand_emb)
|
||
|
||
# 1. 相关性:与查询的相似度
|
||
relevance = cosine_similarity(query_emb, cand_emb)
|
||
|
||
# 2. 冗余度:与已选文档的最大相似度
|
||
if selected:
|
||
# 过滤出有 embedding 的已选文档
|
||
selected_with_emb = [s for s in selected if s.get(emb_key) is not None]
|
||
if selected_with_emb:
|
||
max_sim = max(
|
||
cosine_similarity(cand_emb, np.array(s.get(emb_key)))
|
||
for s in selected_with_emb
|
||
)
|
||
else:
|
||
max_sim = 0.0
|
||
else:
|
||
max_sim = 0.0
|
||
|
||
# 3. MMR 分数 = λ * 相关性 - (1-λ) * 冗余度
|
||
mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim
|
||
mmr_scores.append(mmr_score)
|
||
|
||
# 选择 MMR 分数最高的
|
||
if mmr_scores:
|
||
best_idx = np.argmax(mmr_scores)
|
||
if mmr_scores[best_idx] > -float('inf'):
|
||
selected.append(remaining.pop(best_idx))
|
||
else:
|
||
# 所有候选都没有 embedding,直接取前 top_k
|
||
selected.extend(remaining[:top_k - len(selected)])
|
||
break
|
||
|
||
return selected
|
||
|
||
|
||
def _tokenize_words(text: str) -> set:
|
||
"""
|
||
使用 jieba 分词并过滤噪声,返回有意义的词集合。
|
||
|
||
过滤规则:
|
||
- 去除单字符词(如 "的", "了", "在")—— 这些是停用词,对区分文档无意义
|
||
- 去除纯数字 / 纯标点
|
||
- 保留 2 字及以上的实词
|
||
"""
|
||
import jieba
|
||
words = set()
|
||
for w in jieba.cut(text):
|
||
w = w.strip()
|
||
if len(w) >= 2 and not w.isdigit():
|
||
words.add(w)
|
||
return words
|
||
|
||
|
||
def mmr_filter_by_content(
|
||
candidates: List[Dict],
|
||
top_k: int = 30,
|
||
similarity_threshold: float = 0.85
|
||
) -> List[Dict]:
|
||
"""
|
||
基于 jieba 词级 Jaccard 相似度的去重(不需要 embedding)
|
||
|
||
与旧版字符级 set(text) 的区别:
|
||
- 旧版:set("安全生产管理制度") → {'安','全','生','产',...},中文文档间字符集合高度重叠
|
||
- 新版:jieba 分词 → {"安全生产", "管理制度", ...},词级集合区分度高
|
||
|
||
适用于:
|
||
- MMR_USE_EMBEDDING=False 时的快速去重
|
||
- 避免 CPU 编码 100+ 文档的 50 秒开销
|
||
|
||
Args:
|
||
candidates: 候选文档列表
|
||
top_k: 返回数量
|
||
similarity_threshold: 相似度阈值,超过则视为重复(默认 0.85)
|
||
|
||
Returns:
|
||
去重后的候选文档列表
|
||
"""
|
||
if not candidates:
|
||
return []
|
||
|
||
if len(candidates) <= top_k:
|
||
return candidates
|
||
|
||
# 预分词:对所有候选文档一次性分词,避免重复调用 jieba.cut
|
||
word_sets = []
|
||
for c in candidates:
|
||
content = c.get('content', c.get('document', ''))[:500]
|
||
word_sets.append(_tokenize_words(content))
|
||
|
||
selected_indices = []
|
||
|
||
for i in range(len(candidates)):
|
||
if len(selected_indices) >= top_k:
|
||
break
|
||
|
||
current_words = word_sets[i]
|
||
if not current_words:
|
||
# 空内容直接保留
|
||
selected_indices.append(i)
|
||
continue
|
||
|
||
is_duplicate = False
|
||
for j in selected_indices:
|
||
selected_words = word_sets[j]
|
||
if not selected_words:
|
||
continue
|
||
|
||
intersection = len(current_words & selected_words)
|
||
union = len(current_words | selected_words)
|
||
similarity = intersection / union if union > 0 else 0
|
||
|
||
if similarity > similarity_threshold:
|
||
is_duplicate = True
|
||
break
|
||
|
||
if not is_duplicate:
|
||
selected_indices.append(i)
|
||
|
||
return [candidates[i] for i in selected_indices]
|
||
|
||
|
||
# ==================== 测试 ====================
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
if sys.platform == 'win32':
|
||
sys.stdout.reconfigure(encoding='utf-8')
|
||
|
||
print("=" * 60)
|
||
print("MMR 去重测试")
|
||
print("=" * 60)
|
||
|
||
# 模拟候选文档
|
||
np.random.seed(42)
|
||
|
||
def random_embedding():
|
||
emb = np.random.randn(768)
|
||
return emb / np.linalg.norm(emb)
|
||
|
||
query_emb = random_embedding()
|
||
|
||
# 创建 10 个候选,前 5 个相似
|
||
base_emb = random_embedding()
|
||
candidates = []
|
||
|
||
for i in range(10):
|
||
if i < 5:
|
||
# 前 5 个与 query 相似
|
||
emb = query_emb + np.random.randn(768) * 0.1
|
||
else:
|
||
# 后 5 个与 query 不太相似
|
||
emb = random_embedding()
|
||
|
||
candidates.append({
|
||
'id': f'doc_{i}',
|
||
'content': f'文档内容 {i}',
|
||
'embedding': emb / np.linalg.norm(emb)
|
||
})
|
||
|
||
print(f"\n候选数量: {len(candidates)}")
|
||
|
||
# MMR 去重
|
||
selected = mmr_rerank(query_emb, candidates, top_k=5, lambda_param=0.5)
|
||
|
||
print(f"MMR 选择数量: {len(selected)}")
|
||
print(f"选择的文档 ID: {[c['id'] for c in selected]}")
|
||
|
||
# 计算多样性
|
||
embs = [c['embedding'] for c in selected]
|
||
diversity_scores = []
|
||
for i in range(len(embs)):
|
||
for j in range(i + 1, len(embs)):
|
||
sim = cosine_similarity(embs[i], embs[j])
|
||
diversity_scores.append(sim)
|
||
|
||
avg_similarity = np.mean(diversity_scores) if diversity_scores else 0
|
||
print(f"平均相似度(越低多样性越高): {avg_similarity:.3f}")
|