init: RAG 知识库服务初始提交
- 后端 API(Flask + Gunicorn) - RAG 引擎(混合检索 + 云端 Reranker + 引用溯源) - 文档解析(MinerU + 多格式支持) - Docker 生产部署配置 - 排除前端项目、敏感配置、模型文件
This commit is contained in:
223
core/mmr.py
Normal file
223
core/mmr.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# -*- 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 mmr_filter_by_content(
|
||||
candidates: List[Dict],
|
||||
top_k: int = 30,
|
||||
similarity_threshold: float = 0.9
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
基于内容相似度的去重(简化版,不需要 embedding)
|
||||
|
||||
适用于:
|
||||
- 没有 embedding 的情况
|
||||
- 快速去重场景
|
||||
|
||||
Args:
|
||||
candidates: 候选文档列表
|
||||
top_k: 返回数量
|
||||
similarity_threshold: 相似度阈值,超过则视为重复
|
||||
|
||||
Returns:
|
||||
去重后的候选文档列表
|
||||
"""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
if len(candidates) <= top_k:
|
||||
return candidates
|
||||
|
||||
selected = []
|
||||
remaining = candidates.copy()
|
||||
|
||||
while len(selected) < top_k and remaining:
|
||||
current = remaining.pop(0)
|
||||
|
||||
# 检查是否与已选内容重复
|
||||
is_duplicate = False
|
||||
current_content = current.get('content', current.get('document', ''))[:200]
|
||||
|
||||
for s in selected:
|
||||
s_content = s.get('content', s.get('document', ''))[:200]
|
||||
|
||||
# 简单的 Jaccard 相似度
|
||||
words1 = set(current_content)
|
||||
words2 = set(s_content)
|
||||
if words1 and words2:
|
||||
intersection = len(words1 & words2)
|
||||
union = len(words1 | words2)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
|
||||
if similarity > similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
selected.append(current)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user