Files
rag/core/mmr.py
lacerate551 8c92657490 fix(bm25): 修复 BM25 索引覆盖 bug + 综合评测集 v2 + 检索增强
核心修复:
- 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: 解析器改进
2026-06-17 17:31:43 +08:00

253 lines
7.3 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
"""
MMRMax 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}")