☰
中文关键词提取实战:TF-IDF与TextRank完整实现与调优指南
2026/10/14 8:35:04 网站建设 项目流程

做自然语言处理项目,十次里有八次要先过关键词提取这一关。无论是做舆情系统、搜索召回、文本分类预处理,还是做自动摘要,关键词都是最基础也最实用的信息载体。中文文本的关键词提取看似简单,实际上手就是一道坎:英文单词天然按空格切分,中文呢?“自然语言处理”应该是一个词还是三个词?“的、了、是”这类虚词怎么处理才算干净?这些问题的答案,会直接影响后续所有算法效果的上限。

如果你正准备在自己的项目里实现一个中文关键词提取模块,或者想搞清楚 TF-IDF、TextRank 这些算法在中文场景下的真实表现,这篇实战记录应该能帮你少走不少弯路。我会从核心思路讲起,对比几种主流方案,再给出可以直接跑的完整代码,最后把我这些年踩过的坑和排查思路一并整理出来。内容按从浅到深的顺序组织,既有原理也有工程细节,适合刚接触 NLP 的读者,也适合想系统落地关键词模块的工程人员。

1. 中文关键词提取的核心思路

1.1 真实项目中为什么需要关键词提取

很多人会直觉地认为,关键词提取就是“把文章里出现次数多的词挑出来”。但真实项目里需求往往是多样的。我在一个客服工单分类系统里,需要从用户反馈中抽出“充电慢”“屏幕闪烁”“自动重启”这类症状词,用来做问题定位;在另一个资讯聚合项目里,需要提取“新能源汽车市场趋势”这类话题短语,供推荐系统使用;还有一个搜索相关的模块,要用关键词做同义改写和索引扩充。同样是“关键词提取”,不同场景对粒度、数量和召回率的要求完全不同。

所以做这个模块之前,先问自己三个问题:关键词是给机器用的还是给人看的?是只取单个名词,还是需要能表达完整语义的短语?核心诉求是准确还是召回?

这会直接影响你选什么算法。人和机器的需求差异很典型——给人看的关键词,需要符合阅读直觉,通常两三个词或四字短语更好;给机器建模用的关键词,哪怕粒度碎一点,只要词表覆盖率高就行。想明白需求,后面才不会被“哪个算法更高级”这种问题困住。

1.2 中文与英文关键词提取的本质差异:先过分词这道坎

英文文本做关键词提取,预处理相对轻量:按空格和标点切分,再做词形还原和停用词过滤基本就够。中文没有天然分隔符,必须先分词。分词本身就是一个完整的研究方向,它会直接影响关键词提取的结果。

比如“研究生命科学”,“研究/生命/科学”和“研究生/命/科学”完全是两种结果。多数场景下,我们会优先选择工程上更稳的分词工具(后面会细说),但分词产生的误差一定会传导到关键词提取阶段,所以排查问题时,我永远先怀疑分词,而不是先怀疑关键词算法。

另一个差异是中文里虚词使用频率极高,“的、了、在、是、和”这类词在统计权重时会产生大量噪音,必须通过停用词表过滤。此外中文字与字之间没有形态变化,不存在英文里的时态、单复数问题,做词形还原这步可以省掉,但相应地,同一个概念的语义变体需要靠同义词映射或词向量来兜底。这些差异决定了中文关键词提取的技术栈整体上是“分词 + 词性过滤 + 权重计算”的组合,而不是单纯的词频统计。

2. 三种主流算法选型:统计信心、图排序与向量化

2.1 TF-IDF:最朴素但最可靠的统计方案

TF-IDF 的核心直觉一句话能说清:如果一个词在本篇文档里出现得多,但在整个语料里出现得少,那它对这篇文档就有代表性。

公式拆开看,词频叫 (TF),在有分词结果后,统计某词在当前文档里出现的次数,再做归一化(一般除以文档总词数,避免长文档天然占便宜)。逆文档频率叫 (IDF),写作 (IDF = \log(\frac{N}{n_i+1})+1),其中 (N) 是语料文档总数,(n_i) 是包含该词 (i) 的文档数。(TF) 乘以 (IDF) 就是最终权重。

这里有个关键参数需要理解:为什么 (IDF) 要用对数?因为文档数可能相差几个数量级,直接相除会让数值分布太极端,取对数能把这种差异压缩到可比较的区间。这个平滑处理是统计方案里最容易被忽视、又最影响实际效果的细节。

TF-IDF 的选型理由很直接:计算简单、可解释性强、对长文本效果好。它最大的缺点是依赖一个质量足够好的外部语料来计算 IDF——语料越有代表性,IDF 越可靠。如果只有几十篇文档,算出来的 IDF 没有统计意义。这种场景下 TextRank 会更合适。

基于常见实践经验,我给一个参考:优先用公开语料预计算好的 IDF 词典(比如 jieba 自带的 idf.txt),如果你处理的是垂直领域文本,再把领域语料和通用词典合并重算。这个做法多数情况下效果最稳。

2.2 TextRank:不需要外部语料的图排序算法

TextRank 的思路源自 PageRank。它把文章里的每个词当成一个节点,如果一个词和另一个词在一定窗口大小内共同出现,就在两个节点之间连一条边。然后通过迭代计算每个节点的权重,最终排序取 TopN。这样做的好处是它不需要 IDF 这类外部统计量,单篇文档也能跑,天然适合短文本或语料不足的场景。

公式上简洁地写,节点 (V_i) 的得分为 (S(V_i) = (1-d) + d \times \sum_{V_j \in In(V_i)} \frac{w_{ji}}{\sum_{V_k \in Out(V_j)} w_{jk}} S(V_j)),其中 (d) 是阻尼系数,通常取 0.85。这个公式不必死记,但要知道几个会影响结果的参数:窗口大小(一般是 5,窗口太小词共现关系稀疏,太大引入噪音)、是否有权(共现次数是否算权重)、以及是否过滤词性(通常只保留名词、动词、形容词,否则虚词会污染图结构)。

TextRank 的坑在于,它选出的词常常偏向“在文章里被反复提到的内容”,如果文章本身频繁重复同一个泛化概念(比如“问题”),这个词会被高估。所以实际落地时我一般会对 TextRank 结果再做一层词性过滤或位置加权(标题、首段、段首句本身可以给更高初始权重)。

2.3 词向量与主题模型:什么时候才值得升级

TF-IDF 和 TextRank 属于“统计派”,核心缺陷是忽略了语义。比如一篇讲“苹果发布新手机”的文章,“苹果”和“手机”在语义上的关联没有被建模,只按字面共现和频率打分。于是近些年出现基于词向量和主题模型的方案——用预训练向量表示词的语义,再通过 TextRank 式图网络上的相似度矩阵来加权;或者用 LDA、BERTopic 这类主题模型把文档归到若干主题,再选每个主题下权重最高的词作为关键词。

我的观点是,这两种方案适合的是“关键词不是独立词,而是领域概念”的场景。比如医疗病历里“心肌梗死”和“心梗”是同义词,统计方案会分成两笔,向量方案可以自动聚合。但代价也明显:需要预训练模型、需要更多算力,调参复杂度上升,且结果可解释性变差。

所以选型建议很朴素:起步阶段、数据量小、场景要求快速上线,直接 TF-IDF 或 TextRank 足够;等积累了足够的标注数据或领域语料,再引入词向量或主题模型做增强。多数项目在第一阶段就已经解决了 80% 的问题,不必一上来就上预训练模型。

3. 实战落地:从零构建中文关键词提取模块

3.1 环境准备与工具选型:为什么先碰 jieba

中文分词工具我实际用过几类:jieba 属于轻量型,速度快,词典可控,适合快速验证和中小规模应用;HanLP 和 LTP 提供了更完整的分词、词性标注、依存分析能力,适合需要更深语法信息的场景;像 THULAC 也在多领域语料上表现稳定。如果你只是做关键词提取,而不是完整的句法分析,我建议先从 jieba 开始——它的词典体系、词性标注、内置 IDF 资源都和关键词提取直接配套,安装之后基本零成本就能跑通。

环境上只需要 Python 3.8+,安装 jieba 就够了。如果你后面要自己算 IDF,需要 pandas 和 numpy;要画效果对比图,再加 matplotlib。我自己的经验是,不要一上来就搭虚拟环境之外的额外依赖,关键词提取模块的核心算法本身很轻,方法级别跑通,再考虑工程化。

这里补充一个很多人不知道的细节:jieba 自带 idf.txt 文件,里面是通用领域语料统计得到的 IDF 值。它的覆盖范围偏向新闻和通用文本,如果你的领域专业性很强,强烈建议在领域语料里重新统计 IDF。做法在 3.2 节给出。

3.2 TF-IDF 关键词提取的完整代码实现

先给你可以直接落地的完整实现,核心分两部分:加载已有 IDF 词典,以及对单篇文档提取 TopN 关键词。

import jieba import jieba.posseg as pseg import math from collections import Counter import os # ------------------------------------------------------------ # 1. 读取 jieba 内置 IDF,或自定义 IDF 词典 # IDF 词典格式:词<TAB>数值,每行一个词 # ------------------------------------------------------------ def load_idf_dict(idf_path=None): idf_dict = {} if idf_path and os.path.exists(idf_path): with open(idf_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split("\t") if len(parts) == 2: w, score = parts idf_dict[w] = float(score) return idf_dict # 没有自定义词典时,退回到 jieba 内置词典 idf_path = os.path.join(os.path.dirname(jieba.__file__), "analyse", "idf.txt") with open(idf_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split(" ") if len(parts) == 2: w, score = parts idf_dict[w] = float(score) return idf_dict # ------------------------------------------------------------ # 2. 停用词加载 # ------------------------------------------------------------ def load_stopwords(stopwords_path): stopwords = set() if stopwords_path and os.path.exists(stopwords_path): with open(stopwords_path, "r", encoding="utf-8") as f: for line in f: w = line.strip() if w: stopwords.add(w) return stopwords # ------------------------------------------------------------ # 3. TF-IDF 关键词提取 # 自定义分词函数,先按词性过滤,再统计 TF 和 IDF # ------------------------------------------------------------ def tfidf_keywords(text, idf_dict, stopwords, top_k=10): # 使用词性标注接口,方便后续过滤 words = pseg.cut(text) word_tf = Counter() total_words = 0 for word, flag in words: w = word.strip().lower() if not w or len(w) < 2: continue if w in stopwords: continue # 常见做法:只保留名词、动词、形容词、动名词等实词 if flag.startswith("n") or flag.startswith("v") or flag.startswith("a") or flag in ("nz", "vn"): word_tf[w] += 1 total_words += 1 scores = {} for w, tf in word_tf.items(): idf_score = idf_dict.get(w, 1.5) # 未登录词的 IDF 用一个默认值 tf_score = tf / total_words scores[w] = tf_score * idf_score sorted_words = sorted(scores.items(), key=lambda x: x[1], reverse=True) return sorted_words[:top_k] # 使用示例 if __name__ == "__main__": text = """ 华为在开发者大会上发布了最新的鸿蒙操作系统版本,该系统优化了多设备协同能力, 并推出了面向开发者的新工具链。与会专家表示,操作系统的生态建设依赖于应用开发者的积极参与, 而开发工具的完善程度直接影响开发效率和应用质量。 """ idf_dict = load_idf_dict() # 使用 jieba 内置 IDF stopwords = load_stopwords("stopwords.txt") # 自己准备一个停用词表 result = tfidf_keywords(text, idf_dict, stopwords, top_k=8) for w, score in result: print(f"{w}\t{score:.4f}")

这段代码里有几个关键点需要解释:

第一,词性过滤。我保留了n(名词)、v(动词)、a(形容词)和nz(其他专名)、vn(动名词)。关键词提取经典策略是“名词为主、动词辅助、形容词慎用”,因为形容词主观色彩强,容易引入噪音。你要是做新闻资讯类场景,这组过滤规则基本够用。

第二,未登录词的 IDF 默认值。代码里写了idf_dict.get(w, 1.5),意思是词不在词典里时给一个默认 IDF。为什么是 1.5?这是经验值,按公式 (\log(\frac{N}{1})=取对数),如果语料规模是几十万篇,IDF 大约在 10 以上,但那会让新词权重过高;直接给 1.5 左右可以避免未登录词抢占排名。实际你可以根据自己的语料规模调。

第三,TF 归一化。我除以total_words而不是原始总词频,是为了让长文档和短文档的 TF 可比。如果你处理的文档长度差异很大,这一步很重要。

3.3 自定义 IDF:让垂直领域文本效果立住

如果你只看通用新闻,上一节代码就够了。但你要做的是垂直领域,比如医疗、金融、法律,jieba 内置 IDF 不一定准确。举一个我遇到的真实问题:在医疗病历场景里,“患者”这个词在几乎所有文档里都出现,但 jieba 内置 IDF 给的权重很高,导致每篇病历提取出的关键词都有“患者”,这对后续聚类一点用没有。原因就是通用语料里“患者”没那么常见,但垂直语料里它就是高频词。

解决办法是在领域语料里重算 IDF。收集一批领域文档(数量要尽量多,建议至少几千篇),统计每个词出现在多少篇文档里,然后按 (IDF = \log(\frac{N}{n_i+1})+1) 计算。

import os import jieba from collections import Counter def build_idf_from_corpus(corpus_dir): doc_count = 0 word_doc_freq = Counter() for filename in os.listdir(corpus_dir): path = os.path.join(corpus_dir, filename) if not filename.endswith(".txt"): continue with open(path, "r", encoding="utf-8") as f: content = f.read() if not content.strip(): continue doc_count += 1 # 一句话去重:同一个词在一篇文档里只算一次 words = set([w for w in jieba.cut(content) if len(w.strip()) > 1]) for w in words: word_doc_freq[w] += 1 idf_dict = {} for w, df in word_doc_freq.items(): idf_dict[w] = math.log(doc_count / (df + 1)) + 1 return idf_dict

细节强调一遍:统计的是“文档频率”而不是“词频”。一个词在单篇文档里出现一百次,在 IDF 计算里也只能贡献一次。如果按词频算,长文档会污染统计结果。构建完自定义 IDF 后,把它保存成文本,再加载进 3.2 节的load_idf_dict即可。

3.4 TextRank 关键词提取的代码实现

TextRank 在 jieba 里也有现成实现(jieba.analyse.textrank),但为了让你理解机制,我给出一个更容易改动的版本。核心逻辑:建图、迭代、归一化。

import jieba.posseg as pseg from collections import defaultdict import math def textrank_keywords(text, top_k=10, window=5, damping=0.85, max_iter=50): # 1. 分词 + 词性过滤 words = pseg.cut(text) candidate = [] for word, flag in words: w = word.strip().lower() if len(w) < 2: continue if flag.startswith("n") or flag.startswith("v") or flag.startswith("a") or flag in ("nz", "vn"): candidate.append(w) # 2. 按窗口建共现图 graph = defaultdict(Counter) for i, w in enumerate(candidate): for j in range(i + 1, min(i + 1 + window, len(candidate))): # 注意这里 j 的窗口范围,常见写法是 j in range(i+1, i+window+1)

我不建议在博文里直接把完整 TextRank 代码平铺出来,因为 jieba 自带实现已经可用。这里演示的是核心建图逻辑:以共现窗口构建稀疏矩阵,然后迭代。实际项目里我更多会使用jieba.analyse.textrank(text, topK=10, withWeight=True),它内部已经实现了过滤、建图、迭代和归一化。如果你要改动窗口参数或阻尼系数,再看源码调整。

有一点值得提醒:TextRank 对短文本效果比 TF-IDF 好,因为不需要外部 IDF 统计量,但这个优势在你只有单篇短文本时不明显。短文本词共现关系太少,窗口内基本都是同一句话的词,图结构严重退化。这时候它的输出和“词频统计”差不了太多,不必神化。

3.5 多词短语提取:让关键词变成可读语义单元

单个词提取出来往往不够用,“操作系统”是一个词,但“操作系统生态建设”在语义上是一个更完整的概念。要做多词短语提取,我的方案分两步:第一步分词并保留候选词;第二步按词性规则合并相邻词。

规则我总结了几组常用的模式:名词 + 名词(比如“系统 + 开发”);形容词 + 名词(“高效 + 系统”);名词 + 动词(“数据 + 处理”)。这种分析在 jieba 的词性标注基础上实现并不复杂。你可以自己写一个相邻候选词的合并函数,如果两词之间的词性组合命中规则,并且连接后整体得分(两词得分相加)大于阈值,就输出为短语。

我在项目里实际体验是,短语提取可以把关键词的“可读性”提升不少,但代价是召回整体下降,因为规则覆盖率有限。如果你的需求重点是召回(比如用于搜索索引),建议单字词和短语双通道输出,不要互相替代。

4. 效果优化与验收:跑数据之前必须想清楚的事

4.1 停用词表怎么建才实用:不能直接抄网上的通用表

停用词表是中文关键词提取里最容易被低估的模块。网上流传的通用停用词表,覆盖了“的、了、是、在、我、你”这类高频虚词,但真实项目里的“停用词”远不止这些。

举一个真实例子:在汽车论坛场景,“车”“车子”“问题”“感觉”这类泛化词出现频率极高,但它们对区分不同话题毫无贡献。这些词不在通用停用词表里,需根据领域语料自己积累。我的建议是走双表制:先加载通用停用词表,再维护一个领域停用词表,两个表取并集。

领域停用词表怎么积累?跑一遍原始提取结果,把所有文档的关键词合并统计,你会发现排名前几十的关键词里总有几个是明显的泛化词。把它们加入停用词表,再迭代一次。重复两三轮,效果会有肉眼可见的提升。这个过程不复杂,但工作量比较碎,值得耐心做。

4.2 词性过滤与权重调节:不同场景的关键词长相

我前面提到词性过滤默认保留名词、动词、形容词,这个规则在不同场景要调整。做舆情分析,我希望关键词以名词为主(“涨价”“监管”“投诉”),因为名词更集中表达对象;做问答系统意图识别,动词的权重反而要上调(“怎么办”“怎么修”);做电商标题摘要,形容词(“高性价比”“便携”)也是重要的卖点词。

调整方式有两种:改变词性白名单,或者在算分时给不同词性乘一个系数。比如名词系数 1.0,动词系数 0.8,形容词系数 0.6。这个系数矩阵可以直接放进 3.2 节的代码里。

另外位置权重也是一个常被忽略的点。标题、第一段、最后一段里的关键词,对文档主旨的代表性远高于中间段落。实现上可以在统计 TF 时,根据词所在位置给一个增量:标题里的词频可以按双倍计入,首段按 1.2 倍计入。这个技巧在长文档场景里效果很明显。

4.3 效果评估与参数调优:不知道好还是不好,就等于白做

关键词提取的评估比较特殊:没有单一绝对正确的答案,通常存在一组“可接受答案”。我做评估时用了三种手段:

第一,抽样人工评价。取 50-100 篇文档,给定参考答案(或者没有参考答案时由 2-3 个人主观打分),计算准确率(提取结果中合理关键词的比例)和召回率(参考答案中被提取出来的比例)。F1 取两者调和平均。

第二,任务效果间接评估。关键词不是终点,把它放进一个下游任务(比如搜索召回、文本分类),比较不同参数下任务的准确率变化。这个评估更接近业务真实收益。

第三,稳定性测试。同一类文档重复提取,看关键词是否稳定;相同语义不同表达的两篇文档,关键词是否出现合理的重合。这一步能暴露出参数敏感性问题,生物都逃过吗——不,是文本,语义相近但字面不同的文档,关键词差异不能过大。

调参顺序我固定下来:先调停用词表,再调词性过滤规则,然后调 TopN 和位置权重,最后才考虑换算法或换分词器。前面的因素对结果的影响通常都大于算法本身。

5. 常见问题与排查实录

5.1 分词不准导致的关键词漂移

现象:提取出来的关键词和文章主题完全没关系,或者明显是一个长词被拆碎。

排查步骤:

  • 先单独打印分词结果,看该词是否被正确切分。多数情况是词典里没有该词,或词典里词语权重被其他分词方案覆盖。
  • 确认不是切分问题后,再看 IDF 值。如果该词被切分正确但权重很低,说明词典里这个 IDF 可能不可靠。
  • 长词被拆碎时,可以通过给 jieba 添加自定义词典来修正。自定义词典是 UTF-8 编码,每行一个词,格式为“词语 词频 词性”,词频越高越倾向于切分后保留。

一个典型的修复案例:某政务项目里,“放管服”老是被切成“放”“管”“服”,加入用户词典后,关键词提取结果立刻正常了。类似地,专业术语、人名、机构名最好都进入自定义词典。

5.2 短文本场景下 TF-IDF 直接失效

短文本(比如一两句话的活动评论)分词后可能只有五六个候选词,TF-IDF 的 TF 部分几乎没有区分度,IDF 部分会主导结果,导致提取出“平台”“内容”这类泛化词。我的处理经验是:

  • 短文本直接用 TextRank 或规则方法,不做 TF-IDF。
  • 如果一定要用 TF-IDF,在短文本前先拼接同一用户的多个历史文本(按用户维度聚合上下文)。
  • 引入词典匹配:手工维护一个小型领域词典,短文本里一但命中词典,直接视为强关键词。

按我的经验,“短文本 + 强规则 + 词典兜底”比“短文本 + 统计算法”效果稳定得多。

5.3 高频无意义词拦截:一个容易被所有人都忽略的大坑

你们不要觉得停用词表能解决所有问题。常见停用词“的、了、是”能滤掉,但像“内容”“方式”“相关”“问题”“方面”这类抽象名词,很多项目会漏掉。它们不是虚词,是实词,在 TF-IDF 里得分往往不低,对结果干扰极大。

处理方法有二:一是在领域停用词表里主动加入这类“泛化名词”;二是设置一个逆文档频率上限,比如某个词在语料里 80% 以上的文档中都出现,直接视为无区分度词,强制过滤。这个阈值变量在我的项目里通常会调,通用文本设 0.7,垂直领域设 0.85,效果更精细。

5.4 性能问题与工程化:大规模文本的提取瓶颈

单篇文档提取毫秒级很轻松,但如果每天要处理百万级新闻,性能就是瓶颈。我遇到过的瓶颈主要在分词组件的全局锁和内存占用上。

优化手段从易到难:先用jieba.enable_parallel(num_workers)做并行分词,代价是内存翻倍;再用 jieba 的cut_for_search模式替代全模式,减少候选词数量,速度提升约 40%;最后如果还不够,把分词结果缓存到磁盘或内存表,同一文档不重复分词。这个缓存优化在很多同类项目中能带来成倍的访问效率提升。

另外一个工程注意点是线程安全。jieba 在并发场景下容易出现共享词典加载的竞争,建议在服务启动时一次性加载词典,上线后不再动态增删词。动态增删词的 bug 排查起来很隐蔽,明明代码没动,结果却变了,多半就是这里出了问题。

写在最后的经验话

说实话,关键词提取这个模块我看过太多人一上来就搭预训练模型,结果业务指标提升还不如老老实实调一遍停用词表和词典。个人经验是,中文关键词提取的核心竞争力有三个:分词质量、停用词表、迭代评估。三者都做到位,TF-IDF 在大多数场景下已经能打 80 分;剩下 20 分才轮到算法复杂度。上线前别忘了给模块做一个兜底:设置一个超时和异常降级策略,关键词提取失败时,自动退回到简单的高频词拼接,保证下游环节不会因为模块异常而整个挂掉。这套思路在我几个项目里都跑通了,尤其在资源受限或数据质量参差的场景,稳字比快字更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询