llm_evaluator.py 45 KB

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