llm_evaluator.py 56 KB

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