Swordreforge's blog

轻量级中文博客摘要库的 Rust 实现:在 512MB 服务器上跑起来的纯算法方案(老博客)

阅读 91评论 0

前言

最近在给自己的博客添加自动摘要功能时,遇到了一个典型的「既要又要」困境:既要摘要质量过得去,又要服务器成本足够低(我的博客只跑在一台 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 相似度计算的性能优化

计算所有句子对的相似度是算法的主要耗时点。我们做了两层优化:

  1. 短句子用线性查找:当两个句子分词后长度均小于 10 时,直接使用 Vec::contains 计算交集,避免 HashSet 的构建开销。
  2. 长句子用 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:相似度计算、MMR
  • textrank:图排序迭代
  • summarizer:完整流程集成

所有测试均在 CI 中通过,确保代码可靠性。


6. 总结与展望

这个项目让我重新认识到:在特定场景下,经典的算法往往比复杂的模型更具工程价值。对于个人博客、新闻摘要等任务,TextRank + MMR 的组合已经足够生成通顺、覆盖核心信息的摘要,而资源消耗可以忽略不计。

当然,纯算法方案也有其天花板:它无法生成原文中没有的句子(即只能抽取,不能生成),也无法理解深层语义。但对于追求轻量、可控的场景,这依然是一个极具吸引力的选择。

未来可能的方向

  • 支持自定义停用词表:允许用户替换为领域特定词表。
  • 增加 TF-IDF 加权:结合词频权重,进一步提升关键句识别能力。
  • WebAssembly 构建:让摘要能力直接运行在浏览器中,彻底实现零服务器成本。

如果你也有类似需求,欢迎尝试这个库,或者参与贡献。项目地址:
(预留)


最后,感谢你读到这里。如果你对文中某个细节有疑问,或者有更好的优化建议,欢迎在评论区留言交流

评论

还没有评论,来抢沙发。