llm_evaluator.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. LLM评估模块
  5. 用于评估搜索词质量和搜索结果相关度
  6. """
  7. import logging
  8. from typing import List, Dict, Any, Optional
  9. from concurrent.futures import ThreadPoolExecutor, as_completed
  10. from openrouter_client import OpenRouterClient
  11. logger = logging.getLogger(__name__)
  12. class LLMEvaluator:
  13. """LLM评估器"""
  14. def __init__(self, openrouter_client: OpenRouterClient):
  15. """
  16. 初始化评估器
  17. Args:
  18. openrouter_client: OpenRouter客户端实例
  19. """
  20. self.client = openrouter_client
  21. def evaluate_search_word(
  22. self,
  23. original_feature: str,
  24. search_word: str
  25. ) -> Dict[str, Any]:
  26. """
  27. 评估搜索词质量(阶段4)
  28. Args:
  29. original_feature: 原始特征名称
  30. search_word: 组合搜索词
  31. Returns:
  32. 评估结果
  33. """
  34. prompt = f"""你是一个小红书内容分析专家。
  35. # 任务说明
  36. 从给定关键词中提取并组合适合在小红书搜索的query词(目标是找到【{original_feature}】相关内容,但query中不能直接出现"{original_feature}")
  37. ## 可选词汇
  38. {search_word}
  39. ## 要求
  40. 1. 只能使用可选词汇中的词,可以进行以下变化:
  41. - 直接使用原词或括号内的同义词
  42. - 多个词组合
  43. - 适当精简
  44. 2. 不能添加可选词汇以外的新词
  45. 3. 按推荐程度排序(越靠前越推荐)
  46. ## 输出格式(JSON)
  47. {{
  48. "score": 0.75,
  49. "reasoning": "评估理由"
  50. }}
  51. 注意:只返回JSON,不要其他内容。"""
  52. result = self.client.chat_json(prompt=prompt, max_retries=3)
  53. if result:
  54. return {
  55. "score": result.get("score", 0.0),
  56. "reasoning": result.get("reasoning", ""),
  57. "original_feature": original_feature
  58. }
  59. else:
  60. logger.error(f"评估搜索词失败: {search_word}")
  61. return {
  62. "score": 0.0,
  63. "reasoning": "LLM评估失败",
  64. "original_feature": original_feature
  65. }
  66. def evaluate_search_words_batch(
  67. self,
  68. original_feature: str,
  69. search_words: List[str],
  70. max_workers: int = 5
  71. ) -> List[Dict[str, Any]]:
  72. """
  73. 批量评估搜索词(并行)
  74. Args:
  75. original_feature: 原始特征
  76. search_words: 搜索词列表
  77. max_workers: 最大并发数
  78. Returns:
  79. 评估结果列表(已排序)
  80. """
  81. logger.info(f"开始批量评估 {len(search_words)} 个搜索词...")
  82. results = []
  83. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  84. # 提交任务
  85. future_to_word = {
  86. executor.submit(self.evaluate_search_word, original_feature, word): word
  87. for word in search_words
  88. }
  89. # 收集结果
  90. for idx, future in enumerate(as_completed(future_to_word), 1):
  91. word = future_to_word[future]
  92. try:
  93. result = future.result()
  94. result["search_word"] = word
  95. results.append(result)
  96. logger.info(f" [{idx}/{len(search_words)}] {word}: {result['score']:.3f}")
  97. except Exception as e:
  98. logger.error(f" 评估失败: {word}, 错误: {e}")
  99. results.append({
  100. "search_word": word,
  101. "score": 0.0,
  102. "reasoning": f"评估异常: {str(e)}",
  103. "original_feature": original_feature
  104. })
  105. # 按分数排序
  106. results.sort(key=lambda x: x["score"], reverse=True)
  107. # 添加排名
  108. for rank, result in enumerate(results, 1):
  109. result["rank"] = rank
  110. logger.info(f"批量评估完成,最高分: {results[0]['score']:.3f}")
  111. return results
  112. def evaluate_search_words_in_batches(
  113. self,
  114. original_feature: str,
  115. search_words: List[str],
  116. batch_size: int = 50,
  117. base_word: str = ""
  118. ) -> List[Dict[str, Any]]:
  119. """
  120. 分批评估搜索词(每批N个,减少API调用)
  121. Args:
  122. original_feature: 原始特征
  123. search_words: 搜索词列表
  124. batch_size: 每批处理的搜索词数量,默认10
  125. base_word: 中心词(如果提供,要求所有组合必须包含此词)
  126. Returns:
  127. 评估结果列表(已排序)
  128. """
  129. logger.info(f"开始分批评估 {len(search_words)} 个搜索词(每批 {batch_size} 个)...")
  130. all_results = []
  131. total_batches = (len(search_words) + batch_size - 1) // batch_size
  132. # 分批处理
  133. for batch_idx in range(total_batches):
  134. start_idx = batch_idx * batch_size
  135. end_idx = min(start_idx + batch_size, len(search_words))
  136. batch_words = search_words[start_idx:end_idx]
  137. logger.info(f" 处理第 {batch_idx + 1}/{total_batches} 批({len(batch_words)} 个搜索词)")
  138. # 从搜索词中提取所有独特的词作为可选词汇
  139. available_words_set = set()
  140. for word in batch_words:
  141. # 分割搜索词,提取单个词
  142. parts = word.split()
  143. available_words_set.update(parts)
  144. # 转换为列表并排序(保证稳定性)
  145. available_words = sorted(list(available_words_set))
  146. # 构建可选词汇字符串(逗号分隔)
  147. available_words_str = "、".join(available_words)
  148. # 构建 base_word 约束
  149. base_word_constraint = ""
  150. if base_word:
  151. base_word_constraint = f"""
  152. ## 中心词约束(重要)
  153. - 所有组合词都基于中心词: **{base_word}**
  154. - **禁止去掉中心词**,你只负责评分和排序
  155. - source_word 必须包含 "{base_word}"
  156. """
  157. prompt = f"""
  158. # 任务说明
  159. 模拟你是一个内容创作者,评估并排序这些基于中心词的搜索组合。
  160. {base_word_constraint}
  161. ## 可选词汇
  162. {available_words_str}
  163. ## 要求
  164. 1. 只能使用可选词汇中的词,可以进行以下变化:
  165. - 直接使用原词或括号内的同义词
  166. - 多个词组合
  167. - 适当精简
  168. 2. **source_word 必须包含中心词 "{base_word}"**(如果提供了中心词)
  169. 3. 不能添加可选词汇以外的新词
  170. 4. 按推荐程度排序(越靠前越推荐),取top5
  171. ## 输出格式(JSON):
  172. [
  173. {{
  174. "rank": 1,
  175. "search_word": "组合的搜索词",
  176. "source_word": "组合来源词,空格分割,组合来源词都是从available_words_str中选取的",
  177. "score": 0.85,
  178. "reasoning": "推荐理由"
  179. }},
  180. {{
  181. "index": 2,
  182. "search_word": "组合的搜索词",
  183. "source_word": "组合来源词,空格分割,组合来源词都是从available_words_str中选取的",
  184. "score": 0.80,
  185. "reasoning": "推荐理由"
  186. }}
  187. ]
  188. - 只返回JSON数组,不要其他内容"""
  189. # 调用LLM
  190. result = self.client.chat_json(prompt=prompt, max_retries=3)
  191. if result and isinstance(result, list):
  192. # 处理结果 - 新格式直接包含search_word
  193. for idx, item in enumerate(result):
  194. search_word = item.get("search_word", "")
  195. if search_word: # 确保有搜索词
  196. all_results.append({
  197. "search_word": search_word,
  198. "source_word": item.get("source_word", ""),
  199. "score": item.get("score", 0.0),
  200. "reasoning": item.get("reasoning", ""),
  201. "original_feature": original_feature
  202. })
  203. logger.info(f" [{start_idx + idx + 1}/{len(search_words)}] "
  204. f"{search_word}: {item.get('score', 0.0):.3f}")
  205. else:
  206. logger.error(f" 第 {batch_idx + 1} 批评估失败,跳过")
  207. # 为失败的批次添加默认结果(使用原搜索词)
  208. for word in batch_words:
  209. all_results.append({
  210. "search_word": word,
  211. "score": 0.0,
  212. "reasoning": "批量评估失败",
  213. "original_feature": original_feature
  214. })
  215. # 按分数排序
  216. all_results.sort(key=lambda x: x["score"], reverse=True)
  217. # 添加排名
  218. for rank, result in enumerate(all_results, 1):
  219. result["rank"] = rank
  220. logger.info(f"分批评估完成,最高分: {all_results[0]['score']:.3f} (总API调用: {total_batches} 次)")
  221. return all_results
  222. def generate_queries_from_candidates(
  223. self,
  224. original_feature: str,
  225. base_word: str,
  226. candidate_words: List[str],
  227. max_queries: int = 10
  228. ) -> List[Dict[str, Any]]:
  229. """
  230. 基于中心词和候选词列表,让LLM生成搜索query
  231. Args:
  232. original_feature: 原始特征名称
  233. base_word: 中心词
  234. candidate_words: 候选词列表
  235. max_queries: 最大query数量
  236. Returns:
  237. query数组(与旧格式兼容)
  238. """
  239. logger.info(f"LLM生成query(中心词: {base_word}, 候选词: {len(candidate_words)}个)")
  240. candidate_words_str = "、".join(candidate_words)
  241. prompt = f"""# 角色定位
  242. 你是"内容创作搜索顾问"。任务:
  243. 围绕中心词为主体,并结合待选词中明显的 高频词/高权重词,生成完整、不重不漏、可检索的 query。
  244. 核心流程:
  245. 先判断搜索类型(具体案例 / 案例集合) → 再围绕"中心词 + 高权重词(如果有)"生成 query → 完整覆盖 → 去重 → 输出。
  246. # 输入
  247. 中心词:{base_word}
  248. 待选词:{candidate_words_str}
  249. # 核心原则
  250. 1.中心词优先原则:所有 query 必须围绕中心词构造,中心词在所有 query 的出现率必须 ≥ 80%
  251. 2.高权重词优先构造:对待选词做频次分析:同义/包含关系归并后,出现次数最高的词为"高权重主体词",若存在高权重词 → 所有 query 必须围绕 中心词 + 高权重词,若无高权重词 → 使用中心词 + 去重词合理组合
  252. 3. **去重不漏** - 去重同义、保留关键差异、所有有效组合需覆盖
  253. 4. **query是问题** - 不包含多模态信息,例如XXvlog、视频等,如原始输入中不存在此类信息则不加入query
  254. 5. 组合需有语义逻辑:不能随机堆词,query 必须是自然、可搜索的有真实含义的问题句
  255. 6.主体与场景必须出现之一:若中心词是场景 → 等同主体优先级
  256. # 处理流程
  257. ## Step 1: 词汇分析与去重
  258. 对输入的词汇进行分类和合并:
  259. **分类维度**:
  260. - **主体类**: 内容核心对象(猫咪、美食、旅行地)
  261. - **手法类**: 创作表现方式(拟人化、对比、测评)
  262. - **特征类**: 风格特点(反差、温馨、搞笑)
  263. - **场景类**: 具体情境(穿衣服、戴墨镜、吃饭)
  264. - **行为类**: 创作动作(分享、记录、教程)
  265. **核心主体识别规则**:
  266. 中心词默认最高优先级
  267. 待选词中出现频次最高者 = 高权重主体词
  268. Query 必须围绕:中心词 + 高权重词(如果有)
  269. **去重规则**:
  270. - 同义词: 猫/猫咪 → 猫咪
  271. - 包含关系归纳: 宠物猫咪/猫咪 → 猫咪
  272. - 修饰词判断: 若修饰词不改变核心意图则去除,若改变则保留
  273. **原词保留原则**:
  274. - 所有 query 中语言必须来自清洗后的词汇,允许添加:
  275. 连接词(的、和、与)
  276. 必要动词(分享、展示、记录)
  277. 集合词(有哪些、合集、大全)
  278. - 禁止任何同义替换。: 猫咪✗改为宠物猫/小猫, 服饰✗改为穿搭/衣服
  279. ## Step 2: 词汇关系分析
  280. **目标**: 确定哪些词汇可以合理组合
  281. **关系判断规则**:
  282. 1. **强关联** 可直接组合:
  283. - 中心词 + 高权重主体词(必选)
  284. - 中心词 + 特征词
  285. - 高权重主体词 + 特征词
  286. 2. **中等关联** 需通过主体连接:
  287. - 中心词 + 主体 + 特征
  288. 3. **禁止组合**
  289. - 特征 + 特征;特征独立成句;与中心词无关系的随机组合
  290. ## Step 3: 判断搜索类型
  291. 根据词汇的**具体化程度**判断搜索粒度:
  292. 1、THEN → 类型: specific_case (具体案例)
  293. IF 满足以下任一条件:
  294. 1. 包含具体场景/道具/动作
  295. 2. 词汇组合后可想象出明确画面
  296. 3. 描述足够详细,指向单一呈现形式
  297. **创作者需求**:
  298. 找一个可以直接参考模仿的成品案例,想看"就是这样的内容"
  299. 2、THEN → 类型: case_collection (案例集合)
  300. ELSE IF 满足以下条件:
  301. 1. 只有主体 + 手法/特征,缺少具体场景
  302. 2. 词汇组合较抽象,无法想象单一画面
  303. 3. 需要看到多个变化形式
  304. **创作者需求**:
  305. 了解这一类内容有哪些玩法,想看"这种类型都有什么"
  306. ## Step 4: 生成完整Query列表
  307. ### 核心原则: 词汇组合完整覆盖
  308. **中心词权重规则**:
  309. - 中心词必须出现在≥80%的query中
  310. **组词逻辑规则**:
  311. - 每个query必须遵循词汇关系矩阵中的强关联或中等关联
  312. - 找案例不是找方法,query需明确找案例、案例集、集合、有哪些等适配case和案例集类型的词汇
  313. **严格去重规则**:
  314. - 提取每个query的核心要素: [主体]+[场景/手法]+[特征/行为]
  315. - 两个query的核心要素若完全相同或高度重叠(≥2个要素相同),则判定为重复
  316. - 生成每个新query时立即与已生成的query对比,重复则舍弃
  317. - 判断标准: 搜索意图是否相同,而非文字是否相同
  318. **覆盖策略**:
  319. 1. **主干组合** - 主体+核心手法/场景 必须覆盖
  320. 2. **特征叠加** - 在主干上叠加不同特征词
  321. 3. **表述多样** - 同一组合用不同表述方式
  322. 4. **避免重复** - 去除语义相同的query
  323. **原词保真规则**:
  324. - 只能使用去重后词汇清单中的词汇
  325. - 不允许用同义词替换原词
  326. - 允许添加的词: 连接词(的、和、与)、必要动词(分享、展示)、集合词(有哪些、合集)
  327. - 每生成一个query立即检查是否包含不在清单中的新概念词,若有则删除该query
  328. #### A类Query生成规则(具体案例)
  329. **结构**: 主体 + 具体场景/道具 + 手法/特征
  330. **长度**: 6-15字
  331. **语言风格**: 描述性、具象化
  332. **数量要求**: 根据去重后词汇丰富度生成,确保覆盖所有有意义的组合
  333. - 词汇简单(2-8个): 生成2-4个query
  334. - 词汇中等(9-12个): 生成4-6个query
  335. - 词汇丰富(12+个): 生成6-10个query
  336. #### B类Query生成规则(案例集合)
  337. **结构**: 主体 + 手法/特征 + 集合词
  338. **长度**: 6-12字
  339. **数量要求**: 根据去重后词汇丰富度生成
  340. - 词汇简单(2-8个): 生成2-4个query
  341. - 词汇中等(8-10个): 生成4-6个query
  342. - 词汇丰富(10+个): 生成6-10个query
  343. ## 质量检查标准
  344. 生成query后,必须进行覆盖度检查:
  345. **检查清单**:
  346. 1. 词汇覆盖检查:
  347. - 列出所有去重后的词汇
  348. - 标注每个词汇出现在哪些query中
  349. - 确保去重后每个词汇至少被使用1次
  350. 2. 组合覆盖检查:
  351. - 逐个检查query是否符合词汇关系矩阵
  352. - 检查是否存在弱关联或无关联的词汇组合
  353. - 弱关联组合 → 删除或重写
  354. 3. 重复检查:
  355. - 提取每个query的核心要素
  356. - 两两对比核心要素
  357. - 核心要素一致 → 删除
  358. 4.原词保真检查
  359. - 拆解每个query的词汇
  360. - 验证每个实词是否在去重后清单中
  361. - 允许存在的词: 连接词、动词、集合词
  362. - 不允许存在的词: 同义替换词、新概念词
  363. - 发现不允许的词 → 删除该query或替换回原词
  364. 5. 补充生成:
  365. 词汇未覆盖 / 关键组合缺失 → 补充生成
  366. # 输出
  367. 最终按以下格式输出结果(JSON数组格式):
  368. [
  369. {{
  370. "search_word": "猫咪服饰造型元素有哪些",
  371. "中心词": "服饰造型元素",
  372. "source_word": "猫 猫咪 服饰造型元素 传递快乐 宠物猫咪 猫咪宠物 猫咪主体",
  373. "reasoning": "判断依据说明"
  374. }},
  375. {{
  376. "search_word": "猫咪传递快乐的服饰造型元素",
  377. "中心词": "服饰造型元素",
  378. "source_word": "猫 猫咪 服饰造型元素 传递快乐 宠物猫咪 猫咪宠物 猫咪主体",
  379. "reasoning": "判断依据说明"
  380. }}
  381. ]
  382. **source_word规则**(重要):
  383. 1. 格式:空格分隔的词汇
  384. 2. 来源:**必须且只能**从"中心词 + 待选词"中提取
  385. 3. 提取规则:该query实际使用到的所有原始词汇
  386. 4. 禁止:同义替换、添加新词
  387. 5. 必须包含:中心词(如果query中使用了中心词)
  388. # 执行顺序
  389. 词汇分析 → 中心词确定 → 高权重词识别 → 关系分析 → 类型判定 →
  390. 围绕"中心词+高权重词"生成 query → 质量检查 → 补充 → 输出
  391. 注意:只返回JSON数组,不要其他内容。"""
  392. # 调用 LLM
  393. llm_results = self.client.chat_json(prompt=prompt, max_retries=3)
  394. if not llm_results or not isinstance(llm_results, list):
  395. logger.error("LLM返回格式错误")
  396. return []
  397. logger.info(f"LLM生成了 {len(llm_results)} 个query")
  398. # 解析并验证
  399. formatted_results = []
  400. for rank, item in enumerate(llm_results[:max_queries], 1):
  401. validated_source_word = self._validate_and_fix_source_word(
  402. llm_source_word=item.get("source_word", ""),
  403. query=item.get("search_word", ""),
  404. base_word=base_word,
  405. candidate_words=candidate_words
  406. )
  407. formatted_results.append({
  408. "search_word": item.get("search_word", ""),
  409. "source_word": validated_source_word,
  410. "score": 0.0,
  411. "reasoning": item.get("reasoning", ""),
  412. "rank": rank,
  413. "original_feature": original_feature
  414. })
  415. return formatted_results
  416. def _validate_and_fix_source_word(
  417. self,
  418. llm_source_word: str,
  419. query: str,
  420. base_word: str,
  421. candidate_words: List[str]
  422. ) -> str:
  423. """
  424. 验证并修正 LLM 输出的 source_word
  425. 确保只包含"中心词 + 候选词"中的词
  426. Args:
  427. llm_source_word: LLM 输出的 source_word
  428. query: 生成的 search_word
  429. base_word: 中心词
  430. candidate_words: 候选词列表
  431. Returns:
  432. 验证后的 source_word
  433. """
  434. words = llm_source_word.split()
  435. valid_words = []
  436. # 验证每个词是否在允许列表中
  437. for word in words:
  438. if word == base_word or word in candidate_words:
  439. valid_words.append(word)
  440. # 确保中心词存在(如果query中包含)
  441. if base_word in query and base_word not in valid_words:
  442. valid_words.insert(0, base_word)
  443. # 去重
  444. seen = set()
  445. deduplicated = []
  446. for word in valid_words:
  447. if word not in seen:
  448. seen.add(word)
  449. deduplicated.append(word)
  450. return ' '.join(deduplicated)
  451. def evaluate_single_note(
  452. self,
  453. original_feature: str,
  454. search_word: str,
  455. note: Dict[str, Any],
  456. note_index: int = 0
  457. ) -> Dict[str, Any]:
  458. """
  459. 评估单个帖子(阶段6,多模态)
  460. Args:
  461. original_feature: 原始特征
  462. search_word: 搜索词
  463. note: 单个帖子
  464. note_index: 帖子索引
  465. Returns:
  466. 单个帖子的评估结果
  467. """
  468. card = note.get("note_card", {})
  469. title = card.get("display_title", "")
  470. desc = card.get("desc", "")[:500] # 限制长度
  471. images = card.get("image_list", [])[:10] # 最多10张图
  472. prompt = f"""你是一个小红书内容分析专家。
  473. 任务:评估这个帖子是否包含目标特征"{original_feature}"的元素
  474. 原始特征:"{original_feature}"
  475. 搜索词:"{search_word}"
  476. 帖子内容:
  477. 标题: {title}
  478. 正文: {desc}
  479. 请分析帖子的文字和图片内容,返回JSON格式:
  480. {{
  481. "relevance": 0.85, // 0.0-1.0,相关度
  482. "matched_elements": ["元素1", "元素2"], // 匹配的元素列表
  483. "reasoning": "简短的匹配理由"
  484. }}
  485. 只返回JSON,不要其他内容。"""
  486. result = self.client.chat_json(
  487. prompt=prompt,
  488. images=images if images else None,
  489. max_retries=3
  490. )
  491. if result:
  492. return {
  493. "note_index": note_index,
  494. "relevance": result.get("relevance", 0.0),
  495. "matched_elements": result.get("matched_elements", []),
  496. "reasoning": result.get("reasoning", "")
  497. }
  498. else:
  499. logger.error(f" 评估帖子 {note_index} 失败: {search_word}")
  500. return {
  501. "note_index": note_index,
  502. "relevance": 0.0,
  503. "matched_elements": [],
  504. "reasoning": "评估失败"
  505. }
  506. def evaluate_search_results_parallel(
  507. self,
  508. original_feature: str,
  509. search_word: str,
  510. notes: List[Dict[str, Any]],
  511. max_notes: int = 20,
  512. max_workers: int = 20
  513. ) -> Dict[str, Any]:
  514. """
  515. 并行评估搜索结果(每个帖子独立评估)
  516. Args:
  517. original_feature: 原始特征
  518. search_word: 搜索词
  519. notes: 帖子列表
  520. max_notes: 最多评估几条帖子
  521. max_workers: 最大并发数
  522. Returns:
  523. 评估结果汇总
  524. """
  525. if not notes:
  526. return {
  527. "overall_relevance": 0.0,
  528. "extracted_elements": [],
  529. "evaluated_notes": []
  530. }
  531. notes_to_eval = notes[:max_notes]
  532. evaluated_notes = []
  533. logger.info(f" 并行评估 {len(notes_to_eval)} 个帖子({max_workers}并发)")
  534. # 20并发评估每个帖子
  535. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  536. futures = []
  537. for idx, note in enumerate(notes_to_eval):
  538. future = executor.submit(
  539. self.evaluate_single_note,
  540. original_feature,
  541. search_word,
  542. note,
  543. idx
  544. )
  545. futures.append(future)
  546. # 收集结果
  547. for future in as_completed(futures):
  548. try:
  549. result = future.result()
  550. evaluated_notes.append(result)
  551. except Exception as e:
  552. logger.error(f" 评估帖子失败: {e}")
  553. # 按note_index排序
  554. evaluated_notes.sort(key=lambda x: x['note_index'])
  555. # 汇总:计算整体相关度和提取元素
  556. if evaluated_notes:
  557. overall_relevance = sum(n['relevance'] for n in evaluated_notes) / len(evaluated_notes)
  558. # 提取所有元素并统计频次
  559. element_counts = {}
  560. for note in evaluated_notes:
  561. for elem in note['matched_elements']:
  562. element_counts[elem] = element_counts.get(elem, 0) + 1
  563. # 按频次排序,取前5个
  564. extracted_elements = sorted(
  565. element_counts.keys(),
  566. key=lambda x: element_counts[x],
  567. reverse=True
  568. )[:5]
  569. else:
  570. overall_relevance = 0.0
  571. extracted_elements = []
  572. return {
  573. "overall_relevance": overall_relevance,
  574. "extracted_elements": extracted_elements,
  575. "evaluated_notes": evaluated_notes
  576. }
  577. def evaluate_search_results(
  578. self,
  579. original_feature: str,
  580. search_word: str,
  581. notes: List[Dict[str, Any]],
  582. max_notes: int = 5,
  583. max_images_per_note: int = 10
  584. ) -> Dict[str, Any]:
  585. """
  586. 评估搜索结果(阶段6,多模态)
  587. Args:
  588. original_feature: 原始特征
  589. search_word: 搜索词
  590. notes: 帖子列表
  591. max_notes: 最多评估几条帖子
  592. max_images_per_note: 每条帖子最多取几张图片
  593. Returns:
  594. 评估结果
  595. """
  596. if not notes:
  597. return {
  598. "overall_relevance": 0.0,
  599. "extracted_elements": [],
  600. "recommended_extension": None,
  601. "evaluated_notes": []
  602. }
  603. # 限制评估数量
  604. notes_to_eval = notes[:max_notes]
  605. # 准备文本信息
  606. notes_info = []
  607. all_images = []
  608. for idx, note in enumerate(notes_to_eval):
  609. card = note.get("note_card", {})
  610. title = card.get("display_title", "")
  611. desc = card.get("desc", "")[:300] # 限制长度
  612. notes_info.append({
  613. "index": idx,
  614. "title": title,
  615. "desc": desc
  616. })
  617. # 收集图片
  618. images = card.get("image_list", [])[:max_images_per_note]
  619. all_images.extend(images)
  620. # 构建提示词
  621. notes_text = "\n\n".join([
  622. f"帖子 {n['index']}:\n标题: {n['title']}\n正文: {n['desc']}"
  623. for n in notes_info
  624. ])
  625. prompt = f"""你是一个小红书内容分析专家。
  626. 任务:评估搜索结果是否包含目标特征的元素
  627. 原始特征:"{original_feature}"
  628. 搜索词:"{search_word}"
  629. 帖子数量:{len(notes_to_eval)} 条
  630. 帖子内容:
  631. {notes_text}
  632. 请综合分析帖子的文字和图片内容,判断:
  633. 1. 这些搜索结果中是否包含与"{original_feature}"相似的元素
  634. 2. 提取最相关的元素关键词(2-4个字的词组)
  635. 3. 推荐最适合用于扩展搜索的关键词
  636. 返回JSON格式:
  637. {{
  638. "overall_relevance": 0.72, // 0.0-1.0,整体相关度
  639. "extracted_elements": ["关键词1", "关键词2", "关键词3"], // 提取的相似元素,按相关度排序
  640. "recommended_extension": "关键词1", // 最优的扩展关键词
  641. "evaluated_notes": [
  642. {{
  643. "note_index": 0, // 帖子索引
  644. "relevance": 0.85, // 该帖子的相关度
  645. "matched_elements": ["元素1", "元素2"], // 该帖子匹配的元素
  646. "reasoning": "简短的匹配理由"
  647. }}
  648. ]
  649. }}
  650. 注意:
  651. - extracted_elements 应该是帖子中实际包含的、与原始特征相似的元素
  652. - 优先提取在图片或文字中明显出现的元素
  653. - 只返回JSON,不要其他内容"""
  654. # 调用LLM(带图片)
  655. result = self.client.chat_json(
  656. prompt=prompt,
  657. images=all_images if all_images else None,
  658. max_retries=3
  659. )
  660. if result:
  661. # 确保返回完整格式
  662. return {
  663. "overall_relevance": result.get("overall_relevance", 0.0),
  664. "extracted_elements": result.get("extracted_elements", []),
  665. "recommended_extension": result.get("recommended_extension"),
  666. "evaluated_notes": result.get("evaluated_notes", [])
  667. }
  668. else:
  669. logger.error(f"评估搜索结果失败: {search_word}")
  670. return {
  671. "overall_relevance": 0.0,
  672. "extracted_elements": [],
  673. "recommended_extension": None,
  674. "evaluated_notes": []
  675. }
  676. def batch_evaluate_search_results(
  677. self,
  678. features_with_results: List[Dict[str, Any]],
  679. max_workers: int = 3
  680. ) -> List[Dict[str, Any]]:
  681. """
  682. 批量评估搜索结果(并行,但并发数较低以避免超时)
  683. Args:
  684. features_with_results: 带搜索结果的特征列表
  685. max_workers: 最大并发数
  686. Returns:
  687. 带评估结果的特征列表
  688. """
  689. logger.info(f"开始批量评估 {len(features_with_results)} 个搜索结果...")
  690. results = []
  691. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  692. # 提交任务
  693. future_to_feature = {}
  694. for feature in features_with_results:
  695. if not feature.get("search_result"):
  696. # 无搜索结果,跳过
  697. feature["result_evaluation"] = None
  698. results.append(feature)
  699. continue
  700. original_feature = self._get_original_feature(feature)
  701. search_word = feature.get("search_word", "")
  702. notes = feature["search_result"].get("data", {}).get("data", [])
  703. future = executor.submit(
  704. self.evaluate_search_results,
  705. original_feature,
  706. search_word,
  707. notes
  708. )
  709. future_to_feature[future] = feature
  710. # 收集结果
  711. for idx, future in enumerate(as_completed(future_to_feature), 1):
  712. feature = future_to_feature[future]
  713. try:
  714. evaluation = future.result()
  715. feature["result_evaluation"] = evaluation
  716. results.append(feature)
  717. logger.info(f" [{idx}/{len(future_to_feature)}] {feature.get('search_word')}: "
  718. f"relevance={evaluation['overall_relevance']:.3f}")
  719. except Exception as e:
  720. logger.error(f" 评估失败: {feature.get('search_word')}, 错误: {e}")
  721. feature["result_evaluation"] = None
  722. results.append(feature)
  723. logger.info(f"批量评估完成")
  724. return results
  725. def _get_original_feature(self, feature_node: Dict[str, Any]) -> str:
  726. """
  727. 从特征节点中获取原始特征名称
  728. Args:
  729. feature_node: 特征节点
  730. Returns:
  731. 原始特征名称
  732. """
  733. # 尝试从llm_evaluation中获取
  734. if "llm_evaluation" in feature_node:
  735. return feature_node["llm_evaluation"].get("original_feature", "")
  736. # 尝试从其他字段获取
  737. return feature_node.get("原始特征名称", feature_node.get("特征名称", ""))
  738. # ========== Stage 6: 两层评估方法 ==========
  739. def evaluate_query_relevance_batch(
  740. self,
  741. search_query: str,
  742. notes: List[Dict[str, Any]],
  743. max_notes: int = 20
  744. ) -> Dict[str, Any]:
  745. """
  746. 第一层评估:批量判断搜索结果与 Query 的相关性
  747. 一次 LLM 调用评估多个笔记的 Query 相关性
  748. Args:
  749. search_query: 搜索Query
  750. notes: 笔记列表
  751. max_notes: 最多评估几条笔记
  752. Returns:
  753. {
  754. "note_0": {"与query相关性": "相关", "说明": "..."},
  755. "note_1": {"与query相关性": "不相关", "说明": "..."},
  756. ...
  757. }
  758. """
  759. if not notes:
  760. return {}
  761. notes_to_eval = notes[:max_notes]
  762. # 构建笔记列表文本
  763. notes_text = ""
  764. for idx, note in enumerate(notes_to_eval):
  765. note_card = note.get('note_card', {})
  766. title = note_card.get('display_title', '')
  767. content = note_card.get('desc', '')[:800] # 限制长度
  768. images = note_card.get('image_list', [])
  769. notes_text += f"note_{idx}:\n"
  770. notes_text += f"- 标题: {title}\n"
  771. notes_text += f"- 正文: {content}\n"
  772. notes_text += f"- 图像: {len(images)}张图片\n\n"
  773. # 构建完整的第一层评估 Prompt(用户提供,不简化)
  774. prompt = f"""# 任务说明
  775. 判断搜索结果是否与搜索Query相关,过滤掉完全无关的结果。
  776. # 输入信息
  777. 搜索Query: {search_query}
  778. 搜索结果列表:
  779. {notes_text}
  780. # 判断标准
  781. ✅ 相关(保留)
  782. 搜索结果的标题、正文或图像内容中包含Query相关的信息:
  783. Query的核心关键词在结果中出现
  784. 或 结果讨论的主题与Query直接相关
  785. 或 结果是Query概念的上位/下位/平行概念
  786. ❌ 不相关(过滤)
  787. 搜索结果与Query完全无关:
  788. Query的关键词完全未出现
  789. 结果主题与Query无任何关联
  790. 仅因搜索引擎误匹配而出现
  791. ## 判断示例
  792. Query "墨镜搭配" → 结果"太阳镜选购指南" ✅ 保留(墨镜=太阳镜)
  793. Query "墨镜搭配" → 结果"眼镜搭配技巧" ✅ 保留(眼镜是墨镜的上位概念)
  794. Query "墨镜搭配" → 结果"帽子搭配技巧" ❌ 过滤(完全无关)
  795. Query "复古滤镜" → 结果"滤镜调色教程" ✅ 保留(包含滤镜)
  796. Query "复古滤镜" → 结果"相机推荐" ❌ 过滤(主题不相关)
  797. # 输出格式
  798. {{
  799. "note_0": {{
  800. "与query相关性": "相关 / 不相关",
  801. "说明": ""
  802. }},
  803. "note_1": {{
  804. "与query相关性": "相关 / 不相关",
  805. "说明": ""
  806. }}
  807. }}
  808. # 特殊情况处理
  809. - 如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行判断
  810. - 当无法明确判断时,倾向于保留(标记为"相关")
  811. 只返回JSON,不要其他内容。"""
  812. # 调用 LLM(批量评估)
  813. result = self.client.chat_json(
  814. prompt=prompt,
  815. max_retries=3
  816. )
  817. if result:
  818. return result
  819. else:
  820. logger.error(f" 第一层批量评估失败: Query={search_query}")
  821. # 返回默认结果(全部标记为"相关"以保守处理)
  822. default_result = {}
  823. for idx in range(len(notes_to_eval)):
  824. default_result[f"note_{idx}"] = {
  825. "与query相关性": "相关",
  826. "说明": "LLM评估失败,默认保留"
  827. }
  828. return default_result
  829. def evaluate_feature_matching_single(
  830. self,
  831. target_feature: str,
  832. note_title: str,
  833. note_content: str,
  834. note_images: List[str],
  835. note_index: int
  836. ) -> Dict[str, Any]:
  837. """
  838. 第二层评估:评估单个笔记与目标特征的匹配度
  839. Args:
  840. target_feature: 目标特征
  841. note_title: 笔记标题
  842. note_content: 笔记正文
  843. note_images: 图片URL列表
  844. note_index: 笔记索引
  845. Returns:
  846. {
  847. "综合得分": 0.9, # 0-1分
  848. "匹配类型": "完全匹配",
  849. "评分说明": "...",
  850. "关键匹配点": [...]
  851. }
  852. """
  853. # 构建完整的第二层评估 Prompt(用户提供,不简化)
  854. prompt = f"""# 任务说明
  855. 你需要判断搜索到的案例与目标特征的相关性。
  856. # 输入信息
  857. 目标特征:{target_feature}
  858. 搜索结果:
  859. - 标题: {note_title}
  860. - 正文: {note_content[:800]}
  861. - 图像: {len(note_images)}张图片(请仔细分析图片内容,包括OCR提取图片中的文字)
  862. # 判断流程
  863. ## 目标特征匹配度评分
  864. 综合考虑语义相似度(概念匹配、层级关系)和场景关联度(应用场景、使用语境)进行评分:
  865. - 0.8-1分:完全匹配
  866. 语义层面:找到与目标特征完全相同或高度一致的内容,核心概念完全一致
  867. 场景层面:完全适用于同一场景、受众、平台和语境
  868. 示例:
  869. 目标"复古滤镜" + 小红书穿搭场景 vs 结果"小红书复古滤镜调色教程"
  870. 目标"墨镜" + 时尚搭配场景 vs 结果"时尚墨镜搭配指南"
  871. - 0.6-0.7分:相似匹配
  872. 语义层面:
  873. 结果是目标的上位概念(更宽泛)或下位概念(更具体)
  874. 或属于同一概念的不同表现形式,或属于平行概念(同级不同类)
  875. 场景层面:场景相近但有差异,需要筛选或调整后可用
  876. 示例:
  877. 目标"墨镜" + 时尚搭配 vs 结果"眼镜搭配技巧"(上位概念,需筛选)
  878. 目标"怀旧滤镜" + 人像拍摄 vs 结果"胶片感调色"(不同表现形式)
  879. 目标"日常穿搭" + 街拍 vs 结果"通勤穿搭拍照"(场景相近)
  880. - 0.5-0.6分:弱相似
  881. 语义层面:属于同一大类但具体方向或侧重点明显不同,仅提供了相关概念
  882. 场景层面:场景有明显差异,迁移需要较大改造
  883. 示例:
  884. 目标"户外运动穿搭" vs 结果"健身房穿搭指南"
  885. 目标"小红书图文笔记" vs 结果"抖音短视频脚本"
  886. - 0.4分及以下:无匹配
  887. 语义层面:仅表面词汇重叠,实质关联弱,或概念距离过远
  888. 场景层面:应用场景基本不同或完全不同
  889. 示例:
  890. 目标"墨镜" vs 结果"配饰大全"(概念过于宽泛)
  891. 目标"美食摄影构图" vs 结果"美食博主日常vlog"
  892. ## 概念层级关系说明
  893. 在评分时,需要注意概念层级关系的影响:
  894. 完全匹配(同一概念 + 同场景)→ 0.8-1分
  895. 目标"墨镜" vs 结果"墨镜搭配",且都在时尚搭配场景
  896. 上位/下位概念(层级差一层)→ 通常0.6-0.7分
  897. 目标"墨镜" vs 结果"眼镜搭配"(结果更宽泛,需筛选)
  898. 目标"眼镜" vs 结果"墨镜选购"(结果更具体,部分适用)
  899. 平行概念(同级不同类)→ 通常0.6-0.7分
  900. 目标"墨镜" vs 结果"近视眼镜"(都是眼镜类,但功能场景不同)
  901. 远距离概念(层级差两层及以上)→ 0.5分及以下
  902. 目标"墨镜" vs 结果"配饰"(概念过于宽泛,指导性弱)
  903. # 匹配结论判断
  904. 根据综合得分判定匹配类型:
  905. 0.8-1.0分:✅ 完全匹配
  906. 判断:找到了目标特征的直接灵感来源
  907. 建议:直接采纳为该特征的灵感溯源结果
  908. 0.6-0.79分:⚠️ 相似匹配
  909. 判断:找到了相关的灵感参考,但存在一定差异
  910. 建议:作为候选结果保留,可与其他结果综合判断或继续搜索更精确的匹配
  911. 0.59分及以下:❌ 无匹配
  912. 判断:该结果与目标特征关联度不足
  913. 建议:排除该结果,需要调整搜索策略继续寻找
  914. # 输出格式
  915. {{
  916. "综合得分": 0.7,
  917. "匹配类型": "相似匹配",
  918. "评分说明": "结果'眼镜搭配技巧'是目标'墨镜'的上位概念,内容涵盖多种眼镜类型。场景都是时尚搭配,但需要从结果中筛选出墨镜相关的内容。概念关系:上位概念(宽泛一层)",
  919. "关键匹配点": [
  920. "眼镜与脸型的搭配原则(部分适用于墨镜)",
  921. "配饰的风格选择方法"
  922. ]
  923. }}
  924. # 特殊情况处理
  925. 复合特征评估:如果目标特征是复合型(如"复古滤镜+第一人称视角"),需要分别评估每个子特征的匹配度,然后取平均值作为最终得分
  926. 信息不完整:如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行评分
  927. 上位概念的实用性:当结果是目标的上位概念时,评分应考虑:内容中目标相关部分的占比;是否提供了可直接应用于目标的知识;场景的一致性程度;如果结果虽是上位概念但完全不涉及目标内容,应降至5-6分或更低
  928. 只返回JSON,不要其他内容。"""
  929. # 调用 LLM(传递图片进行多模态分析)
  930. result = self.client.chat_json(
  931. prompt=prompt,
  932. images=note_images if note_images else None,
  933. max_retries=3
  934. )
  935. if result:
  936. return result
  937. else:
  938. logger.error(f" 第二层评估失败: note {note_index}, target={target_feature}")
  939. return {
  940. "综合得分": 0.0,
  941. "匹配类型": "评估失败",
  942. "评分说明": "LLM评估失败",
  943. "关键匹配点": []
  944. }
  945. def evaluate_note_with_filter(
  946. self,
  947. search_query: str,
  948. target_feature: str,
  949. note_title: str,
  950. note_content: str,
  951. note_images: List[str],
  952. note_index: int = 0
  953. ) -> Dict[str, Any]:
  954. """
  955. 两层评估单个笔记(完整Prompt版本)
  956. 第一层:Query相关性过滤
  957. 第二层:目标特征匹配度评分
  958. Args:
  959. search_query: 搜索Query,如 "外观装扮 发布萌宠内容"
  960. target_feature: 目标特征,如 "佩戴"
  961. note_title: 笔记标题
  962. note_content: 笔记正文
  963. note_images: 图片URL列表(会传递给LLM进行视觉分析和OCR)
  964. note_index: 笔记索引
  965. Returns:
  966. 评估结果字典
  967. """
  968. # 构建完整的评估Prompt(用户提供的完整版本,一字不改)
  969. prompt = f"""# 任务说明
  970. 你需要判断搜索到的案例信息与目标特征的相关性。判断分为两层:第一层过滤与搜索Query无关的结果,第二层评估与目标特征的匹配度。
  971. # 输入信息
  972. 搜索Query:{search_query}
  973. 目标特征:{target_feature}
  974. 搜索结果:
  975. - 标题: {note_title}
  976. - 正文: {note_content[:800]}
  977. - 图像: {len(note_images)}张图片(请仔细分析图片内容,包括OCR提取图片中的文字)
  978. # 判断流程
  979. 第一层:Query相关性过滤
  980. 判断标准:搜索结果是否与搜索Query相关
  981. 过滤规则:
  982. ✅ 保留:搜索结果的标题、正文或图像内容中包含Query相关的信息
  983. Query的核心关键词在结果中出现
  984. 或结果讨论的主题与Query直接相关
  985. 或结果是Query概念的上位/下位/平行概念
  986. ❌ 过滤:搜索结果与Query完全无关
  987. Query的关键词完全未出现
  988. 结果主题与Query无任何关联
  989. 仅因搜索引擎误匹配而出现
  990. 示例:
  991. Query "墨镜搭配" → 结果"太阳镜选购指南" ✅ 保留(墨镜=太阳镜)
  992. Query "墨镜搭配" → 结果"眼镜搭配技巧" ✅ 保留(眼镜是上位概念)
  993. Query "墨镜搭配" → 结果"帽子搭配技巧" ❌ 过滤(完全无关)
  994. Query "复古滤镜" → 结果"滤镜调色教程" ✅ 保留(包含滤镜)
  995. Query "复古滤镜" → 结果"相机推荐" ❌ 过滤(主题不相关)
  996. 输出:
  997. 如果判定为 ❌ 过滤,直接输出:
  998. json{{
  999. "Query相关性": "不相关",
  1000. "综合得分": 0,
  1001. "匹配类型": "过滤",
  1002. "说明": "搜索结果与Query '{search_query}' 完全无关,建议过滤"
  1003. }}
  1004. 如果判定为 ✅ 保留,进入第二层评分
  1005. 第二层:目标特征匹配度评分
  1006. 综合考虑语义相似度(概念匹配、层级关系、实操价值)和场景关联度(应用场景、使用语境)进行评分:
  1007. 8-10分:完全匹配
  1008. 语义层面:找到与目标特征完全相同或高度一致的内容,核心概念完全一致
  1009. 场景层面:完全适用于同一场景、受众、平台和语境
  1010. 实操价值:提供了具体可执行的方法、步骤或技巧
  1011. 示例:
  1012. 目标"复古滤镜" + 小红书穿搭场景 vs 结果"小红书复古滤镜调色教程"
  1013. 目标"墨镜" + 时尚搭配场景 vs 结果"时尚墨镜搭配指南"
  1014. 6-7分:相似匹配
  1015. 语义层面:
  1016. 结果是目标的上位概念(更宽泛)或下位概念(更具体)
  1017. 或属于同一概念的不同表现形式
  1018. 或属于平行概念(同级不同类)
  1019. 场景层面:场景相近但有差异,需要筛选或调整后可用
  1020. 实操价值:有一定参考价值但需要转化应用
  1021. 示例:
  1022. 目标"墨镜" + 时尚搭配 vs 结果"眼镜搭配技巧"(上位概念,需筛选)
  1023. 目标"怀旧滤镜" + 人像拍摄 vs 结果"胶片感调色"(不同表现形式)
  1024. 目标"日常穿搭" + 街拍 vs 结果"通勤穿搭拍照"(场景相近)
  1025. 5-6分:弱相似
  1026. 语义层面:属于同一大类但具体方向或侧重点明显不同
  1027. 场景层面:场景有明显差异,迁移需要较大改造
  1028. 实操价值:提供了概念启发但需要较大转化
  1029. 示例:
  1030. 目标"户外运动穿搭" vs 结果"健身房穿搭指南"
  1031. 目标"小红书图文笔记" vs 结果"抖音短视频脚本"
  1032. 4分及以下:无匹配
  1033. 语义层面:仅表面词汇重叠,实质关联弱,或概念距离过远
  1034. 场景层面:应用场景基本不同或完全不同
  1035. 实操价值:实操指导价值有限或无价值
  1036. 示例:
  1037. 目标"墨镜" vs 结果"配饰大全"(概念过于宽泛)
  1038. 目标"美食摄影构图" vs 结果"美食博主日常vlog"
  1039. 概念层级关系说明
  1040. 在评分时,需要注意概念层级关系的影响:
  1041. 完全匹配(同一概念 + 同场景)→ 8-10分
  1042. 目标"墨镜" vs 结果"墨镜搭配",且都在时尚搭配场景
  1043. 上位/下位概念(层级差一层)→ 通常6-7分
  1044. 目标"墨镜" vs 结果"眼镜搭配"(结果更宽泛,需筛选)
  1045. 目标"眼镜" vs 结果"墨镜选购"(结果更具体,部分适用)
  1046. 平行概念(同级不同类)→ 通常6-7分
  1047. 目标"墨镜" vs 结果"近视眼镜"(都是眼镜类,但功能场景不同)
  1048. 远距离概念(层级差两层及以上)→ 4分及以下
  1049. 目标"墨镜" vs 结果"配饰"(概念过于宽泛,指导性弱)
  1050. 匹配结论判断
  1051. 根据综合得分判定匹配类型:
  1052. 8.0-10.0分:✅ 完全匹配
  1053. 判断:找到了目标特征的直接灵感来源
  1054. 置信度:高
  1055. 建议:直接采纳为该特征的灵感溯源结果
  1056. 5.0-7.9分:⚠️ 相似匹配
  1057. 判断:找到了相关的灵感参考,但存在一定差异
  1058. 置信度:中
  1059. 建议:作为候选结果保留,可与其他结果综合判断或继续搜索更精确的匹配
  1060. 1.0-4.9分:❌ 无匹配
  1061. 判断:该结果与目标特征关联度不足
  1062. 置信度:低
  1063. 建议:排除该结果,需要调整搜索策略继续寻找
  1064. # 输出格式
  1065. 通过Query相关性过滤的结果:
  1066. json{{
  1067. "Query相关性": "相关",
  1068. "综合得分": 7.0,
  1069. "匹配类型": "相似匹配",
  1070. "置信度": "中",
  1071. "评分说明": "结果'眼镜搭配技巧'是目标'墨镜'的上位概念,内容涵盖多种眼镜类型。场景都是时尚搭配,但需要从结果中筛选出墨镜相关的内容。概念关系:上位概念(宽泛一层)",
  1072. "关键匹配点": [
  1073. "眼镜与脸型的搭配原则(部分适用于墨镜)",
  1074. "配饰的风格选择方法"
  1075. ]
  1076. }}
  1077. 未通过Query相关性过滤的结果:
  1078. json{{
  1079. "Query相关性": "不相关",
  1080. "综合得分": 0,
  1081. "匹配类型": "过滤",
  1082. "说明": "搜索结果'帽子搭配技巧'与Query'墨镜搭配'完全无关,建议过滤"
  1083. }}
  1084. # 特殊情况处理
  1085. 复合特征评估:如果目标特征是复合型(如"复古滤镜+第一人称视角"),需要分别评估每个子特征的匹配度,然后取算术平均值作为最终得分
  1086. 信息不完整:如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行评分
  1087. 上位概念的实用性:当结果是目标的上位概念时,评分应考虑:
  1088. 内容中目标相关部分的占比
  1089. 是否提供了可直接应用于目标的知识
  1090. 场景的一致性程度
  1091. 如果结果虽是上位概念但完全不涉及目标内容,应降至5-6分或更低
  1092. Query与目标特征的关系:
  1093. 如果Query就是目标特征本身,第一层和第二层判断可以合并考虑
  1094. 如果Query是为了探索目标特征而构建的更宽泛查询,第一层更宽松,第二层更严格
  1095. 只返回JSON,不要其他内容。"""
  1096. # 调用LLM(传递图片URL进行多模态分析)
  1097. result = self.client.chat_json(
  1098. prompt=prompt,
  1099. images=note_images if note_images else None, # ✅ 传递图片
  1100. max_retries=3
  1101. )
  1102. if result:
  1103. # 添加笔记索引
  1104. result['note_index'] = note_index
  1105. return result
  1106. else:
  1107. logger.error(f" 评估笔记 {note_index} 失败: Query={search_query}")
  1108. return {
  1109. "note_index": note_index,
  1110. "Query相关性": "评估失败",
  1111. "综合得分": 0,
  1112. "匹配类型": "评估失败",
  1113. "说明": "LLM评估失败"
  1114. }
  1115. def batch_evaluate_notes_with_filter(
  1116. self,
  1117. search_query: str,
  1118. target_feature: str,
  1119. notes: List[Dict[str, Any]],
  1120. max_notes: int = 20,
  1121. max_workers: int = 10
  1122. ) -> Dict[str, Any]:
  1123. """
  1124. 两层评估多个笔记(拆分为两次LLM调用)
  1125. 第一层:批量评估Query相关性(1次LLM调用)
  1126. 第二层:对"相关"的笔记评估特征匹配度(M次LLM调用)
  1127. Args:
  1128. search_query: 搜索Query
  1129. target_feature: 目标特征
  1130. notes: 笔记列表
  1131. max_notes: 最多评估几条笔记
  1132. max_workers: 最大并发数
  1133. Returns:
  1134. 评估结果汇总(包含统计信息)
  1135. """
  1136. if not notes:
  1137. return {
  1138. "total_notes": 0,
  1139. "evaluated_notes": 0,
  1140. "filtered_count": 0,
  1141. "statistics": {},
  1142. "notes_evaluation": []
  1143. }
  1144. notes_to_eval = notes[:max_notes]
  1145. logger.info(f" 两层评估 {len(notes_to_eval)} 个笔记")
  1146. # ========== 第一层:批量评估Query相关性 ==========
  1147. logger.info(f" [第一层] 批量评估Query相关性(1次LLM调用)")
  1148. query_relevance_result = self.evaluate_query_relevance_batch(
  1149. search_query=search_query,
  1150. notes=notes_to_eval,
  1151. max_notes=max_notes
  1152. )
  1153. # 解析第一层结果,找出"相关"的笔记
  1154. relevant_notes_info = []
  1155. for idx, note in enumerate(notes_to_eval):
  1156. note_key = f"note_{idx}"
  1157. relevance_info = query_relevance_result.get(note_key, {})
  1158. relevance = relevance_info.get("与query相关性", "相关") # 默认为"相关"
  1159. if relevance == "相关":
  1160. # 保留笔记信息用于第二层评估
  1161. note_card = note.get('note_card', {})
  1162. relevant_notes_info.append({
  1163. "note_index": idx,
  1164. "note_card": note_card,
  1165. "title": note_card.get('display_title', ''),
  1166. "content": note_card.get('desc', ''),
  1167. "images": note_card.get('image_list', []),
  1168. "第一层评估": relevance_info
  1169. })
  1170. logger.info(f" [第一层] 过滤结果: {len(relevant_notes_info)}/{len(notes_to_eval)} 条相关")
  1171. # ========== 第二层:对相关笔记评估特征匹配度 ==========
  1172. evaluated_notes = []
  1173. if relevant_notes_info:
  1174. logger.info(f" [第二层] 并行评估特征匹配度({len(relevant_notes_info)}次LLM调用,{max_workers}并发)")
  1175. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  1176. futures = []
  1177. for note_info in relevant_notes_info:
  1178. future = executor.submit(
  1179. self.evaluate_feature_matching_single,
  1180. target_feature,
  1181. note_info["title"],
  1182. note_info["content"],
  1183. note_info["images"],
  1184. note_info["note_index"]
  1185. )
  1186. futures.append((future, note_info))
  1187. # 收集结果并合并
  1188. for future, note_info in futures:
  1189. try:
  1190. second_layer_result = future.result()
  1191. # 合并两层评估结果
  1192. merged_result = {
  1193. "note_index": note_info["note_index"],
  1194. "Query相关性": "相关",
  1195. "综合得分": second_layer_result.get("综合得分", 0.0), # 0-1分制
  1196. "匹配类型": second_layer_result.get("匹配类型", ""),
  1197. "评分说明": second_layer_result.get("评分说明", ""),
  1198. "关键匹配点": second_layer_result.get("关键匹配点", []),
  1199. "第一层评估": note_info["第一层评估"],
  1200. "第二层评估": second_layer_result
  1201. }
  1202. evaluated_notes.append(merged_result)
  1203. except Exception as e:
  1204. logger.error(f" [第二层] 评估笔记 {note_info['note_index']} 失败: {e}")
  1205. # 失败的笔记也加入结果
  1206. evaluated_notes.append({
  1207. "note_index": note_info["note_index"],
  1208. "Query相关性": "相关",
  1209. "综合得分": 0.0,
  1210. "匹配类型": "评估失败",
  1211. "评分说明": f"第二层评估失败: {str(e)}",
  1212. "关键匹配点": [],
  1213. "第一层评估": note_info["第一层评估"],
  1214. "第二层评估": {}
  1215. })
  1216. # 添加第一层就被过滤的笔记(Query不相关)
  1217. for idx, note in enumerate(notes_to_eval):
  1218. note_key = f"note_{idx}"
  1219. relevance_info = query_relevance_result.get(note_key, {})
  1220. relevance = relevance_info.get("与query相关性", "相关")
  1221. if relevance == "不相关":
  1222. evaluated_notes.append({
  1223. "note_index": idx,
  1224. "Query相关性": "不相关",
  1225. "综合得分": 0.0,
  1226. "匹配类型": "过滤",
  1227. "说明": relevance_info.get("说明", ""),
  1228. "第一层评估": relevance_info
  1229. })
  1230. # 按note_index排序
  1231. evaluated_notes.sort(key=lambda x: x.get('note_index', 0))
  1232. # 统计信息
  1233. total_notes = len(notes)
  1234. evaluated_count = len(evaluated_notes)
  1235. filtered_count = sum(1 for n in evaluated_notes if n.get('Query相关性') == '不相关')
  1236. # 匹配度分布统计(使用0-1分制的阈值)
  1237. match_distribution = {
  1238. '完全匹配(0.8-1.0)': 0,
  1239. '相似匹配(0.6-0.79)': 0,
  1240. '弱相似(0.5-0.59)': 0,
  1241. '无匹配(≤0.4)': 0
  1242. }
  1243. for note_eval in evaluated_notes:
  1244. if note_eval.get('Query相关性') == '不相关':
  1245. continue # 过滤的不计入分布
  1246. score = note_eval.get('综合得分', 0)
  1247. if score >= 0.8:
  1248. match_distribution['完全匹配(0.8-1.0)'] += 1
  1249. elif score >= 0.6:
  1250. match_distribution['相似匹配(0.6-0.79)'] += 1
  1251. elif score >= 0.5:
  1252. match_distribution['弱相似(0.5-0.59)'] += 1
  1253. else:
  1254. match_distribution['无匹配(≤0.4)'] += 1
  1255. logger.info(f" 评估完成: 过滤{filtered_count}条, 匹配分布: {match_distribution}")
  1256. return {
  1257. "total_notes": total_notes,
  1258. "evaluated_notes": evaluated_count,
  1259. "filtered_count": filtered_count,
  1260. "statistics": match_distribution,
  1261. "notes_evaluation": evaluated_notes
  1262. }
  1263. def test_evaluator():
  1264. """测试评估器"""
  1265. import os
  1266. # 初始化客户端
  1267. client = OpenRouterClient()
  1268. evaluator = LLMEvaluator(client)
  1269. # 测试搜索词评估
  1270. print("\n=== 测试搜索词评估 ===")
  1271. result = evaluator.evaluate_search_word(
  1272. original_feature="拟人",
  1273. search_word="宠物猫 猫咪"
  1274. )
  1275. print(f"评分: {result['score']:.3f}")
  1276. print(f"理由: {result['reasoning']}")
  1277. # 测试批量评估
  1278. print("\n=== 测试批量评估 ===")
  1279. results = evaluator.evaluate_search_words_batch(
  1280. original_feature="拟人",
  1281. search_words=["宠物猫 猫咪", "宠物猫 猫孩子", "宠物猫 猫"],
  1282. max_workers=2
  1283. )
  1284. for r in results:
  1285. print(f"{r['search_word']}: {r['score']:.3f} (rank={r['rank']})")
  1286. if __name__ == "__main__":
  1287. logging.basicConfig(
  1288. level=logging.INFO,
  1289. format='%(asctime)s - %(levelname)s - %(message)s'
  1290. )
  1291. test_evaluator()