
最近刚把一个中文抽取式自动文摘的完整流程跑通从中文语料库清洗、分句分词到三种抽取式算法实现再用 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全角空格、nbsp;、p标签等。我用正则做了统一清理import re def clean_text(raw: str) - str: # 去掉 HTML 标签 text re.sub(r[^], , raw) # 替换全角空格和常见网页空白符号 text text.replace(\u3000, ).replace(nbsp;, ) # 只保留中文、英文、数字和常用标点 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 年”“同比增长”这类 tokenIDF 通常很高包含它们的句子容易被顶到最前面但这类句子不见得是真正的关键总结句。所以后来我在 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_threshold0.1, d0.85, max_iter30): 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, kk) 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 比较单个 tokenROUGE-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-IDF0.3650.331中容易选中带数字的流水账句子TextRank0.3810.352好结构紧凑但相似句排重需要额外处理LSA0.3720.344中上偶尔选出主题相关的专业性句子TextRank 在 ROUGE-L 上领先并不意外因为它的图结构天然对“信息覆盖均衡”更友好。TF-IDF 分数稍低但胜在简单稳定适合快速做基线。LSA 分数居中但对计算资源的消耗明显变大性价比一般。5.2 句子数量和长度阈值的双向影响抽取句数 K 是这个项目里最敏感的超参数。K 太小摘要信息覆盖不全ROUGE 召回率很难看K 太大摘要接近原文精确率掉得厉害而且失去“摘要”的意义。我测试了 1 到 6 句的取值发现 K3 或 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 分数。没有这份实验记录后面的所有优化都会变成反复横跳。如果你也要做中文抽取式自动文摘我特别建议在项目早期就把中间语料、分词版本和评测代码固定下来剩下的其实就是在三个方向上打磨细节。条件允许的话还可以给三种方法的摘要结果各建一个样例页挑几篇长度接近的新闻直接对比人工阅读感受——客观分数和主观阅读体验同时对齐这个项目才算真正完整。