|
|
@@ -597,6 +597,280 @@ class LLMEvaluator:
|
|
|
|
|
|
# ========== Stage 6: 两层评估方法 ==========
|
|
|
|
|
|
+ def evaluate_query_relevance_batch(
|
|
|
+ self,
|
|
|
+ search_query: str,
|
|
|
+ notes: List[Dict[str, Any]],
|
|
|
+ max_notes: int = 20
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 第一层评估:批量判断搜索结果与 Query 的相关性
|
|
|
+
|
|
|
+ 一次 LLM 调用评估多个笔记的 Query 相关性
|
|
|
+
|
|
|
+ Args:
|
|
|
+ search_query: 搜索Query
|
|
|
+ notes: 笔记列表
|
|
|
+ max_notes: 最多评估几条笔记
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ {
|
|
|
+ "note_0": {"与query相关性": "相关", "说明": "..."},
|
|
|
+ "note_1": {"与query相关性": "不相关", "说明": "..."},
|
|
|
+ ...
|
|
|
+ }
|
|
|
+ """
|
|
|
+ if not notes:
|
|
|
+ return {}
|
|
|
+
|
|
|
+ notes_to_eval = notes[:max_notes]
|
|
|
+
|
|
|
+ # 构建笔记列表文本
|
|
|
+ notes_text = ""
|
|
|
+ for idx, note in enumerate(notes_to_eval):
|
|
|
+ note_card = note.get('note_card', {})
|
|
|
+ title = note_card.get('display_title', '')
|
|
|
+ content = note_card.get('desc', '')[:800] # 限制长度
|
|
|
+ images = note_card.get('image_list', [])
|
|
|
+
|
|
|
+ notes_text += f"note_{idx}:\n"
|
|
|
+ notes_text += f"- 标题: {title}\n"
|
|
|
+ notes_text += f"- 正文: {content}\n"
|
|
|
+ notes_text += f"- 图像: {len(images)}张图片\n\n"
|
|
|
+
|
|
|
+ # 构建完整的第一层评估 Prompt(用户提供,不简化)
|
|
|
+ prompt = f"""# 任务说明
|
|
|
+判断搜索结果是否与搜索Query相关,过滤掉完全无关的结果。
|
|
|
+
|
|
|
+# 输入信息
|
|
|
+
|
|
|
+搜索Query: {search_query}
|
|
|
+
|
|
|
+搜索结果列表:
|
|
|
+{notes_text}
|
|
|
+
|
|
|
+# 判断标准
|
|
|
+✅ 相关(保留)
|
|
|
+搜索结果的标题、正文或图像内容中包含Query相关的信息:
|
|
|
+
|
|
|
+Query的核心关键词在结果中出现
|
|
|
+或 结果讨论的主题与Query直接相关
|
|
|
+或 结果是Query概念的上位/下位/平行概念
|
|
|
+
|
|
|
+
|
|
|
+❌ 不相关(过滤)
|
|
|
+搜索结果与Query完全无关:
|
|
|
+Query的关键词完全未出现
|
|
|
+结果主题与Query无任何关联
|
|
|
+仅因搜索引擎误匹配而出现
|
|
|
+
|
|
|
+
|
|
|
+## 判断示例
|
|
|
+Query "墨镜搭配" → 结果"太阳镜选购指南" ✅ 保留(墨镜=太阳镜)
|
|
|
+Query "墨镜搭配" → 结果"眼镜搭配技巧" ✅ 保留(眼镜是墨镜的上位概念)
|
|
|
+Query "墨镜搭配" → 结果"帽子搭配技巧" ❌ 过滤(完全无关)
|
|
|
+Query "复古滤镜" → 结果"滤镜调色教程" ✅ 保留(包含滤镜)
|
|
|
+Query "复古滤镜" → 结果"相机推荐" ❌ 过滤(主题不相关)
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+{{
|
|
|
+ "note_0": {{
|
|
|
+ "与query相关性": "相关 / 不相关",
|
|
|
+ "说明": ""
|
|
|
+ }},
|
|
|
+ "note_1": {{
|
|
|
+ "与query相关性": "相关 / 不相关",
|
|
|
+ "说明": ""
|
|
|
+ }}
|
|
|
+}}
|
|
|
+
|
|
|
+# 特殊情况处理
|
|
|
+
|
|
|
+- 如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行判断
|
|
|
+- 当无法明确判断时,倾向于保留(标记为"相关")
|
|
|
+
|
|
|
+只返回JSON,不要其他内容。"""
|
|
|
+
|
|
|
+ # 调用 LLM(批量评估)
|
|
|
+ result = self.client.chat_json(
|
|
|
+ prompt=prompt,
|
|
|
+ max_retries=3
|
|
|
+ )
|
|
|
+
|
|
|
+ if result:
|
|
|
+ return result
|
|
|
+ else:
|
|
|
+ logger.error(f" 第一层批量评估失败: Query={search_query}")
|
|
|
+ # 返回默认结果(全部标记为"相关"以保守处理)
|
|
|
+ default_result = {}
|
|
|
+ for idx in range(len(notes_to_eval)):
|
|
|
+ default_result[f"note_{idx}"] = {
|
|
|
+ "与query相关性": "相关",
|
|
|
+ "说明": "LLM评估失败,默认保留"
|
|
|
+ }
|
|
|
+ return default_result
|
|
|
+
|
|
|
+ def evaluate_feature_matching_single(
|
|
|
+ self,
|
|
|
+ target_feature: str,
|
|
|
+ note_title: str,
|
|
|
+ note_content: str,
|
|
|
+ note_images: List[str],
|
|
|
+ note_index: int
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 第二层评估:评估单个笔记与目标特征的匹配度
|
|
|
+
|
|
|
+ Args:
|
|
|
+ target_feature: 目标特征
|
|
|
+ note_title: 笔记标题
|
|
|
+ note_content: 笔记正文
|
|
|
+ note_images: 图片URL列表
|
|
|
+ note_index: 笔记索引
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ {
|
|
|
+ "综合得分": 0.9, # 0-1分
|
|
|
+ "匹配类型": "完全匹配",
|
|
|
+ "评分说明": "...",
|
|
|
+ "关键匹配点": [...]
|
|
|
+ }
|
|
|
+ """
|
|
|
+ # 构建完整的第二层评估 Prompt(用户提供,不简化)
|
|
|
+ prompt = f"""# 任务说明
|
|
|
+你需要判断搜索到的案例与目标特征的相关性。
|
|
|
+
|
|
|
+# 输入信息
|
|
|
+目标特征:{target_feature}
|
|
|
+
|
|
|
+搜索结果:
|
|
|
+- 标题: {note_title}
|
|
|
+- 正文: {note_content[:800]}
|
|
|
+- 图像: {len(note_images)}张图片(请仔细分析图片内容,包括OCR提取图片中的文字)
|
|
|
+
|
|
|
+# 判断流程
|
|
|
+## 目标特征匹配度评分
|
|
|
+综合考虑语义相似度(概念匹配、层级关系)和场景关联度(应用场景、使用语境)进行评分:
|
|
|
+
|
|
|
+- 0.8-1分:完全匹配
|
|
|
+语义层面:找到与目标特征完全相同或高度一致的内容,核心概念完全一致
|
|
|
+场景层面:完全适用于同一场景、受众、平台和语境
|
|
|
+
|
|
|
+示例:
|
|
|
+目标"复古滤镜" + 小红书穿搭场景 vs 结果"小红书复古滤镜调色教程"
|
|
|
+目标"墨镜" + 时尚搭配场景 vs 结果"时尚墨镜搭配指南"
|
|
|
+
|
|
|
+
|
|
|
+- 0.6-0.7分:相似匹配
|
|
|
+语义层面:
|
|
|
+结果是目标的上位概念(更宽泛)或下位概念(更具体)
|
|
|
+或属于同一概念的不同表现形式,或属于平行概念(同级不同类)
|
|
|
+场景层面:场景相近但有差异,需要筛选或调整后可用
|
|
|
+
|
|
|
+示例:
|
|
|
+目标"墨镜" + 时尚搭配 vs 结果"眼镜搭配技巧"(上位概念,需筛选)
|
|
|
+目标"怀旧滤镜" + 人像拍摄 vs 结果"胶片感调色"(不同表现形式)
|
|
|
+目标"日常穿搭" + 街拍 vs 结果"通勤穿搭拍照"(场景相近)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+- 0.5-0.6分:弱相似
|
|
|
+语义层面:属于同一大类但具体方向或侧重点明显不同,仅提供了相关概念
|
|
|
+场景层面:场景有明显差异,迁移需要较大改造
|
|
|
+
|
|
|
+示例:
|
|
|
+目标"户外运动穿搭" vs 结果"健身房穿搭指南"
|
|
|
+目标"小红书图文笔记" vs 结果"抖音短视频脚本"
|
|
|
+
|
|
|
+
|
|
|
+- 0.4分及以下:无匹配
|
|
|
+语义层面:仅表面词汇重叠,实质关联弱,或概念距离过远
|
|
|
+场景层面:应用场景基本不同或完全不同
|
|
|
+
|
|
|
+示例:
|
|
|
+目标"墨镜" vs 结果"配饰大全"(概念过于宽泛)
|
|
|
+目标"美食摄影构图" vs 结果"美食博主日常vlog"
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+## 概念层级关系说明
|
|
|
+在评分时,需要注意概念层级关系的影响:
|
|
|
+完全匹配(同一概念 + 同场景)→ 0.8-1分
|
|
|
+目标"墨镜" vs 结果"墨镜搭配",且都在时尚搭配场景
|
|
|
+
|
|
|
+
|
|
|
+上位/下位概念(层级差一层)→ 通常0.6-0.7分
|
|
|
+目标"墨镜" vs 结果"眼镜搭配"(结果更宽泛,需筛选)
|
|
|
+目标"眼镜" vs 结果"墨镜选购"(结果更具体,部分适用)
|
|
|
+
|
|
|
+
|
|
|
+平行概念(同级不同类)→ 通常0.6-0.7分
|
|
|
+目标"墨镜" vs 结果"近视眼镜"(都是眼镜类,但功能场景不同)
|
|
|
+
|
|
|
+
|
|
|
+远距离概念(层级差两层及以上)→ 0.5分及以下
|
|
|
+目标"墨镜" vs 结果"配饰"(概念过于宽泛,指导性弱)
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+# 匹配结论判断
|
|
|
+根据综合得分判定匹配类型:
|
|
|
+
|
|
|
+0.8-1.0分:✅ 完全匹配
|
|
|
+
|
|
|
+判断:找到了目标特征的直接灵感来源
|
|
|
+建议:直接采纳为该特征的灵感溯源结果
|
|
|
+
|
|
|
+
|
|
|
+0.6-0.79分:⚠️ 相似匹配
|
|
|
+
|
|
|
+判断:找到了相关的灵感参考,但存在一定差异
|
|
|
+建议:作为候选结果保留,可与其他结果综合判断或继续搜索更精确的匹配
|
|
|
+
|
|
|
+
|
|
|
+0.59分及以下:❌ 无匹配
|
|
|
+
|
|
|
+判断:该结果与目标特征关联度不足
|
|
|
+建议:排除该结果,需要调整搜索策略继续寻找
|
|
|
+
|
|
|
+
|
|
|
+# 输出格式
|
|
|
+{{
|
|
|
+ "综合得分": 0.7,
|
|
|
+ "匹配类型": "相似匹配",
|
|
|
+ "评分说明": "结果'眼镜搭配技巧'是目标'墨镜'的上位概念,内容涵盖多种眼镜类型。场景都是时尚搭配,但需要从结果中筛选出墨镜相关的内容。概念关系:上位概念(宽泛一层)",
|
|
|
+ "关键匹配点": [
|
|
|
+ "眼镜与脸型的搭配原则(部分适用于墨镜)",
|
|
|
+ "配饰的风格选择方法"
|
|
|
+ ]
|
|
|
+}}
|
|
|
+
|
|
|
+# 特殊情况处理
|
|
|
+复合特征评估:如果目标特征是复合型(如"复古滤镜+第一人称视角"),需要分别评估每个子特征的匹配度,然后取平均值作为最终得分
|
|
|
+信息不完整:如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行评分
|
|
|
+上位概念的实用性:当结果是目标的上位概念时,评分应考虑:内容中目标相关部分的占比;是否提供了可直接应用于目标的知识;场景的一致性程度;如果结果虽是上位概念但完全不涉及目标内容,应降至5-6分或更低
|
|
|
+
|
|
|
+只返回JSON,不要其他内容。"""
|
|
|
+
|
|
|
+ # 调用 LLM(传递图片进行多模态分析)
|
|
|
+ result = self.client.chat_json(
|
|
|
+ prompt=prompt,
|
|
|
+ images=note_images if note_images else None,
|
|
|
+ max_retries=3
|
|
|
+ )
|
|
|
+
|
|
|
+ if result:
|
|
|
+ return result
|
|
|
+ else:
|
|
|
+ logger.error(f" 第二层评估失败: note {note_index}, target={target_feature}")
|
|
|
+ return {
|
|
|
+ "综合得分": 0.0,
|
|
|
+ "匹配类型": "评估失败",
|
|
|
+ "评分说明": "LLM评估失败",
|
|
|
+ "关键匹配点": []
|
|
|
+ }
|
|
|
+
|
|
|
def evaluate_note_with_filter(
|
|
|
self,
|
|
|
search_query: str,
|
|
|
@@ -852,7 +1126,10 @@ Query与目标特征的关系:
|
|
|
max_workers: int = 10
|
|
|
) -> Dict[str, Any]:
|
|
|
"""
|
|
|
- 并行评估多个笔记(两层评估)
|
|
|
+ 两层评估多个笔记(拆分为两次LLM调用)
|
|
|
+
|
|
|
+ 第一层:批量评估Query相关性(1次LLM调用)
|
|
|
+ 第二层:对"相关"的笔记评估特征匹配度(M次LLM调用)
|
|
|
|
|
|
Args:
|
|
|
search_query: 搜索Query
|
|
|
@@ -874,37 +1151,102 @@ Query与目标特征的关系:
|
|
|
}
|
|
|
|
|
|
notes_to_eval = notes[:max_notes]
|
|
|
- evaluated_notes = []
|
|
|
+ logger.info(f" 两层评估 {len(notes_to_eval)} 个笔记")
|
|
|
+
|
|
|
+ # ========== 第一层:批量评估Query相关性 ==========
|
|
|
+ logger.info(f" [第一层] 批量评估Query相关性(1次LLM调用)")
|
|
|
+ query_relevance_result = self.evaluate_query_relevance_batch(
|
|
|
+ search_query=search_query,
|
|
|
+ notes=notes_to_eval,
|
|
|
+ max_notes=max_notes
|
|
|
+ )
|
|
|
|
|
|
- logger.info(f" 并行评估 {len(notes_to_eval)} 个笔记({max_workers}并发)")
|
|
|
+ # 解析第一层结果,找出"相关"的笔记
|
|
|
+ relevant_notes_info = []
|
|
|
+ for idx, note in enumerate(notes_to_eval):
|
|
|
+ note_key = f"note_{idx}"
|
|
|
+ relevance_info = query_relevance_result.get(note_key, {})
|
|
|
+ relevance = relevance_info.get("与query相关性", "相关") # 默认为"相关"
|
|
|
|
|
|
- # 并发评估每个笔记
|
|
|
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
|
- futures = []
|
|
|
- for idx, note in enumerate(notes_to_eval):
|
|
|
+ if relevance == "相关":
|
|
|
+ # 保留笔记信息用于第二层评估
|
|
|
note_card = note.get('note_card', {})
|
|
|
- title = note_card.get('display_title', '')
|
|
|
- content = note_card.get('desc', '')
|
|
|
- images = note_card.get('image_list', [])
|
|
|
+ relevant_notes_info.append({
|
|
|
+ "note_index": idx,
|
|
|
+ "note_card": note_card,
|
|
|
+ "title": note_card.get('display_title', ''),
|
|
|
+ "content": note_card.get('desc', ''),
|
|
|
+ "images": note_card.get('image_list', []),
|
|
|
+ "第一层评估": relevance_info
|
|
|
+ })
|
|
|
+
|
|
|
+ logger.info(f" [第一层] 过滤结果: {len(relevant_notes_info)}/{len(notes_to_eval)} 条相关")
|
|
|
+
|
|
|
+ # ========== 第二层:对相关笔记评估特征匹配度 ==========
|
|
|
+ evaluated_notes = []
|
|
|
|
|
|
- future = executor.submit(
|
|
|
- self.evaluate_note_with_filter,
|
|
|
- search_query,
|
|
|
- target_feature,
|
|
|
- title,
|
|
|
- content,
|
|
|
- images,
|
|
|
- idx
|
|
|
- )
|
|
|
- futures.append(future)
|
|
|
+ if relevant_notes_info:
|
|
|
+ logger.info(f" [第二层] 并行评估特征匹配度({len(relevant_notes_info)}次LLM调用,{max_workers}并发)")
|
|
|
+
|
|
|
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
|
+ futures = []
|
|
|
+ for note_info in relevant_notes_info:
|
|
|
+ future = executor.submit(
|
|
|
+ self.evaluate_feature_matching_single,
|
|
|
+ target_feature,
|
|
|
+ note_info["title"],
|
|
|
+ note_info["content"],
|
|
|
+ note_info["images"],
|
|
|
+ note_info["note_index"]
|
|
|
+ )
|
|
|
+ futures.append((future, note_info))
|
|
|
+
|
|
|
+ # 收集结果并合并
|
|
|
+ for future, note_info in futures:
|
|
|
+ try:
|
|
|
+ second_layer_result = future.result()
|
|
|
+
|
|
|
+ # 合并两层评估结果
|
|
|
+ merged_result = {
|
|
|
+ "note_index": note_info["note_index"],
|
|
|
+ "Query相关性": "相关",
|
|
|
+ "综合得分": second_layer_result.get("综合得分", 0.0), # 0-1分制
|
|
|
+ "匹配类型": second_layer_result.get("匹配类型", ""),
|
|
|
+ "评分说明": second_layer_result.get("评分说明", ""),
|
|
|
+ "关键匹配点": second_layer_result.get("关键匹配点", []),
|
|
|
+ "第一层评估": note_info["第一层评估"],
|
|
|
+ "第二层评估": second_layer_result
|
|
|
+ }
|
|
|
+ evaluated_notes.append(merged_result)
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f" [第二层] 评估笔记 {note_info['note_index']} 失败: {e}")
|
|
|
+ # 失败的笔记也加入结果
|
|
|
+ evaluated_notes.append({
|
|
|
+ "note_index": note_info["note_index"],
|
|
|
+ "Query相关性": "相关",
|
|
|
+ "综合得分": 0.0,
|
|
|
+ "匹配类型": "评估失败",
|
|
|
+ "评分说明": f"第二层评估失败: {str(e)}",
|
|
|
+ "关键匹配点": [],
|
|
|
+ "第一层评估": note_info["第一层评估"],
|
|
|
+ "第二层评估": {}
|
|
|
+ })
|
|
|
|
|
|
- # 收集结果
|
|
|
- for future in as_completed(futures):
|
|
|
- try:
|
|
|
- result = future.result()
|
|
|
- evaluated_notes.append(result)
|
|
|
- except Exception as e:
|
|
|
- logger.error(f" 评估笔记失败: {e}")
|
|
|
+ # 添加第一层就被过滤的笔记(Query不相关)
|
|
|
+ for idx, note in enumerate(notes_to_eval):
|
|
|
+ note_key = f"note_{idx}"
|
|
|
+ relevance_info = query_relevance_result.get(note_key, {})
|
|
|
+ relevance = relevance_info.get("与query相关性", "相关")
|
|
|
+
|
|
|
+ if relevance == "不相关":
|
|
|
+ evaluated_notes.append({
|
|
|
+ "note_index": idx,
|
|
|
+ "Query相关性": "不相关",
|
|
|
+ "综合得分": 0.0,
|
|
|
+ "匹配类型": "过滤",
|
|
|
+ "说明": relevance_info.get("说明", ""),
|
|
|
+ "第一层评估": relevance_info
|
|
|
+ })
|
|
|
|
|
|
# 按note_index排序
|
|
|
evaluated_notes.sort(key=lambda x: x.get('note_index', 0))
|
|
|
@@ -914,12 +1256,12 @@ Query与目标特征的关系:
|
|
|
evaluated_count = len(evaluated_notes)
|
|
|
filtered_count = sum(1 for n in evaluated_notes if n.get('Query相关性') == '不相关')
|
|
|
|
|
|
- # 匹配度分布统计
|
|
|
+ # 匹配度分布统计(使用0-1分制的阈值)
|
|
|
match_distribution = {
|
|
|
- '完全匹配(8-10)': 0,
|
|
|
- '相似匹配(6-7)': 0,
|
|
|
- '弱相似(5-6)': 0,
|
|
|
- '无匹配(≤4)': 0
|
|
|
+ '完全匹配(0.8-1.0)': 0,
|
|
|
+ '相似匹配(0.6-0.79)': 0,
|
|
|
+ '弱相似(0.5-0.59)': 0,
|
|
|
+ '无匹配(≤0.4)': 0
|
|
|
}
|
|
|
|
|
|
for note_eval in evaluated_notes:
|
|
|
@@ -927,14 +1269,14 @@ Query与目标特征的关系:
|
|
|
continue # 过滤的不计入分布
|
|
|
|
|
|
score = note_eval.get('综合得分', 0)
|
|
|
- if score >= 8.0:
|
|
|
- match_distribution['完全匹配(8-10)'] += 1
|
|
|
- elif score >= 6.0:
|
|
|
- match_distribution['相似匹配(6-7)'] += 1
|
|
|
- elif score >= 5.0:
|
|
|
- match_distribution['弱相似(5-6)'] += 1
|
|
|
+ if score >= 0.8:
|
|
|
+ match_distribution['完全匹配(0.8-1.0)'] += 1
|
|
|
+ elif score >= 0.6:
|
|
|
+ match_distribution['相似匹配(0.6-0.79)'] += 1
|
|
|
+ elif score >= 0.5:
|
|
|
+ match_distribution['弱相似(0.5-0.59)'] += 1
|
|
|
else:
|
|
|
- match_distribution['无匹配(≤4)'] += 1
|
|
|
+ match_distribution['无匹配(≤0.4)'] += 1
|
|
|
|
|
|
logger.info(f" 评估完成: 过滤{filtered_count}条, 匹配分布: {match_distribution}")
|
|
|
|