最近刚把一个中文抽取式自动文摘的完整流程跑通,从中文语料库清洗、分句分词,到三种抽取式算法实现,再用 Rouge 评价方法逐一对结果打分,整条链路走完后最大的感受是:抽取式文摘虽然看起来“笨”,但却是上手最快、最容易解释、也最适合中文场景的基础方案。这篇文章就是这次实践的完整复盘。内容涵盖中文语料库处理时最容易踩的编码和颗粒度问题,TF-IDF、TextRank、LSA 三种方法的原理与实现细节,以及 Rouge 评分体系到底在算什么、怎么算才不出错。如果你正准备做 NLP 相关的中文摘要项目,或者想在老项目里加一个“自动提炼要点”的功能,这篇应该能直接帮你避掉不少坑。
1. 为什么先做抽取式:中文自动文摘的起点选择
1.1 生成式摘要虽然热门,但没有抽取式“打底”会很难验收
这几年生成式摘要的讨论度很高,很多人一上来就想去微调一个大语言模型,输入原文,期望它输出一段像人写出来的总结。但真放到实际项目里,问题会立刻暴露:模型部署成本、推理延迟、生成结果不可控,还有中文场景下容易出现的事实性错误。尤其当业务方要求“摘要必须忠于原文,不能出现原文没有的信息”时,生成式结果很难向人解释清楚。抽取式自动文摘的思路完全不同:它不从零“写”,而是从原文句子里挑出最重要的若干句,拼接成摘要。这种方案保证每个句子都来自原文,事实准确性天然可控,也易于追溯——哪句话来自哪一段,指给业务方看就行。
这个项目要处理的是中文新闻类文本,单篇正文一般在 500 到 1200 字之间。目标是输出 3 到 5 句的摘要,能把时间、主体、事件、结果这几个要素覆盖住。基于这样的任务边界,抽取式几乎是必然选择。
1.2 为什么挑了 TF-IDF、TextRank、LSA 这三种方法
题目里要求“三种方法实现自动文摘”,但真正落地时怎么选,得看每种方法的核心思路是否足够不同,这样对比才有意义。我最后选了三种代表性做法的组合:
- TF-IDF 代表“基于统计权重的排序法”,核心是词在文档中的重要度;
- TextRank 代表“基于图模型的迭代排序法”,核心是句子之间的相互投票;
- LSA 代表“基于矩阵分解的主题分析法”,核心是把句子放到潜在主题空间里看位置。
这三者的底层逻辑互不相同,复杂度也是递进的。用同一份语料、同一个评测口径去做对比,能很清楚地看到“哪种思路对中文新闻更有效”,也能帮后来者理解文摘任务里不同技术路线的差异。
1.3 一个容易被忽略的前提:评测指标必须先于实现定好
我很建议你在动手写算法之前,先把评测方法定下来。不然代码写完了,你只会觉得“这个摘要看起来还行,那个看起来也不错”,完全没有客观依据。这个项目从第一天就确定用 Rouge 评价方法做统一打分,包括 ROUGE-1、ROUGE-2 和 ROUGE-L 三组分数。后面三种方法做了很多轮参数调整,全靠这套分数才知道哪个方向值得继续调,哪个方向应该直接放弃。
2. 中文语料库落到可计算语料:清洗、分句、分词的关键动作
2.1 第一道坎:中文语料的编码和格式污染
中文语料库处理没有想象中那么“学术”,大量的时间其实耗在脏数据上。我这次用的是一批公开的中文短新闻数据,单篇文本已经算是比较规整,但依然有几类问题反复出现。
首先是编码问题。有些历史语料是 GBK 编码,读进来直接乱码,需要统一转成 UTF-8。这里有个小建议:不要拿到文件就一次性转码,先抽样 100 条看字符形态,确认是乱码还是正常繁体,再决定批量处理。其次是不可见字符和 HTML 残留,常见的有\u3000全角空格、 、<p>标签等。我用正则做了统一清理:
import re def clean_text(raw: str) -> str: # 去掉 HTML 标签 text = re.sub(r"<[^>]+>", "", raw) # 替换全角空格和常见网页空白符号 text = text.replace("\u3000", " ").replace(" ", " ") # 只保留中文、英文、数字和常用标点 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:\"\'《》]", "", text) return re.sub(r"\s+", " ", text).strip()清洗逻辑宁紧勿松。宁可少留一些奇怪的符号,也不要让它们干扰后面的分词和句子相似度计算。这一步做完之后,语料会变得非常干净,但也要注意:如果后续文本需要保留某些特定格式,比如金额、日期,正则设计的颗粒度就要放宽,否则会把有效信息一起误删。
2.2 分句不能只用句号,还要照顾中文标点的变体
抽取式摘要的基本单位是句子,所以分句质量直接决定候选集长什么样。中文分句最基础的方案是按句号、感叹号、问号切,但实际跑下来有三个坑。
第一,新闻标题经常不带标点,需要单独按换行符切;第二,正文里会出现“某某说:‘……’”这类多级引述,引号内可能包含多个完整句子;第三,有些文本用英文标点。我最后用的分句逻辑是:先把换行符统一转成句号候选,再用。!?;作为切分点,最后对切出来的碎片做二次合并,长度小于 5 个字的碎片直接并入前一句。
def split_sentences(text: str): parts = re.split(r"(?<=[。!?;])", text) sents = [] buffer = "" for part in parts: buffer += part if len(buffer.strip()) >= 5: sents.append(buffer.strip()) buffer = "" if buffer.strip(): sents.append(buffer.strip()) return sents这个“短碎片合入前一句”的小策略很关键,它避免了因为引号或切分边界问题产生一堆无意义短句,比如一个孤零零的“他说”。
2.3 分词与停用词:中文文摘误差的第一来源
中文文本不像英文那样天然有空格分词,所以必须显式做分词。不同的分词工具、同一工具的不同版本的切分结果都会不一样,这会直接影响后续所有算法结果以及 Rouge 的评分。建议项目一开始就锁定一个分词方案,并记录版本号,中途不要随意更换,否则前后两次评测结果根本没有可比性。
我这里的做法是将分词封装成一个独立函数,后续所有环节统一调用。分词之后要过滤停用词,包括“的、了、是、在”这类高频无实义词,以及标点符号。
# segment_text 代表已封装好的中文分词函数 def tokenize(sentence: str, stopwords: set): words = segment_text(sentence) return [w for w in words if w.strip() and w not in stopwords]停用词表的选择会影响两种不同性质的结果。表加得太多,摘要会丢掉必要的连接词,可读性变差;表加得太少,算法容易围绕一些虚词打转。我建议停止词表先用一个通用版本,之后根据验证集 Rouge 分数做小幅增删,而不是一开始就追求“完美词表”。
2.4 语料落盘:一种方便复现的中间格式
清洗、分句、分词后的数据不要直接丢给算法,最好保存成一个结构化的中间文件。我用的是 JSON Lines 格式,每行一条文档,字段包括:doc_id、raw_text、sentences、tokens、reference_summary。这样后续跑任何一种摘要算法,都只需要读这一个文件,不用重新做清洗。
{"doc_id": "001", "raw_text": "...", "sentences": ["...", "..."], "tokens": [["...", "...", "..."], ["...", "..."]]}保存中间文件还有一个额外好处:当你想调整 Rouge 打分逻辑时,不需要重新计算摘要,只需把已生成的摘要句子综合利用起来对比。
3. 三种抽取式算法:TF-IDF、TextRank、LSA 的原理和实现
3.1 TF-IDF:用词的重要性给句子投票
TF-IDF 的思想不复杂:一个词在当前文档里出现次数越多越重要,但在整个语料库中越常见就越不稀有。具体到摘要任务,我先把每个句子的词向量化,词权重用 TF-IDF 表示,然后句子得分等于句内所有词的 TF-IDF 之和除以句子长度。这里除以句子长度是个非常重要的细节,否则长句因为词多,得分天然占优,抽取出的摘要会全是又长又信息量低的句子。
句子得分公式可以简单写成:
score(sentence) = sum(tf_idf(word) for word in sentence_tokens) / len(sentence_tokens)实现时,IDF 放在整个训练语料上统计,而不是只在单篇文档里统计。因为如果只在当前文档算 IDF,那些只出现一次的词全部拿到相同权重,算法就退化成了纯词频排序。
from math import log N = len(all_docs) doc_freq = {} for doc_words in all_docs: for w in set(doc_words): doc_freq[w] = doc_freq.get(w, 0) + 1 idf = {w: log(N / (df + 1)) + 1 for w, df in doc_freq.items()}实际运行中,我注意到 TF-IDF 方法对包含数字、时间词的句子特别敏感。新闻正文里出现“2024 年”“同比增长”这类 token,IDF 通常很高,包含它们的句子容易被顶到最前面,但这类句子不见得是真正的关键总结句。所以后来我在 TF-IDF 基础上做了一个小改动:对纯数字占比过高的句子做降权处理,效果比不加要好。
3.2 TextRank:把“重要句”变成“被其他句子投票多的句”
TextRank 本质是 PageRank 在句子图上的变体。把每篇文档的句子看成节点,句子之间的相似度看成边权重,通过迭代让每个句子从“跟它相似的其他句子”那里获得分数。一个句子如果跟很多高分段句子相似,那它也会变得重要。
句子之间相似度我用的是余弦相似度,向量由句子的词频构成。两句相似度低于阈值时,边权重直接置为 0,避免全连接图把每个句子都拉平。迭代公式就是 PageRank 的标准形式:
score[i] = (1 - d) + d * sum(score[j] * sim(i, j) / sum(sim(j, k) for k in neighbors(j)) for j in neighbors(i))其中d是阻尼系数,通常取 0.85。实际代码里我会迭代 30 次左右,分数收敛后取前 K 个句子按原文顺序输出。
def textrank_rank(sentences, sim_threshold=0.1, d=0.85, max_iter=30): n = len(sentences) scores = [1.0] * n weights = [[cosine_sim(sentences[i], sentences[j]) if i != j else 0.0 for j in range(n)] for i in range(n)] for _ in range(max_iter): new_scores = [0.0] * n for i in range(n): total_weight = 0.0 for j in range(n): if weights[j][i] > 0: row_sum = sum(weights[j]) + 1e-9 total_weight += scores[j] * weights[j][i] / row_sum new_scores[i] = (1 - d) + d * total_weight scores = new_scores return scores用下来 TextRank 整体稳定的原因在于:它能抑制孤立零散句的权重。新闻里偶尔会有一段完全与主题无关的背景铺垫,这种句子和其他句子相似度都低,分数自然上不去。但要提醒一点,TextRank 有它的“盲区”:如果原文连续几句话都在说同一件事,这些句子彼此相似度会很高,排序时会扎堆占据名额。解决方案是选完一个句子后,把与它相似度超过阈值 0.7 的句子暂时从候选集里移掉,也就是简单的贪婪去重。
3.3 LSA:把句子放进主题空间,再看谁离中心近
LSA 与前两种方法完全不是一个思路。它把整篇文档表示成“词-句矩阵”,矩阵元素是词的 TF-IDF 权重。然后对这个矩阵做奇异值分解(SVD),得到三个矩阵的乘积。取前 k 个奇异值和对应的奇异向量,相当于把原来的高维词向量空间压缩成一个 k 维主题空间。每个句子在主题空间里对应一个向量,句子得分就是该向量的长度,也可以理解成这个句子对多个潜在主题的总体覆盖程度。
这个方法的好处是能捕捉“同义但不同词”的句子关系。例如句子 A 说“该公司发布财报”,句子 B 说“企业披露业绩”,它们在字面上几乎没有共享词,但在某个潜在主题维度上可能很接近。这在中文新闻里很常见,因为中文表达同义替换非常灵活。
# 构造词-句 TF-IDF 矩阵 # shape = (vocab_size, sentence_count) matrix = build_tfidf_matrix(sentences) # 用 SVD 把矩阵压缩到 k 维,k 一般设置为 min(10, sentence_count // 2) U, S, Vt = svd(matrix, k=k) sentence_vecs = Vt.T # 每行是一个句子在主题空间里的向量 scores = [sum(v ** 2) ** 0.5 for v in sentence_vecs]实现时需要注意 k 的取值。k 太小,主题太粗,所有句子挤在一起区分度低;k 太大,又失去了降维去噪的意义。我在语料上对比过,k 取 5 到 10 之间比较稳定。另外,词-句矩阵非常稀疏,直接存稠密矩阵会很占内存。我处理的方式是先构建稀疏矩阵再喂给 SVD 函数,而不是用普通的二维数组。
3.4 三种方法如何串成一套统一流程
三种方法虽然算法不同,但外围流程完全一致:读中间文件,对每篇文档的所有句子计算得分,按得分从高到低取前 K 句,最后按句子在原文里的位置重新排序。这个“先打分,再按原序输出”的顺序至关重要。如果直接按得分排序输出,摘要的句子会出现严重的跳题感,像三张纸条贴在一起。
统一流程还带来了一个非常现实的好处:后面接 Rouge 打分时,三种方法输出的是完全同构的数据结构,评测代码只需要写一遍,不会因为算法不同而改动接口。
4. Rouge 打分:如何把文摘质量变成可对比的数字
4.1 Rouge-N:看参考摘要和生成摘要的 n-gram 重合度
Rouge 评价方法本质上是一族指标,最常见的是 ROUGE-N 和 ROUGE-L。先看 ROUGE-N。它的核心是比较两段文本的 n-gram 重叠程度。ROUGE-1 比较单个 token,ROUGE-2 比较两个连续 token。
在中文语境里,token 既可以按分词后的词算,也可以按单个汉字算。两种口径的结果差异很大。按词算更能反映语义单元,但高度依赖分词器质量;按字算更稳定,却会把完全不相关的同字句子误判为相似。我这次主体评测用的是分词后以空格分隔的 token,同时又额外记了一份字符级的 ROUGE-1,用来做稳定性参考。
召回率、精确率、F1 三者要分开看。很多初学者只盯 F1,忽略了召回率,其实 ROUGE 的原始设计更偏召回收向。做抽取式文摘时,我们更关心“参考摘要里有多少信息点被选中了”,所以召回率的重要性不亚于 F1。
ROUGE-N recall = (候选摘要与参考摘要共现的 n-gram 数) / (参考摘要的 n-gram 总数)4.2 Rouge-L:用最长公共子序列衡量句子结构
ROUGE-L 在项目里是使用最多的核心指标,因为它的设计非常契合摘要任务。它基于最长公共子序列长度来判断两段文本的相似度,不需要连续匹配,但要求匹配的词保持先后顺序。这个特性让 ROUGE-L 既能容忍同义词替换,又能在一定程度上感知语序结构。
我举一个简化例子。参考摘要分词为:[人工智能, 正在, 改变, 制造业],生成的摘要分词为:[人工智能, 改变, 制造业]。
最长公共子序列是[人工智能, 改变, 制造业],长度 3,占参考摘要长度的 3/4,所以 ROUGE-L 召回率是 0.75;占生成摘要求的长度 3/3,精确率是 1.0。F1 约为 0.857。这个例子说明,ROUGE-L 并没有真去理解语义,但它能敏锐地捕捉“信息元素的覆盖和顺序”,这对摘要任务很有参考价值。
4.3 评测脚本里必须处理的三个细节
第一,候选摘要和参考摘要的分词口径必须完全一致。你不可能用 A 分词器处理原文,用 B 分词器处理参考摘要,这样 Rouge 分数的波动会大到失真。第二,字符归一化要统一。中文全角括号、英文半角括号如果不统一,会直接破坏 n-gram 切分。第三,标点要显式去除。ROUGE 计算的是词序列的重叠,如果两个句子只有句末标点不同,它应该视为同一句子,而不是被标点干扰。
具体评测流程是这样:分别取每篇文档的参考摘要和算法生成摘要,分词、去停用词(可选)、去标点、空格拼接,然后用 Rouge 计算脚本得到 ROUGE-1、ROUGE-2、ROUGE-L 的 F1。最后对整个验证集求平均值。要注意,报告平均值时最好也附上标准差,否则不同方法之间 0.01 的差距根本无法判断是真实差异还是偶然波动。
5. 复现时的结果对比、参数调节与踩坑记录
5.1 三种方法在同一数据上的表现对比
我抽取了一个 800 篇新闻的验证子集,每篇要求输出最多 3 句摘要,得到相对稳定的结果趋势。下面这个表是我自己数据上的粗对比,不代表通用结论,但能看出方法差异:
| 方法 | ROUGE-1 F1(词级) | ROUGE-L F1(词级) | 摘要可读性主观评价 |
|---|---|---|---|
| TF-IDF | 0.365 | 0.331 | 中,容易选中带数字的流水账句子 |
| TextRank | 0.381 | 0.352 | 好,结构紧凑,但相似句排重需要额外处理 |
| LSA | 0.372 | 0.344 | 中上,偶尔选出主题相关的专业性句子 |
TextRank 在 ROUGE-L 上领先并不意外,因为它的图结构天然对“信息覆盖均衡”更友好。TF-IDF 分数稍低,但胜在简单稳定,适合快速做基线。LSA 分数居中,但对计算资源的消耗明显变大,性价比一般。
5.2 句子数量和长度阈值的双向影响
抽取句数 K 是这个项目里最敏感的超参数。K 太小,摘要信息覆盖不全,ROUGE 召回率很难看;K 太大,摘要接近原文,精确率掉得厉害,而且失去“摘要”的意义。我测试了 1 到 6 句的取值,发现 K=3 或 4 在这个语料上比较平衡。
除了 K,句子长度下限也需要设。清洗后的短句碎片如果用“为了”开头却只有几个字,入选摘要会让 Rouge 和可读性同时变差。我把长度下限设为 8 个字符,低于这个长度直接不参与排序。
5.3 三个值得记录的坑和对应解法
坑一:Rouge 打分时中文没有按空格分隔。第一次跑评测时分数高得离谱,查了半天才发现是 Rouge 包把整个中文句子当成一个 token 来比较,导致无数为零但有假信号。解法:分词后用半角空格拼接,再喂给打分函数。
坑二:TextRank 选出重复度极高的相邻句。新闻前两段往往在复述同一件事,TextRank 会把它们双双选进摘要。解法是加贪婪去重:每选一个句子,就把候选集中相似度大于 0.7 的句子全部移除。加完之后 ROUGE-L F1 提升接近 0.02,算是性价比很高的一步。
坑三:LSA 在大文档上的稀疏矩阵内存爆掉。词-句矩阵的规模是词表大小乘句子数,直接建稠密数组在小语料上都能占到几百 MB。解法是全程使用稀疏矩阵,并且对词表做频次截断,只保留出现次数大于等 2 的词。词表缩小后,LSA 结果几乎不受影响,内存却降了一个量级。
5.4 一点实操体会
整个项目跑完后,我的建议是把 TextRank 作为默认方法,把 TF-IDF 用作快速基线和异常检出工具,LSA 则可以作为锦上添花的对照。实际调参过程中,改动停用词表和 K 值带来的分差往往会大于更换算法本身,所以不要迷信“方法越复杂效果越好”这种话。还有一个值得坚持的习惯:每次改动参数后,必须保存一份评测日志,记录当前用的语料切片、分词配置、摘要长度、Rouge 分数。没有这份实验记录,后面的所有优化都会变成反复横跳。
如果你也要做中文抽取式自动文摘,我特别建议在项目早期就把中间语料、分词版本和评测代码固定下来,剩下的其实就是在三个方向上打磨细节。条件允许的话,还可以给三种方法的摘要结果各建一个样例页,挑几篇长度接近的新闻直接对比人工阅读感受——客观分数和主观阅读体验同时对齐,这个项目才算真正完整。