关键词提取评测:算法说重要,不代表用户觉得重要
一、TF-IDF 给了"点击"最高权重,但用户搜索的是"退货流程"
在做搜索相关性评测时,我遇到一个经典的算法-用户错位问题:
一篇关于"电商退货流程"的帮助文档,TF-IDF 给出的 Top-3 关键词是"点击"、"订单"、"页面"——三个操作步骤词汇。但用户搜索"退货流程"时直接跳过了这篇文章,因为标题和关键词里缺了"退货"二字。
算法认为重要的词(高频但无感),和用户认为重要的词(核心但低频),经常不是同一组词。这种错位是关键词提取在真实场景中的根本挑战。
这个问题的本质是:TF-IDF 的"重要"定义基于统计学(TF × IDF),而用户的"重要"定义基于任务需求——两者不在同一个语义空间里。
二、统计显著 ≠ 语义显著:为什么 TF-IDF 和 TextRank 都会犯错
TF-IDF 的假设是:一个词在文档中频繁出现、但在整个文档集中很少出现,就是关键词。
这个假设在 90% 的通用场景下是成立的。但剩下的 10%——那些核心语义词恰好被淹没在大量描述性词汇中的场景——就是算法的盲区。
TextRank 试图通过图算法捕捉词与词之间的共现关系,但它的基础仍然是词在文档中的出现频率。如果一个核心词只出现了两次但承载了整篇文章的语义骨架,TF-IDF 和 TextRank 都会错过它。
graph TD A[输入文档] --> B[预处理: 分词 + 去停用词] B --> C{关键词提取算法} C --> D[TF-IDF: 统计频率] C --> E[TextRank: 图排序] C --> F[KeyBERT: 嵌入相似度] C --> G[LLM: 语义理解] D --> H[高频词优先] E --> I[共现关系优先] F --> J[语义相似度优先] G --> K[全局理解优先] H --> L{对比人工标注} I --> L J --> L K --> L L --> M[F1 分数] L --> N[用户满意度] M --> O[算法得分 vs 用户评分] N --> O O --> P[错位: 算法高分 ≠ 用户满意]见证奇迹的时刻:同一篇文档,四种算法给出的 Top-5 关键词只有 1 个重叠——而重叠的这个词恰好不是用户认为最重要的那个。
三、多算法融合与人工评价框架
以下实现了一个多算法融合的关键词提取系统,支持自动评测和人工评价反馈:
from typing import List, Dict, Tuple, Optional from dataclasses import dataclass import numpy as np from collections import Counter import jieba import jieba.analyse from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity from sentence_transformers import SentenceTransformer @dataclass class KeywordResult: word: str score: float source: str # "tfidf", "textrank", "keybert", "llm" class MultiStrategyKeywordExtractor: """多策略关键词提取器 —— 取各家之长""" def __init__(self): # KeyBERT 需要嵌入模型 self.embedder = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") # TF-IDF 向量化器 self.tfidf_vectorizer = None def extract_tfidf( self, text: str, top_k: int = 10 ) -> List[KeywordResult]: """TF-IDF 提取 设计原因:最经典的关键词提取算法。 优点:简单、快、有统计理论支撑。 缺点:只统计词频,完全不理解语义。 """ # 使用 jieba 的 TF-IDF 实现 keywords = jieba.analyse.extract_tags( text, topK=top_k, withWeight=True ) return [ KeywordResult(word=word, score=weight, source="tfidf") for word, weight in keywords ] def extract_textrank( self, text: str, top_k: int = 10 ) -> List[KeywordResult]: """TextRank 提取 设计原因:基于图的排序算法。 优点:考虑词之间的共现关系。 缺点:仍然依赖词频,对低频核心词不敏感。 """ keywords = jieba.analyse.textrank( text, topK=top_k, withWeight=True ) return [ KeywordResult(word=word, score=weight, source="textrank") for word, weight in keywords ] def extract_keybert( self, text: str, top_k: int = 10 ) -> List[KeywordResult]: """KeyBERT 提取 —— 基于嵌入相似度 设计原因:将文档和候选词都编码为嵌入向量, 通过余弦相似度排序。相比 TF-IDF, 能更好地捕捉语义上重要但频率不高的词。 """ # 分词 words = list(jieba.cut(text)) # 去停用词和单字词 candidate_words = [ w for w in words if len(w) > 1 and not w.isspace() ] if not candidate_words: return [] # 文档嵌入 doc_embedding = self.embedder.encode(text) # 候选词嵌入 + 相似度计算 word_scores = [] for word in list(set(candidate_words))[:100]: # 限制候选词数量 word_embedding = self.embedder.encode(word) # 设计原因:同时考虑 文档-词相似度 和 词-词去重 sim = float(cosine_similarity( [doc_embedding], [word_embedding] )[0][0]) word_scores.append((word, sim)) word_scores.sort(key=lambda x: x[1], reverse=True) return [ KeywordResult(word=word, score=score, source="keybert") for word, score in word_scores[:top_k] ] def ensemble_extract( self, text: str, top_k: int = 10, weights: Dict[str, float] = None, ) -> List[KeywordResult]: """多算法融合提取 设计原因:单算法有盲区,多算法融合能互补。 融合策略:各算法独立提取 → 归一化分数 → 加权合并。 权重设计: - TF-IDF 0.25: 统计基础 - TextRank 0.15: 共现关系 - KeyBERT 0.60: 语义相关(权重最高,因为与用户感知最一致) """ if weights is None: weights = {"tfidf": 0.25, "textrank": 0.15, "keybert": 0.60} # 各算法独立提取 tfidf_results = self.extract_tfidf(text, top_k=top_k * 2) textrank_results = self.extract_textrank(text, top_k=top_k * 2) keybert_results = self.extract_keybert(text, top_k=top_k * 2) # 收集所有候选词及其各算法得分 all_words: Dict[str, Dict[str, float]] = {} for result in tfidf_results: all_words.setdefault(result.word, {})["tfidf"] = result.score for result in textrank_results: all_words.setdefault(result.word, {})["textrank"] = result.score for result in keybert_results: all_words.setdefault(result.word, {})["keybert"] = result.score # 归一化各算法得分 def normalize(scores_dict: Dict[str, float]) -> Dict[str, float]: if not scores_dict: return {} max_val = max(scores_dict.values()) return {k: v / max_val for k, v in scores_dict.items()} # 收集各算法的原始得分进行归一化 tfidf_raw = {w: all_words[w].get("tfidf", 0) for w in all_words} textrank_raw = {w: all_words[w].get("textrank", 0) for w in all_words} keybert_raw = {w: all_words[w].get("keybert", 0) for w in all_words} tfidf_norm = normalize(tfidf_raw) textrank_norm = normalize(textrank_raw) keybert_norm = normalize(keybert_raw) # 加权合并 final_scores = {} for word in all_words: score = ( weights["tfidf"] * tfidf_norm.get(word, 0) + weights["textrank"] * textrank_norm.get(word, 0) + weights["keybert"] * keybert_norm.get(word, 0) ) final_scores[word] = score # 排序取 Top-K sorted_words = sorted( final_scores.items(), key=lambda x: x[1], reverse=True ) return [ KeywordResult(word=word, score=score, source="ensemble") for word, score in sorted_words[:top_k] ] class KeywordEvaluator: """关键词提取评测器 —— 算法得分 vs 人工标注""" @staticmethod def precision_recall_f1( predicted: List[str], ground_truth: List[str] ) -> Dict[str, float]: """计算精准率、召回率和 F1""" pred_set = set(predicted) gt_set = set(ground_truth) tp = len(pred_set & gt_set) precision = tp / len(pred_set) if pred_set else 0 recall = tp / len(gt_set) if gt_set else 0 f1 = ( 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 ) return { "precision": round(precision, 3), "recall": round(recall, 3), "f1": round(f1, 3), "tp": tp, "pred_count": len(pred_set), "gt_count": len(gt_set), } @staticmethod def rank_correlation( predicted: List[str], ground_truth: List[str] ) -> float: """计算排序相关性 —— 提取顺序是否与人工排序一致 设计原因:F1 只看关键词集合的重叠, 但关键词的排序也很重要。 这里用简化版的 Kendall's tau 来评估排序一致性。 """ # 为预测和标注的关键词分配排名 pred_rank = {word: i for i, word in enumerate(predicted)} gt_rank = {word: i for i, word in enumerate(ground_truth)} common_words = set(predicted) & set(ground_truth) if len(common_words) < 2: return 0.0 concordant = 0 discordant = 0 common_list = list(common_words) for i in range(len(common_list)): for j in range(i + 1, len(common_list)): w1, w2 = common_list[i], common_list[j] pred_order = pred_rank[w1] < pred_rank[w2] gt_order = gt_rank[w1] < gt_rank[w2] if pred_order == gt_order: concordant += 1 else: discordant += 1 total = concordant + discordant return (concordant - discordant) / total if total > 0 else 0.0四、算法评分高 ≠ 用户满意度高
| 评价方式 | 测量什么 | 局限性 |
|---|---|---|
| F1(vs 人工标注) | 关键词重叠率 | 假设人工标注是真理 |
| NDCG(排序质量) | 排序相关性 | 对低频词不敏感 |
| 用户点击率 | 实际用户行为 | 受位置、UI 等因素影响 |
| 用户满意度评分 | 主观感受 | 成本高、样本小 |
最终的检验标准不是算法对你的测试集有多高的 F1,而是用户能不能通过你提取的关键词快速找到他们想要的信息。这是关键词提取的终极评测指标——也是最难量化的指标。
但在真实系统中,用户满意度数据的获取周期通常按周甚至月计算,而算法迭代需要天级别的反馈。一个折中方案是建立人工标注的"金标准"测试集作为日常迭代的代理指标,每月再用线上用户行为数据验证一次方向是否正确。
五、总结
关键词提取的核心矛盾是:统计显著性和语义显著性之间的鸿沟。
核心结论:
- TF-IDF 和 TextRank 依赖词频统计,核心语义词可能因低频被错过
- KeyBERT 通过嵌入相似度部分弥补了语义盲区
- 多算法融合(TF-IDF + TextRank + KeyBERT)比任何单一算法更稳健
- 评测应同时包含 F1(集合重叠)和排序相关性(肯德尔 tau)
- 最终的评测标准是用户满意度,而非算法对人工标注的拟合度
工程建议:在项目中同时跑多个关键词提取算法,对比输出结果。当不同算法给出的关键词列表差异很大时(重叠率 < 30%),说明文档的关键词信息不够明确,需要考虑人工干预。