llm_evaluator.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325
  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. ) -> List[Dict[str, Any]]:
  118. """
  119. 分批评估搜索词(每批N个,减少API调用)
  120. Args:
  121. original_feature: 原始特征
  122. search_words: 搜索词列表
  123. batch_size: 每批处理的搜索词数量,默认10
  124. Returns:
  125. 评估结果列表(已排序)
  126. """
  127. logger.info(f"开始分批评估 {len(search_words)} 个搜索词(每批 {batch_size} 个)...")
  128. all_results = []
  129. total_batches = (len(search_words) + batch_size - 1) // batch_size
  130. # 分批处理
  131. for batch_idx in range(total_batches):
  132. start_idx = batch_idx * batch_size
  133. end_idx = min(start_idx + batch_size, len(search_words))
  134. batch_words = search_words[start_idx:end_idx]
  135. logger.info(f" 处理第 {batch_idx + 1}/{total_batches} 批({len(batch_words)} 个搜索词)")
  136. # 从搜索词中提取所有独特的词作为可选词汇
  137. available_words_set = set()
  138. for word in batch_words:
  139. # 分割搜索词,提取单个词
  140. parts = word.split()
  141. available_words_set.update(parts)
  142. # 转换为列表并排序(保证稳定性)
  143. available_words = sorted(list(available_words_set))
  144. # 构建可选词汇字符串(逗号分隔)
  145. available_words_str = "、".join(available_words)
  146. prompt = f"""
  147. # 任务说明
  148. 从给定关键词中提取并组合适合在小红书搜索的query词(目标是找到【{original_feature}】相关内容,但query中不能直接出现"{original_feature}"二字)
  149. ## 可选词汇
  150. {available_words_str}
  151. ## 要求
  152. 1. 只能使用可选词汇中的词,可以进行以下变化:
  153. - 直接使用原词或括号内的同义词
  154. - 多个词组合
  155. - 适当精简
  156. 2. 不能添加可选词汇以外的新词
  157. 3. 按推荐程度排序(越靠前越推荐),取top10
  158. ## 输出格式(JSON):
  159. [
  160. {{
  161. "rank": 1,
  162. "search_word": "组合的搜索词",
  163. "source_word": "组合来源词,空格分割",
  164. "score": 0.85,
  165. "reasoning": "推荐理由"
  166. }},
  167. {{
  168. "index": 2,
  169. "search_word": "组合的搜索词",
  170. "source_word": "组合来源词,空格分割",
  171. "score": 0.80,
  172. "reasoning": "推荐理由"
  173. }}
  174. ]
  175. - 只返回JSON数组,不要其他内容"""
  176. # 调用LLM
  177. result = self.client.chat_json(prompt=prompt, max_retries=3)
  178. if result and isinstance(result, list):
  179. # 处理结果 - 新格式直接包含search_word
  180. for idx, item in enumerate(result):
  181. search_word = item.get("search_word", "")
  182. if search_word: # 确保有搜索词
  183. all_results.append({
  184. "search_word": search_word,
  185. "source_word": item.get("source_word", ""),
  186. "score": item.get("score", 0.0),
  187. "reasoning": item.get("reasoning", ""),
  188. "original_feature": original_feature
  189. })
  190. logger.info(f" [{start_idx + idx + 1}/{len(search_words)}] "
  191. f"{search_word}: {item.get('score', 0.0):.3f}")
  192. else:
  193. logger.error(f" 第 {batch_idx + 1} 批评估失败,跳过")
  194. # 为失败的批次添加默认结果(使用原搜索词)
  195. for word in batch_words:
  196. all_results.append({
  197. "search_word": word,
  198. "score": 0.0,
  199. "reasoning": "批量评估失败",
  200. "original_feature": original_feature
  201. })
  202. # 按分数排序
  203. all_results.sort(key=lambda x: x["score"], reverse=True)
  204. # 添加排名
  205. for rank, result in enumerate(all_results, 1):
  206. result["rank"] = rank
  207. logger.info(f"分批评估完成,最高分: {all_results[0]['score']:.3f} (总API调用: {total_batches} 次)")
  208. return all_results
  209. def evaluate_single_note(
  210. self,
  211. original_feature: str,
  212. search_word: str,
  213. note: Dict[str, Any],
  214. note_index: int = 0
  215. ) -> Dict[str, Any]:
  216. """
  217. 评估单个帖子(阶段6,多模态)
  218. Args:
  219. original_feature: 原始特征
  220. search_word: 搜索词
  221. note: 单个帖子
  222. note_index: 帖子索引
  223. Returns:
  224. 单个帖子的评估结果
  225. """
  226. card = note.get("note_card", {})
  227. title = card.get("display_title", "")
  228. desc = card.get("desc", "")[:500] # 限制长度
  229. images = card.get("image_list", [])[:10] # 最多10张图
  230. prompt = f"""你是一个小红书内容分析专家。
  231. 任务:评估这个帖子是否包含目标特征"{original_feature}"的元素
  232. 原始特征:"{original_feature}"
  233. 搜索词:"{search_word}"
  234. 帖子内容:
  235. 标题: {title}
  236. 正文: {desc}
  237. 请分析帖子的文字和图片内容,返回JSON格式:
  238. {{
  239. "relevance": 0.85, // 0.0-1.0,相关度
  240. "matched_elements": ["元素1", "元素2"], // 匹配的元素列表
  241. "reasoning": "简短的匹配理由"
  242. }}
  243. 只返回JSON,不要其他内容。"""
  244. result = self.client.chat_json(
  245. prompt=prompt,
  246. images=images if images else None,
  247. max_retries=3
  248. )
  249. if result:
  250. return {
  251. "note_index": note_index,
  252. "relevance": result.get("relevance", 0.0),
  253. "matched_elements": result.get("matched_elements", []),
  254. "reasoning": result.get("reasoning", "")
  255. }
  256. else:
  257. logger.error(f" 评估帖子 {note_index} 失败: {search_word}")
  258. return {
  259. "note_index": note_index,
  260. "relevance": 0.0,
  261. "matched_elements": [],
  262. "reasoning": "评估失败"
  263. }
  264. def evaluate_search_results_parallel(
  265. self,
  266. original_feature: str,
  267. search_word: str,
  268. notes: List[Dict[str, Any]],
  269. max_notes: int = 20,
  270. max_workers: int = 20
  271. ) -> Dict[str, Any]:
  272. """
  273. 并行评估搜索结果(每个帖子独立评估)
  274. Args:
  275. original_feature: 原始特征
  276. search_word: 搜索词
  277. notes: 帖子列表
  278. max_notes: 最多评估几条帖子
  279. max_workers: 最大并发数
  280. Returns:
  281. 评估结果汇总
  282. """
  283. if not notes:
  284. return {
  285. "overall_relevance": 0.0,
  286. "extracted_elements": [],
  287. "evaluated_notes": []
  288. }
  289. notes_to_eval = notes[:max_notes]
  290. evaluated_notes = []
  291. logger.info(f" 并行评估 {len(notes_to_eval)} 个帖子({max_workers}并发)")
  292. # 20并发评估每个帖子
  293. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  294. futures = []
  295. for idx, note in enumerate(notes_to_eval):
  296. future = executor.submit(
  297. self.evaluate_single_note,
  298. original_feature,
  299. search_word,
  300. note,
  301. idx
  302. )
  303. futures.append(future)
  304. # 收集结果
  305. for future in as_completed(futures):
  306. try:
  307. result = future.result()
  308. evaluated_notes.append(result)
  309. except Exception as e:
  310. logger.error(f" 评估帖子失败: {e}")
  311. # 按note_index排序
  312. evaluated_notes.sort(key=lambda x: x['note_index'])
  313. # 汇总:计算整体相关度和提取元素
  314. if evaluated_notes:
  315. overall_relevance = sum(n['relevance'] for n in evaluated_notes) / len(evaluated_notes)
  316. # 提取所有元素并统计频次
  317. element_counts = {}
  318. for note in evaluated_notes:
  319. for elem in note['matched_elements']:
  320. element_counts[elem] = element_counts.get(elem, 0) + 1
  321. # 按频次排序,取前5个
  322. extracted_elements = sorted(
  323. element_counts.keys(),
  324. key=lambda x: element_counts[x],
  325. reverse=True
  326. )[:5]
  327. else:
  328. overall_relevance = 0.0
  329. extracted_elements = []
  330. return {
  331. "overall_relevance": overall_relevance,
  332. "extracted_elements": extracted_elements,
  333. "evaluated_notes": evaluated_notes
  334. }
  335. def evaluate_search_results(
  336. self,
  337. original_feature: str,
  338. search_word: str,
  339. notes: List[Dict[str, Any]],
  340. max_notes: int = 5,
  341. max_images_per_note: int = 10
  342. ) -> Dict[str, Any]:
  343. """
  344. 评估搜索结果(阶段6,多模态)
  345. Args:
  346. original_feature: 原始特征
  347. search_word: 搜索词
  348. notes: 帖子列表
  349. max_notes: 最多评估几条帖子
  350. max_images_per_note: 每条帖子最多取几张图片
  351. Returns:
  352. 评估结果
  353. """
  354. if not notes:
  355. return {
  356. "overall_relevance": 0.0,
  357. "extracted_elements": [],
  358. "recommended_extension": None,
  359. "evaluated_notes": []
  360. }
  361. # 限制评估数量
  362. notes_to_eval = notes[:max_notes]
  363. # 准备文本信息
  364. notes_info = []
  365. all_images = []
  366. for idx, note in enumerate(notes_to_eval):
  367. card = note.get("note_card", {})
  368. title = card.get("display_title", "")
  369. desc = card.get("desc", "")[:300] # 限制长度
  370. notes_info.append({
  371. "index": idx,
  372. "title": title,
  373. "desc": desc
  374. })
  375. # 收集图片
  376. images = card.get("image_list", [])[:max_images_per_note]
  377. all_images.extend(images)
  378. # 构建提示词
  379. notes_text = "\n\n".join([
  380. f"帖子 {n['index']}:\n标题: {n['title']}\n正文: {n['desc']}"
  381. for n in notes_info
  382. ])
  383. prompt = f"""你是一个小红书内容分析专家。
  384. 任务:评估搜索结果是否包含目标特征的元素
  385. 原始特征:"{original_feature}"
  386. 搜索词:"{search_word}"
  387. 帖子数量:{len(notes_to_eval)} 条
  388. 帖子内容:
  389. {notes_text}
  390. 请综合分析帖子的文字和图片内容,判断:
  391. 1. 这些搜索结果中是否包含与"{original_feature}"相似的元素
  392. 2. 提取最相关的元素关键词(2-4个字的词组)
  393. 3. 推荐最适合用于扩展搜索的关键词
  394. 返回JSON格式:
  395. {{
  396. "overall_relevance": 0.72, // 0.0-1.0,整体相关度
  397. "extracted_elements": ["关键词1", "关键词2", "关键词3"], // 提取的相似元素,按相关度排序
  398. "recommended_extension": "关键词1", // 最优的扩展关键词
  399. "evaluated_notes": [
  400. {{
  401. "note_index": 0, // 帖子索引
  402. "relevance": 0.85, // 该帖子的相关度
  403. "matched_elements": ["元素1", "元素2"], // 该帖子匹配的元素
  404. "reasoning": "简短的匹配理由"
  405. }}
  406. ]
  407. }}
  408. 注意:
  409. - extracted_elements 应该是帖子中实际包含的、与原始特征相似的元素
  410. - 优先提取在图片或文字中明显出现的元素
  411. - 只返回JSON,不要其他内容"""
  412. # 调用LLM(带图片)
  413. result = self.client.chat_json(
  414. prompt=prompt,
  415. images=all_images if all_images else None,
  416. max_retries=3
  417. )
  418. if result:
  419. # 确保返回完整格式
  420. return {
  421. "overall_relevance": result.get("overall_relevance", 0.0),
  422. "extracted_elements": result.get("extracted_elements", []),
  423. "recommended_extension": result.get("recommended_extension"),
  424. "evaluated_notes": result.get("evaluated_notes", [])
  425. }
  426. else:
  427. logger.error(f"评估搜索结果失败: {search_word}")
  428. return {
  429. "overall_relevance": 0.0,
  430. "extracted_elements": [],
  431. "recommended_extension": None,
  432. "evaluated_notes": []
  433. }
  434. def batch_evaluate_search_results(
  435. self,
  436. features_with_results: List[Dict[str, Any]],
  437. max_workers: int = 3
  438. ) -> List[Dict[str, Any]]:
  439. """
  440. 批量评估搜索结果(并行,但并发数较低以避免超时)
  441. Args:
  442. features_with_results: 带搜索结果的特征列表
  443. max_workers: 最大并发数
  444. Returns:
  445. 带评估结果的特征列表
  446. """
  447. logger.info(f"开始批量评估 {len(features_with_results)} 个搜索结果...")
  448. results = []
  449. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  450. # 提交任务
  451. future_to_feature = {}
  452. for feature in features_with_results:
  453. if not feature.get("search_result"):
  454. # 无搜索结果,跳过
  455. feature["result_evaluation"] = None
  456. results.append(feature)
  457. continue
  458. original_feature = self._get_original_feature(feature)
  459. search_word = feature.get("search_word", "")
  460. notes = feature["search_result"].get("data", {}).get("data", [])
  461. future = executor.submit(
  462. self.evaluate_search_results,
  463. original_feature,
  464. search_word,
  465. notes
  466. )
  467. future_to_feature[future] = feature
  468. # 收集结果
  469. for idx, future in enumerate(as_completed(future_to_feature), 1):
  470. feature = future_to_feature[future]
  471. try:
  472. evaluation = future.result()
  473. feature["result_evaluation"] = evaluation
  474. results.append(feature)
  475. logger.info(f" [{idx}/{len(future_to_feature)}] {feature.get('search_word')}: "
  476. f"relevance={evaluation['overall_relevance']:.3f}")
  477. except Exception as e:
  478. logger.error(f" 评估失败: {feature.get('search_word')}, 错误: {e}")
  479. feature["result_evaluation"] = None
  480. results.append(feature)
  481. logger.info(f"批量评估完成")
  482. return results
  483. def _get_original_feature(self, feature_node: Dict[str, Any]) -> str:
  484. """
  485. 从特征节点中获取原始特征名称
  486. Args:
  487. feature_node: 特征节点
  488. Returns:
  489. 原始特征名称
  490. """
  491. # 尝试从llm_evaluation中获取
  492. if "llm_evaluation" in feature_node:
  493. return feature_node["llm_evaluation"].get("original_feature", "")
  494. # 尝试从其他字段获取
  495. return feature_node.get("原始特征名称", feature_node.get("特征名称", ""))
  496. # ========== Stage 6: 两层评估方法 ==========
  497. def evaluate_query_relevance_batch(
  498. self,
  499. search_query: str,
  500. notes: List[Dict[str, Any]],
  501. max_notes: int = 20
  502. ) -> Dict[str, Any]:
  503. """
  504. 第一层评估:批量判断搜索结果与 Query 的相关性
  505. 一次 LLM 调用评估多个笔记的 Query 相关性
  506. Args:
  507. search_query: 搜索Query
  508. notes: 笔记列表
  509. max_notes: 最多评估几条笔记
  510. Returns:
  511. {
  512. "note_0": {"与query相关性": "相关", "说明": "..."},
  513. "note_1": {"与query相关性": "不相关", "说明": "..."},
  514. ...
  515. }
  516. """
  517. if not notes:
  518. return {}
  519. notes_to_eval = notes[:max_notes]
  520. # 构建笔记列表文本
  521. notes_text = ""
  522. for idx, note in enumerate(notes_to_eval):
  523. note_card = note.get('note_card', {})
  524. title = note_card.get('display_title', '')
  525. content = note_card.get('desc', '')[:800] # 限制长度
  526. images = note_card.get('image_list', [])
  527. notes_text += f"note_{idx}:\n"
  528. notes_text += f"- 标题: {title}\n"
  529. notes_text += f"- 正文: {content}\n"
  530. notes_text += f"- 图像: {len(images)}张图片\n\n"
  531. # 构建完整的第一层评估 Prompt(用户提供,不简化)
  532. prompt = f"""# 任务说明
  533. 判断搜索结果是否与搜索Query相关,过滤掉完全无关的结果。
  534. # 输入信息
  535. 搜索Query: {search_query}
  536. 搜索结果列表:
  537. {notes_text}
  538. # 判断标准
  539. ✅ 相关(保留)
  540. 搜索结果的标题、正文或图像内容中包含Query相关的信息:
  541. Query的核心关键词在结果中出现
  542. 或 结果讨论的主题与Query直接相关
  543. 或 结果是Query概念的上位/下位/平行概念
  544. ❌ 不相关(过滤)
  545. 搜索结果与Query完全无关:
  546. Query的关键词完全未出现
  547. 结果主题与Query无任何关联
  548. 仅因搜索引擎误匹配而出现
  549. ## 判断示例
  550. Query "墨镜搭配" → 结果"太阳镜选购指南" ✅ 保留(墨镜=太阳镜)
  551. Query "墨镜搭配" → 结果"眼镜搭配技巧" ✅ 保留(眼镜是墨镜的上位概念)
  552. Query "墨镜搭配" → 结果"帽子搭配技巧" ❌ 过滤(完全无关)
  553. Query "复古滤镜" → 结果"滤镜调色教程" ✅ 保留(包含滤镜)
  554. Query "复古滤镜" → 结果"相机推荐" ❌ 过滤(主题不相关)
  555. # 输出格式
  556. {{
  557. "note_0": {{
  558. "与query相关性": "相关 / 不相关",
  559. "说明": ""
  560. }},
  561. "note_1": {{
  562. "与query相关性": "相关 / 不相关",
  563. "说明": ""
  564. }}
  565. }}
  566. # 特殊情况处理
  567. - 如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行判断
  568. - 当无法明确判断时,倾向于保留(标记为"相关")
  569. 只返回JSON,不要其他内容。"""
  570. # 调用 LLM(批量评估)
  571. result = self.client.chat_json(
  572. prompt=prompt,
  573. max_retries=3
  574. )
  575. if result:
  576. return result
  577. else:
  578. logger.error(f" 第一层批量评估失败: Query={search_query}")
  579. # 返回默认结果(全部标记为"相关"以保守处理)
  580. default_result = {}
  581. for idx in range(len(notes_to_eval)):
  582. default_result[f"note_{idx}"] = {
  583. "与query相关性": "相关",
  584. "说明": "LLM评估失败,默认保留"
  585. }
  586. return default_result
  587. def evaluate_feature_matching_single(
  588. self,
  589. target_feature: str,
  590. note_title: str,
  591. note_content: str,
  592. note_images: List[str],
  593. note_index: int
  594. ) -> Dict[str, Any]:
  595. """
  596. 第二层评估:评估单个笔记与目标特征的匹配度
  597. Args:
  598. target_feature: 目标特征
  599. note_title: 笔记标题
  600. note_content: 笔记正文
  601. note_images: 图片URL列表
  602. note_index: 笔记索引
  603. Returns:
  604. {
  605. "综合得分": 0.9, # 0-1分
  606. "匹配类型": "完全匹配",
  607. "评分说明": "...",
  608. "关键匹配点": [...]
  609. }
  610. """
  611. # 构建完整的第二层评估 Prompt(用户提供,不简化)
  612. prompt = f"""# 任务说明
  613. 你需要判断搜索到的案例与目标特征的相关性。
  614. # 输入信息
  615. 目标特征:{target_feature}
  616. 搜索结果:
  617. - 标题: {note_title}
  618. - 正文: {note_content[:800]}
  619. - 图像: {len(note_images)}张图片(请仔细分析图片内容,包括OCR提取图片中的文字)
  620. # 判断流程
  621. ## 目标特征匹配度评分
  622. 综合考虑语义相似度(概念匹配、层级关系)和场景关联度(应用场景、使用语境)进行评分:
  623. - 0.8-1分:完全匹配
  624. 语义层面:找到与目标特征完全相同或高度一致的内容,核心概念完全一致
  625. 场景层面:完全适用于同一场景、受众、平台和语境
  626. 示例:
  627. 目标"复古滤镜" + 小红书穿搭场景 vs 结果"小红书复古滤镜调色教程"
  628. 目标"墨镜" + 时尚搭配场景 vs 结果"时尚墨镜搭配指南"
  629. - 0.6-0.7分:相似匹配
  630. 语义层面:
  631. 结果是目标的上位概念(更宽泛)或下位概念(更具体)
  632. 或属于同一概念的不同表现形式,或属于平行概念(同级不同类)
  633. 场景层面:场景相近但有差异,需要筛选或调整后可用
  634. 示例:
  635. 目标"墨镜" + 时尚搭配 vs 结果"眼镜搭配技巧"(上位概念,需筛选)
  636. 目标"怀旧滤镜" + 人像拍摄 vs 结果"胶片感调色"(不同表现形式)
  637. 目标"日常穿搭" + 街拍 vs 结果"通勤穿搭拍照"(场景相近)
  638. - 0.5-0.6分:弱相似
  639. 语义层面:属于同一大类但具体方向或侧重点明显不同,仅提供了相关概念
  640. 场景层面:场景有明显差异,迁移需要较大改造
  641. 示例:
  642. 目标"户外运动穿搭" vs 结果"健身房穿搭指南"
  643. 目标"小红书图文笔记" vs 结果"抖音短视频脚本"
  644. - 0.4分及以下:无匹配
  645. 语义层面:仅表面词汇重叠,实质关联弱,或概念距离过远
  646. 场景层面:应用场景基本不同或完全不同
  647. 示例:
  648. 目标"墨镜" vs 结果"配饰大全"(概念过于宽泛)
  649. 目标"美食摄影构图" vs 结果"美食博主日常vlog"
  650. ## 概念层级关系说明
  651. 在评分时,需要注意概念层级关系的影响:
  652. 完全匹配(同一概念 + 同场景)→ 0.8-1分
  653. 目标"墨镜" vs 结果"墨镜搭配",且都在时尚搭配场景
  654. 上位/下位概念(层级差一层)→ 通常0.6-0.7分
  655. 目标"墨镜" vs 结果"眼镜搭配"(结果更宽泛,需筛选)
  656. 目标"眼镜" vs 结果"墨镜选购"(结果更具体,部分适用)
  657. 平行概念(同级不同类)→ 通常0.6-0.7分
  658. 目标"墨镜" vs 结果"近视眼镜"(都是眼镜类,但功能场景不同)
  659. 远距离概念(层级差两层及以上)→ 0.5分及以下
  660. 目标"墨镜" vs 结果"配饰"(概念过于宽泛,指导性弱)
  661. # 匹配结论判断
  662. 根据综合得分判定匹配类型:
  663. 0.8-1.0分:✅ 完全匹配
  664. 判断:找到了目标特征的直接灵感来源
  665. 建议:直接采纳为该特征的灵感溯源结果
  666. 0.6-0.79分:⚠️ 相似匹配
  667. 判断:找到了相关的灵感参考,但存在一定差异
  668. 建议:作为候选结果保留,可与其他结果综合判断或继续搜索更精确的匹配
  669. 0.59分及以下:❌ 无匹配
  670. 判断:该结果与目标特征关联度不足
  671. 建议:排除该结果,需要调整搜索策略继续寻找
  672. # 输出格式
  673. {{
  674. "综合得分": 0.7,
  675. "匹配类型": "相似匹配",
  676. "评分说明": "结果'眼镜搭配技巧'是目标'墨镜'的上位概念,内容涵盖多种眼镜类型。场景都是时尚搭配,但需要从结果中筛选出墨镜相关的内容。概念关系:上位概念(宽泛一层)",
  677. "关键匹配点": [
  678. "眼镜与脸型的搭配原则(部分适用于墨镜)",
  679. "配饰的风格选择方法"
  680. ]
  681. }}
  682. # 特殊情况处理
  683. 复合特征评估:如果目标特征是复合型(如"复古滤镜+第一人称视角"),需要分别评估每个子特征的匹配度,然后取平均值作为最终得分
  684. 信息不完整:如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行评分
  685. 上位概念的实用性:当结果是目标的上位概念时,评分应考虑:内容中目标相关部分的占比;是否提供了可直接应用于目标的知识;场景的一致性程度;如果结果虽是上位概念但完全不涉及目标内容,应降至5-6分或更低
  686. 只返回JSON,不要其他内容。"""
  687. # 调用 LLM(传递图片进行多模态分析)
  688. result = self.client.chat_json(
  689. prompt=prompt,
  690. images=note_images if note_images else None,
  691. max_retries=3
  692. )
  693. if result:
  694. return result
  695. else:
  696. logger.error(f" 第二层评估失败: note {note_index}, target={target_feature}")
  697. return {
  698. "综合得分": 0.0,
  699. "匹配类型": "评估失败",
  700. "评分说明": "LLM评估失败",
  701. "关键匹配点": []
  702. }
  703. def evaluate_note_with_filter(
  704. self,
  705. search_query: str,
  706. target_feature: str,
  707. note_title: str,
  708. note_content: str,
  709. note_images: List[str],
  710. note_index: int = 0
  711. ) -> Dict[str, Any]:
  712. """
  713. 两层评估单个笔记(完整Prompt版本)
  714. 第一层:Query相关性过滤
  715. 第二层:目标特征匹配度评分
  716. Args:
  717. search_query: 搜索Query,如 "外观装扮 发布萌宠内容"
  718. target_feature: 目标特征,如 "佩戴"
  719. note_title: 笔记标题
  720. note_content: 笔记正文
  721. note_images: 图片URL列表(会传递给LLM进行视觉分析和OCR)
  722. note_index: 笔记索引
  723. Returns:
  724. 评估结果字典
  725. """
  726. # 构建完整的评估Prompt(用户提供的完整版本,一字不改)
  727. prompt = f"""# 任务说明
  728. 你需要判断搜索到的案例信息与目标特征的相关性。判断分为两层:第一层过滤与搜索Query无关的结果,第二层评估与目标特征的匹配度。
  729. # 输入信息
  730. 搜索Query:{search_query}
  731. 目标特征:{target_feature}
  732. 搜索结果:
  733. - 标题: {note_title}
  734. - 正文: {note_content[:800]}
  735. - 图像: {len(note_images)}张图片(请仔细分析图片内容,包括OCR提取图片中的文字)
  736. # 判断流程
  737. 第一层:Query相关性过滤
  738. 判断标准:搜索结果是否与搜索Query相关
  739. 过滤规则:
  740. ✅ 保留:搜索结果的标题、正文或图像内容中包含Query相关的信息
  741. Query的核心关键词在结果中出现
  742. 或结果讨论的主题与Query直接相关
  743. 或结果是Query概念的上位/下位/平行概念
  744. ❌ 过滤:搜索结果与Query完全无关
  745. Query的关键词完全未出现
  746. 结果主题与Query无任何关联
  747. 仅因搜索引擎误匹配而出现
  748. 示例:
  749. Query "墨镜搭配" → 结果"太阳镜选购指南" ✅ 保留(墨镜=太阳镜)
  750. Query "墨镜搭配" → 结果"眼镜搭配技巧" ✅ 保留(眼镜是上位概念)
  751. Query "墨镜搭配" → 结果"帽子搭配技巧" ❌ 过滤(完全无关)
  752. Query "复古滤镜" → 结果"滤镜调色教程" ✅ 保留(包含滤镜)
  753. Query "复古滤镜" → 结果"相机推荐" ❌ 过滤(主题不相关)
  754. 输出:
  755. 如果判定为 ❌ 过滤,直接输出:
  756. json{{
  757. "Query相关性": "不相关",
  758. "综合得分": 0,
  759. "匹配类型": "过滤",
  760. "说明": "搜索结果与Query '{search_query}' 完全无关,建议过滤"
  761. }}
  762. 如果判定为 ✅ 保留,进入第二层评分
  763. 第二层:目标特征匹配度评分
  764. 综合考虑语义相似度(概念匹配、层级关系、实操价值)和场景关联度(应用场景、使用语境)进行评分:
  765. 8-10分:完全匹配
  766. 语义层面:找到与目标特征完全相同或高度一致的内容,核心概念完全一致
  767. 场景层面:完全适用于同一场景、受众、平台和语境
  768. 实操价值:提供了具体可执行的方法、步骤或技巧
  769. 示例:
  770. 目标"复古滤镜" + 小红书穿搭场景 vs 结果"小红书复古滤镜调色教程"
  771. 目标"墨镜" + 时尚搭配场景 vs 结果"时尚墨镜搭配指南"
  772. 6-7分:相似匹配
  773. 语义层面:
  774. 结果是目标的上位概念(更宽泛)或下位概念(更具体)
  775. 或属于同一概念的不同表现形式
  776. 或属于平行概念(同级不同类)
  777. 场景层面:场景相近但有差异,需要筛选或调整后可用
  778. 实操价值:有一定参考价值但需要转化应用
  779. 示例:
  780. 目标"墨镜" + 时尚搭配 vs 结果"眼镜搭配技巧"(上位概念,需筛选)
  781. 目标"怀旧滤镜" + 人像拍摄 vs 结果"胶片感调色"(不同表现形式)
  782. 目标"日常穿搭" + 街拍 vs 结果"通勤穿搭拍照"(场景相近)
  783. 5-6分:弱相似
  784. 语义层面:属于同一大类但具体方向或侧重点明显不同
  785. 场景层面:场景有明显差异,迁移需要较大改造
  786. 实操价值:提供了概念启发但需要较大转化
  787. 示例:
  788. 目标"户外运动穿搭" vs 结果"健身房穿搭指南"
  789. 目标"小红书图文笔记" vs 结果"抖音短视频脚本"
  790. 4分及以下:无匹配
  791. 语义层面:仅表面词汇重叠,实质关联弱,或概念距离过远
  792. 场景层面:应用场景基本不同或完全不同
  793. 实操价值:实操指导价值有限或无价值
  794. 示例:
  795. 目标"墨镜" vs 结果"配饰大全"(概念过于宽泛)
  796. 目标"美食摄影构图" vs 结果"美食博主日常vlog"
  797. 概念层级关系说明
  798. 在评分时,需要注意概念层级关系的影响:
  799. 完全匹配(同一概念 + 同场景)→ 8-10分
  800. 目标"墨镜" vs 结果"墨镜搭配",且都在时尚搭配场景
  801. 上位/下位概念(层级差一层)→ 通常6-7分
  802. 目标"墨镜" vs 结果"眼镜搭配"(结果更宽泛,需筛选)
  803. 目标"眼镜" vs 结果"墨镜选购"(结果更具体,部分适用)
  804. 平行概念(同级不同类)→ 通常6-7分
  805. 目标"墨镜" vs 结果"近视眼镜"(都是眼镜类,但功能场景不同)
  806. 远距离概念(层级差两层及以上)→ 4分及以下
  807. 目标"墨镜" vs 结果"配饰"(概念过于宽泛,指导性弱)
  808. 匹配结论判断
  809. 根据综合得分判定匹配类型:
  810. 8.0-10.0分:✅ 完全匹配
  811. 判断:找到了目标特征的直接灵感来源
  812. 置信度:高
  813. 建议:直接采纳为该特征的灵感溯源结果
  814. 5.0-7.9分:⚠️ 相似匹配
  815. 判断:找到了相关的灵感参考,但存在一定差异
  816. 置信度:中
  817. 建议:作为候选结果保留,可与其他结果综合判断或继续搜索更精确的匹配
  818. 1.0-4.9分:❌ 无匹配
  819. 判断:该结果与目标特征关联度不足
  820. 置信度:低
  821. 建议:排除该结果,需要调整搜索策略继续寻找
  822. # 输出格式
  823. 通过Query相关性过滤的结果:
  824. json{{
  825. "Query相关性": "相关",
  826. "综合得分": 7.0,
  827. "匹配类型": "相似匹配",
  828. "置信度": "中",
  829. "评分说明": "结果'眼镜搭配技巧'是目标'墨镜'的上位概念,内容涵盖多种眼镜类型。场景都是时尚搭配,但需要从结果中筛选出墨镜相关的内容。概念关系:上位概念(宽泛一层)",
  830. "关键匹配点": [
  831. "眼镜与脸型的搭配原则(部分适用于墨镜)",
  832. "配饰的风格选择方法"
  833. ]
  834. }}
  835. 未通过Query相关性过滤的结果:
  836. json{{
  837. "Query相关性": "不相关",
  838. "综合得分": 0,
  839. "匹配类型": "过滤",
  840. "说明": "搜索结果'帽子搭配技巧'与Query'墨镜搭配'完全无关,建议过滤"
  841. }}
  842. # 特殊情况处理
  843. 复合特征评估:如果目标特征是复合型(如"复古滤镜+第一人称视角"),需要分别评估每个子特征的匹配度,然后取算术平均值作为最终得分
  844. 信息不完整:如果OCR提取的图像文字不完整或正文内容缺失,应在说明中注明,并根据实际可获取的信息进行评分
  845. 上位概念的实用性:当结果是目标的上位概念时,评分应考虑:
  846. 内容中目标相关部分的占比
  847. 是否提供了可直接应用于目标的知识
  848. 场景的一致性程度
  849. 如果结果虽是上位概念但完全不涉及目标内容,应降至5-6分或更低
  850. Query与目标特征的关系:
  851. 如果Query就是目标特征本身,第一层和第二层判断可以合并考虑
  852. 如果Query是为了探索目标特征而构建的更宽泛查询,第一层更宽松,第二层更严格
  853. 只返回JSON,不要其他内容。"""
  854. # 调用LLM(传递图片URL进行多模态分析)
  855. result = self.client.chat_json(
  856. prompt=prompt,
  857. images=note_images if note_images else None, # ✅ 传递图片
  858. max_retries=3
  859. )
  860. if result:
  861. # 添加笔记索引
  862. result['note_index'] = note_index
  863. return result
  864. else:
  865. logger.error(f" 评估笔记 {note_index} 失败: Query={search_query}")
  866. return {
  867. "note_index": note_index,
  868. "Query相关性": "评估失败",
  869. "综合得分": 0,
  870. "匹配类型": "评估失败",
  871. "说明": "LLM评估失败"
  872. }
  873. def batch_evaluate_notes_with_filter(
  874. self,
  875. search_query: str,
  876. target_feature: str,
  877. notes: List[Dict[str, Any]],
  878. max_notes: int = 20,
  879. max_workers: int = 10
  880. ) -> Dict[str, Any]:
  881. """
  882. 两层评估多个笔记(拆分为两次LLM调用)
  883. 第一层:批量评估Query相关性(1次LLM调用)
  884. 第二层:对"相关"的笔记评估特征匹配度(M次LLM调用)
  885. Args:
  886. search_query: 搜索Query
  887. target_feature: 目标特征
  888. notes: 笔记列表
  889. max_notes: 最多评估几条笔记
  890. max_workers: 最大并发数
  891. Returns:
  892. 评估结果汇总(包含统计信息)
  893. """
  894. if not notes:
  895. return {
  896. "total_notes": 0,
  897. "evaluated_notes": 0,
  898. "filtered_count": 0,
  899. "statistics": {},
  900. "notes_evaluation": []
  901. }
  902. notes_to_eval = notes[:max_notes]
  903. logger.info(f" 两层评估 {len(notes_to_eval)} 个笔记")
  904. # ========== 第一层:批量评估Query相关性 ==========
  905. logger.info(f" [第一层] 批量评估Query相关性(1次LLM调用)")
  906. query_relevance_result = self.evaluate_query_relevance_batch(
  907. search_query=search_query,
  908. notes=notes_to_eval,
  909. max_notes=max_notes
  910. )
  911. # 解析第一层结果,找出"相关"的笔记
  912. relevant_notes_info = []
  913. for idx, note in enumerate(notes_to_eval):
  914. note_key = f"note_{idx}"
  915. relevance_info = query_relevance_result.get(note_key, {})
  916. relevance = relevance_info.get("与query相关性", "相关") # 默认为"相关"
  917. if relevance == "相关":
  918. # 保留笔记信息用于第二层评估
  919. note_card = note.get('note_card', {})
  920. relevant_notes_info.append({
  921. "note_index": idx,
  922. "note_card": note_card,
  923. "title": note_card.get('display_title', ''),
  924. "content": note_card.get('desc', ''),
  925. "images": note_card.get('image_list', []),
  926. "第一层评估": relevance_info
  927. })
  928. logger.info(f" [第一层] 过滤结果: {len(relevant_notes_info)}/{len(notes_to_eval)} 条相关")
  929. # ========== 第二层:对相关笔记评估特征匹配度 ==========
  930. evaluated_notes = []
  931. if relevant_notes_info:
  932. logger.info(f" [第二层] 并行评估特征匹配度({len(relevant_notes_info)}次LLM调用,{max_workers}并发)")
  933. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  934. futures = []
  935. for note_info in relevant_notes_info:
  936. future = executor.submit(
  937. self.evaluate_feature_matching_single,
  938. target_feature,
  939. note_info["title"],
  940. note_info["content"],
  941. note_info["images"],
  942. note_info["note_index"]
  943. )
  944. futures.append((future, note_info))
  945. # 收集结果并合并
  946. for future, note_info in futures:
  947. try:
  948. second_layer_result = future.result()
  949. # 合并两层评估结果
  950. merged_result = {
  951. "note_index": note_info["note_index"],
  952. "Query相关性": "相关",
  953. "综合得分": second_layer_result.get("综合得分", 0.0), # 0-1分制
  954. "匹配类型": second_layer_result.get("匹配类型", ""),
  955. "评分说明": second_layer_result.get("评分说明", ""),
  956. "关键匹配点": second_layer_result.get("关键匹配点", []),
  957. "第一层评估": note_info["第一层评估"],
  958. "第二层评估": second_layer_result
  959. }
  960. evaluated_notes.append(merged_result)
  961. except Exception as e:
  962. logger.error(f" [第二层] 评估笔记 {note_info['note_index']} 失败: {e}")
  963. # 失败的笔记也加入结果
  964. evaluated_notes.append({
  965. "note_index": note_info["note_index"],
  966. "Query相关性": "相关",
  967. "综合得分": 0.0,
  968. "匹配类型": "评估失败",
  969. "评分说明": f"第二层评估失败: {str(e)}",
  970. "关键匹配点": [],
  971. "第一层评估": note_info["第一层评估"],
  972. "第二层评估": {}
  973. })
  974. # 添加第一层就被过滤的笔记(Query不相关)
  975. for idx, note in enumerate(notes_to_eval):
  976. note_key = f"note_{idx}"
  977. relevance_info = query_relevance_result.get(note_key, {})
  978. relevance = relevance_info.get("与query相关性", "相关")
  979. if relevance == "不相关":
  980. evaluated_notes.append({
  981. "note_index": idx,
  982. "Query相关性": "不相关",
  983. "综合得分": 0.0,
  984. "匹配类型": "过滤",
  985. "说明": relevance_info.get("说明", ""),
  986. "第一层评估": relevance_info
  987. })
  988. # 按note_index排序
  989. evaluated_notes.sort(key=lambda x: x.get('note_index', 0))
  990. # 统计信息
  991. total_notes = len(notes)
  992. evaluated_count = len(evaluated_notes)
  993. filtered_count = sum(1 for n in evaluated_notes if n.get('Query相关性') == '不相关')
  994. # 匹配度分布统计(使用0-1分制的阈值)
  995. match_distribution = {
  996. '完全匹配(0.8-1.0)': 0,
  997. '相似匹配(0.6-0.79)': 0,
  998. '弱相似(0.5-0.59)': 0,
  999. '无匹配(≤0.4)': 0
  1000. }
  1001. for note_eval in evaluated_notes:
  1002. if note_eval.get('Query相关性') == '不相关':
  1003. continue # 过滤的不计入分布
  1004. score = note_eval.get('综合得分', 0)
  1005. if score >= 0.8:
  1006. match_distribution['完全匹配(0.8-1.0)'] += 1
  1007. elif score >= 0.6:
  1008. match_distribution['相似匹配(0.6-0.79)'] += 1
  1009. elif score >= 0.5:
  1010. match_distribution['弱相似(0.5-0.59)'] += 1
  1011. else:
  1012. match_distribution['无匹配(≤0.4)'] += 1
  1013. logger.info(f" 评估完成: 过滤{filtered_count}条, 匹配分布: {match_distribution}")
  1014. return {
  1015. "total_notes": total_notes,
  1016. "evaluated_notes": evaluated_count,
  1017. "filtered_count": filtered_count,
  1018. "statistics": match_distribution,
  1019. "notes_evaluation": evaluated_notes
  1020. }
  1021. def test_evaluator():
  1022. """测试评估器"""
  1023. import os
  1024. # 初始化客户端
  1025. client = OpenRouterClient()
  1026. evaluator = LLMEvaluator(client)
  1027. # 测试搜索词评估
  1028. print("\n=== 测试搜索词评估 ===")
  1029. result = evaluator.evaluate_search_word(
  1030. original_feature="拟人",
  1031. search_word="宠物猫 猫咪"
  1032. )
  1033. print(f"评分: {result['score']:.3f}")
  1034. print(f"理由: {result['reasoning']}")
  1035. # 测试批量评估
  1036. print("\n=== 测试批量评估 ===")
  1037. results = evaluator.evaluate_search_words_batch(
  1038. original_feature="拟人",
  1039. search_words=["宠物猫 猫咪", "宠物猫 猫孩子", "宠物猫 猫"],
  1040. max_workers=2
  1041. )
  1042. for r in results:
  1043. print(f"{r['search_word']}: {r['score']:.3f} (rank={r['rank']})")
  1044. if __name__ == "__main__":
  1045. logging.basicConfig(
  1046. level=logging.INFO,
  1047. format='%(asctime)s - %(levelname)s - %(message)s'
  1048. )
  1049. test_evaluator()