轻量级中文博客摘要库的 Rust 实现:在 512MB 服务器上跑起来的纯算法方案(老博客)
前言
最近在给自己的博客添加自动摘要功能时,遇到了一个典型的「既要又要」困境:既要摘要质量过得去,又要服务器成本足够低(我的博客只跑在一台 512MB 内存的廉价 VPS 上)。市面上主流的 AI 摘要方案要么依赖云端 API(费用高、数据隐私存疑),要么需要加载动辄几百 MB 的预训练模型(内存直接爆表)。经过一番调研,我最终选择了一条「复古」但极其务实的路线——纯算法的文本摘要库。
本文将完整分享这个项目的技术选型、核心算法、Rust 实现细节以及性能优化技巧。最终产物是一个不到 3MB 的二进制文件,内存占用稳定在 50MB 以内,能够为任意长度的中文博客生成通顺的摘要。
项目代码已开源,欢迎体验:
(预留)(示例链接,实际需替换)
1. 技术选型:为什么放弃 AI 模型?
1.1 三条常见路线的局限性
最初我调研了三条技术路线:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 云端生成式 API(如 GPT) | 摘要质量高,零部署成本 | 依赖网络,有费用,数据需上传第三方 |
| 本地运行预训练模型(如 BART) | 自主可控,无调用限制 | 模型文件通常 >500MB,推理内存 >1GB |
| 客户端运行 Transformer.js | 不消耗服务器算力 | 模型首次加载慢(50MB 模型在 3Mbps 下需 170s),且给客户端增压 |
显然,对于 512MB 内存的服务器,前三者都不现实。但我注意到,在深度学习普及之前,学术界已经积累了相当成熟的基于图排序和统计的摘要算法,比如 TextRank、MMR 等。这些算法不依赖任何神经网络,仅通过词频、句子位置、相似度等特征就能提取出关键句,效果在博客文章这类结构清晰的文本上足够可用。
1.2 为什么是 Rust?
既然走纯算法路线,语言选择就很自由了。我选择 Rust 的原因:
- 极致的内存控制和体积:编译后二进制仅 3MB,运行时内存几乎由输入文本大小决定,没有隐式开销。
- 优秀的生态:
jieba-rs提供高质量中文分词,regex处理句子分割,lazy_static实现静态停用词表。 - 无运行时依赖:可轻松嵌入任何后端服务,甚至编译为 WebAssembly 在前端运行。
2. 核心算法:TextRank + MMR + 位置加权
整个摘要生成流程分为 8 个步骤,下面逐一拆解。
2.1 算法流程图
输入文本 ↓ 1. 句子分割(按标点切分,过滤短句/长句) ↓ 2. 中文分词(jieba-rs + 停用词过滤) ↓ 3. 构建相似度矩阵(Jaccard 相似度) ↓ 4. 归一化矩阵(每行和为 1,用于 TextRank 转移) ↓ 5. TextRank 迭代计算句子重要性 ↓ 6. 位置加权(标题/首尾句提升权重) ↓ 7. MMR 去重选择(平衡相关性与多样性) ↓ 8. 按原文顺序输出摘要
2.2 TextRank:将句子视为网页
TextRank 是 PageRank 算法在文本上的应用。我们将每个句子看作一个节点,句子间的相似度作为边的权重,通过迭代计算每个句子的「重要性分数」。
核心公式:
Score(Vi) = (1-d)/N + d * Σ( Score(Vj) / |Out(Vj)| )
其中 d 是阻尼系数(通常取 0.85),N 为句子总数。
Rust 实现:
const DAMPING_FACTOR: f64 = 0.85; const MAX_ITERATIONS: usize = 100; const CONVERGENCE_THRESHOLD: f64 = 1e-5; pub fn textrank(similarity_matrix: &[Vec<f64>]) -> Vec<f64> { let n = similarity_matrix.len(); let init_score = 1.0 / n as f64; let mut scores = vec![init_score; n]; let mut prev_scores = scores.clone(); for _ in 0..MAX_ITERATIONS { for i in 0..n { let mut sum = 0.0; for j in 0..n { if i != j && similarity_matrix[j][i] > 0.0 { sum += similarity_matrix[j][i] * prev_scores[j]; } } scores[i] = (1.0 - DAMPING_FACTOR) / n as f64 + DAMPING_FACTOR * sum; } let max_diff = scores.iter() .zip(prev_scores.iter()) .map(|(a, b)| (a - b).abs()) .fold(0.0_f64, |acc, x| acc.max(x)); if max_diff < CONVERGENCE_THRESHOLD { break; } prev_scores.copy_from_slice(&scores); } scores }
2.3 MMR:避免摘要重复
单纯的 TextRank 可能会选出几个高度相似的句子(比如都描述同一个观点)。MMR(最大边际相关性)通过一个平衡公式,在保证相关性的同时惩罚与已选句子过于相似的候选句。
公式:
MMR = λ * relevance - (1-λ) * max_redundancy
- relevance:句子的 TextRank 分数
- max_redundancy:与已选句子的最大相似度
- λ:平衡参数,默认 0.7(偏向相关性)
Rust 实现:
pub fn mmr_selection( scores: &[f64], similarity_matrix: &[Vec<f64>], top_n: usize, lambda: f64, ) -> Vec<usize> { let mut selected = Vec::with_capacity(top_n); let mut remaining: Vec<usize> = (0..scores.len()).collect(); for _ in 0..top_n.min(scores.len()) { let mut best_idx = 0; let mut best_score = f64::NEG_INFINITY; for &idx in &remaining { let relevance = scores[idx]; let redundancy = if selected.is_empty() { 0.0 } else { selected.iter() .map(|&sel_idx| similarity_matrix[idx][sel_idx]) .fold(f64::NEG_INFINITY, |a, b| a.max(b)) }; let mmr_score = lambda * relevance - (1.0 - lambda) * redundancy; if mmr_score > best_score { best_score = mmr_score; best_idx = idx; } } selected.push(best_idx); // 使用 swap_remove 达到 O(1) 删除 if let Some(pos) = remaining.iter().position(|&x| x == best_idx) { remaining.swap_remove(pos); } } selected.sort(); selected }
2.4 位置权重:强化首尾句
博客文章通常开头点题、结尾总结,因此我们给前两句和后两句额外加权:
- 前 2 句权重 ×1.3
- 后 2 句权重 ×1.2
这个简单的启发式能明显提升摘要的连贯性。
3. 工程实现细节
3.1 分词与停用词
使用 jieba-rs 分词,并嵌入了一份 2313 个词的哈工大停用词表(编译时直接 include,无需运行时读取文件)。
lazy_static! { static ref JIEBA: Jieba = Jieba::empty(); static ref STOPWORDS: HashSet<String> = { let stopwords = include_str!("../data/hit_stopwords.txt"); stopwords .lines() .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) .map(|line| line.trim().to_string()) .collect() }; } pub fn tokenize(text: &str) -> Vec<String> { let words = JIEBA.cut(text, false); words .into_iter() .map(|word| word.to_string()) .filter(|word| { !STOPWORDS.contains(word) && word.len() > 1 && !word.chars().all(|c| c.is_ascii_digit()) }) .collect() }
3.2 相似度计算的性能优化
计算所有句子对的相似度是算法的主要耗时点。我们做了两层优化:
- 短句子用线性查找:当两个句子分词后长度均小于 10 时,直接使用
Vec::contains计算交集,避免 HashSet 的构建开销。 - 长句子用 HashSet:对于长句,用 HashSet 加速集合运算。
pub fn jaccard_similarity(tokens1: &[String], tokens2: &[String]) -> f64 { if tokens1.is_empty() || tokens2.is_empty() { return 0.0; } if tokens1.len() < 10 && tokens2.len() < 10 { let mut intersection = 0; for token in tokens1 { if tokens2.contains(token) { intersection += 1; } } if intersection == 0 { return 0.0; } return intersection as f64 / (tokens1.len() + tokens2.len() - intersection) as f64; } let set1: HashSet<&String> = tokens1.iter().collect(); let set2: HashSet<&String> = tokens2.iter().collect(); let intersection = set1.intersection(&set2).count(); if intersection == 0 { 0.0 } else { intersection as f64 / (set1.len() + set2.len() - intersection) as f64 } }
3.3 自适应摘要长度
不同的文章篇幅应提取不同数量的句子。我们内置了一个简单的自适应规则:
pub fn summarize_auto(&self, text: &str) -> String { let sentences = split_sentences(text); let total = sentences.len(); let adaptive_max = match total { 0..=5 => total, // 全部保留 6..=15 => 3, // 提取 3 句 16..=30 => 5, // 提取 5 句 _ => 7, // 提取 7 句 }; // 使用 adaptive_max 重新生成摘要 // ... }
4. 使用方式
4.1 作为命令行工具
# 安装(如果已发布到 crates.io) cargo install rustblog-summarizer # 生成摘要(默认取 5 句) summarizer article.txt # 指定摘要句数 summarizer article.txt 3 # 自适应模式 summarizer --auto article.txt # 博客优化模式(相当于 --auto --position-weight) summarizer --blog article.txt
4.2 作为 Rust 库
在 Cargo.toml 中添加:
[dependencies] rustblog-summarizer = "0.1"
然后在代码中使用:
use rustblog_summarizer::Summarizer; fn main() { let summarizer = Summarizer::new(5, 0.7, true, true); let summary = summarizer.summarize("你的长文章内容..."); println!("{}", summary); // 或者直接使用博客专用函数 let blog_summary = rustblog_summarizer::summarize_blog_post(article); }
5. 性能与测试
5.1 资源占用
- 编译后二进制大小:约 3.0 MB(release 模式,strip = true)
- 运行时内存:处理 5000 字文章时,峰值内存约 15 MB;处理 2 万字文章时约 50 MB(主要由相似度矩阵占用,矩阵大小为 N² × 8 字节,N 为句子数)。
- 处理速度:平均 1000 字/秒(受句子数量影响,矩阵构建是 O(N²))。
5.2 测试覆盖
项目包含 19 个单元测试 + 14 个文档测试,覆盖了所有核心模块:
tokenizer:分词、停用词过滤sentence:句子分割、过滤similarity:相似度计算、MMRtextrank:图排序迭代summarizer:完整流程集成
所有测试均在 CI 中通过,确保代码可靠性。
6. 总结与展望
这个项目让我重新认识到:在特定场景下,经典的算法往往比复杂的模型更具工程价值。对于个人博客、新闻摘要等任务,TextRank + MMR 的组合已经足够生成通顺、覆盖核心信息的摘要,而资源消耗可以忽略不计。
当然,纯算法方案也有其天花板:它无法生成原文中没有的句子(即只能抽取,不能生成),也无法理解深层语义。但对于追求轻量、可控的场景,这依然是一个极具吸引力的选择。
未来可能的方向
- 支持自定义停用词表:允许用户替换为领域特定词表。
- 增加 TF-IDF 加权:结合词频权重,进一步提升关键句识别能力。
- WebAssembly 构建:让摘要能力直接运行在浏览器中,彻底实现零服务器成本。
如果你也有类似需求,欢迎尝试这个库,或者参与贡献。项目地址:
(预留)
最后,感谢你读到这里。如果你对文中某个细节有疑问,或者有更好的优化建议,欢迎在评论区留言交流