$100/月,17 种语言的新闻去重

我做了个新闻聚合产品 3mins.news,接了 180 多个 RSS 源,覆盖 17 种语言。每小时几百篇文章进来,同一件事 BBC 写英文、NHK 写日文、Le Monde 写法文、新华社写中文。

做下来发现,最难的既不是抓 RSS 也不是写前端——是去重。四篇不同语言的文章,词汇零重叠,怎么让系统知道它们说的是同一件事?

这篇文章聊聊我的方案,踩过的坑,以及怎么把整条管线塞进 Cloudflare Workers 里跑到 $100/月。

传统文本去重为什么不行

做文档去重,教科书答案是 MinHash + LSH:把文档切成 n-gram,哈希成紧凑签名,再用局部敏感哈希找相似的对。Google News 早年就是这么做的,单语言场景下非常好使。

但到了多语言场景,直接翻车。

随便拿一个真实场景——某天早上的同一条新闻:

来源 语言 标题
Reuters 英语 "EU Approves New AI Regulation Framework"
NHK 日语 "EU、新たなAI規制枠組みを承認"
Le Monde 法语 "L'UE adopte un nouveau cadre réglementaire pour l'IA"
新华社 中文 "欧盟批准新人工智能监管框架"

四篇文章,同一事件。做 3-word shingling 一看:

  • 英语:{"EU Approves New", "Approves New AI", ...}
  • 日语:{"EU、新たな", "新たなAI規制", ...}
  • 法语:{"L'UE adopte un", "adopte un nouveau", ...}

Jaccard 相似度?**零。**token 层面完全没交集,MinHash 算出来就是"毫不相关"。

你可能会想:先统一翻译成一种语言再去重?代价太大了——加延迟、加成本,翻译误差还会传到相似度计算里。

用 Embedding 抹平语言差异

现代多语言 embedding 模型的核心能力就是:语义相同的文本,不管什么语言,映射到向量空间里的位置都很近。"EU approves AI regulation" 和 "欧盟批准AI监管" 的余弦相似度能到 0.85 以上。

我的做法很直接:每篇文章入库时算一次 1536 维的 embedding 向量。之后所有去重和聚类都在向量空间里做,语言差异在这一层就消失了。

成本方面,embedding 非常便宜——大约 $0.04/百万 token,每天处理几千篇文章就几美分。

两阶段聚类

光有 embedding 相似度还不够。你需要把文章实际分组成一个个 "story"(同一事件的文章簇)。我用两阶段来做。

第一阶段:往已有 story 上靠

大部分新文章都属于正在跟踪的事件——Reuters 发了条危机更新,应该归到已有的 story 里,不是新建一个。

做法是对每篇未分配的文章,KNN 查已有 story 的 embedding:

SELECT item_src.item_id,
       story_cand.story_id,
       1 - (story_cand.embedding_vec <=> item_src.embedding_vec) AS sim
FROM item item_src
JOIN LATERAL (
    SELECT story.story_id, story.embedding_vec
    FROM story
    WHERE story.embedding_vec IS NOT NULL
      AND story.created_at >= $cutoff
      AND ABS(item_src.published_at -
              story.latest_published_at) <= $max_gap
    ORDER BY story.embedding_vec <=> item_src.embedding_vec
    LIMIT 10
) story_cand ON true
WHERE item_src.item_id = ANY($batch_ids)
  AND item_src.embedding_vec IS NOT NULL

几个关键约束是调出来的:

  • 相似度 ≥ 0.7 才算匹配。调低了会把不相干的文章混在一起。
  • 时间差 ≤ 18 小时。没有这个限制的话,"2025 年日本地震"和 "2026 年日本地震"会被合成一个 story——语义上确实很像,但不是同一事件。
  • story 年龄 ≤ 36 小时。太老的 story 不再吸收新文章。

这一阶段大概能消化 70-80% 的新文章。匹配上的文章归入已有 story,story 的 embedding 用最近几篇文章的滑动窗口均值更新。

第二阶段:UnionFind 发现新 story

没匹配上的文章可能属于新事件。怎么找出这些新聚类?做 item 之间的 KNN:

SELECT item_src.item_id AS src_id,
       item_cand.item_id AS dst_id,
       1 - (item_cand.embedding_vec <=> item_src.embedding_vec) AS sim
FROM item item_src
JOIN LATERAL (
    SELECT item_id, embedding_vec
    FROM item AS item_cand
    WHERE item_cand.embedding_vec IS NOT NULL
      AND item_cand.published_at >= $window_start
      AND item_cand.item_id <> item_src.item_id
    ORDER BY item_cand.embedding_vec <=> item_src.embedding_vec
    LIMIT 10
) item_cand ON true
WHERE item_src.item_id = ANY($batch_ids)

KNN 的结果是一张相似度图——节点是文章,边是相似度 ≥ 0.7 的连接。然后用**并查集(UnionFind)**提取连通分量:

class UnionFind {
  private parent: number[]
  private rank: number[]

  constructor(size: number) {
    this.parent = Array.from({ length: size }, (_, i) => i)
    this.rank = new Array(size).fill(0)
  }

  find(node: number): number {
    if (this.parent[node] !== node) {
      this.parent[node] = this.find(this.parent[node]) // 路径压缩
    }
    return this.parent[node]
  }

  union(a: number, b: number): void {
    let rootA = this.find(a)
    let rootB = this.find(b)
    if (rootA === rootB) return
    if (this.rank[rootA] < this.rank[rootB]) [rootA, rootB] = [rootB, rootA]
    this.parent[rootB] = rootA
    if (this.rank[rootA] === this.rank[rootB]) this.rank[rootA]++
  }
}

对每条相似度达标的边 union(src, dst),提取连通分量。2 篇以上的分量就是一个新 story。

为什么选 UnionFind 而不是 DBSCAN 或层次聚类?

  1. 每次操作 O(α(n)),约等于 O(1)
  2. 纯内存计算,不用再跑数据库
  3. KNN 出来的边列表直接灌进去就行
  4. 确定性输出——同样的边永远得到同样的聚类

Story Embedding 怎么维护

每个 story 的 embedding 取最近 3 篇文章的均值:

WITH latest AS (
    SELECT item.embedding_vec,
           ROW_NUMBER() OVER (
               ORDER BY item.published_at DESC NULLS LAST
           ) AS rn
    FROM story_item si
    JOIN item ON si.item_id = item.item_id
    WHERE si.story_id = $story_id
      AND item.embedding_vec IS NOT NULL
)
UPDATE story
SET embedding_vec = (SELECT avg(embedding_vec) FROM latest WHERE rn <= 3)
WHERE story_id = $story_id

为什么只取最近 3 篇?因为 story 会演化。"欧盟提议 AI 监管"→"欧盟投票"→"欧盟 AI 法案正式签署",焦点一直在变。滑动窗口让 embedding 跟着最新动态走,而不是被历史平均值拖住。

为什么用 pgvector 而不是专用向量库

所有向量操作都在 PostgreSQL 里用 pgvector 完成。不需要 Pinecone、Weaviate、Qdrant。

HNSW 索引建好,KNN 直接查:

CREATE INDEX ON item USING hnsw (embedding_vec vector_cosine_ops);
CREATE INDEX ON story USING hnsw (embedding_vec vector_cosine_ops);

在我的数据规模(活跃窗口内几万条 item)下,KNN 查询稳定在 100ms 以内。

选 pgvector 的核心理由:一个库搞定一切。向量 KNN 和关系数据(发布时间、来源 ID、story 状态)的联合查询在 PostgreSQL 里是一条 SQL 的事,拆成两个系统就得在应用层做 JOIN,痛苦且容易出 bug。另外 embedding 写入和元数据更新在同一个事务里,一致性有保证。

有个连接池的坑值得一提:SET hnsw.ef_search = 64 在连接归还连接池后会被重置。解法是在显式事务里用 SET LOCAL

BEGIN;
SET LOCAL hnsw.ef_search = 64;
-- KNN 查询
COMMIT;

参数只在这个事务内有效,连接池怎么回收都不影响。

完整管线长什么样

RSS 源 (180+, 17 种语言)
    │
    ▼
[Fetch] ── 解析 RSS ── URL 去重 ── 算 Embedding
    │
    ▼
[Cluster] ── 第一阶段:匹配已有 story (KNN)
    │         第二阶段:UnionFind 新聚类
    │
    ▼
[Score] ── LLM 打分 (0-10, 6 个维度)
    │      LLM 改写标题和摘要
    │      翻译成 10 种语言
    │
    ▼
[Event] ── 相关 story 归入多日事件
    │      生成四段式事件叙事
    │      翻译成 16 种语言
    │
    ▼
[Publish] ── 选出 Top Story ── 渲染多语言邮件 ── 批量发送

每个阶段都是一个独立的 Cloudflare Workflow。Workflow 的好处是天然带持久化和重试——LLM 调用挂了只重跑那一步,Worker 中途崩了从上次完成的步骤恢复。

不同任务用不同模型

任务 模型 选型理由
评分 + 改写 DeepSeek 便宜,结构化输出稳定
翻译 Qwen Plus 多语言质量更好
Embedding Qwen3 Embedding 8B 跨语言对齐好,成本极低

翻译不是一种语言一次调用——每批塞 2 种语言,分 5 批跑完 10 种语言。每批独立写库,第 3 批挂了不影响前两批的结果。

评分和翻译各有独立的状态标记(needs_scoreneeds_translate)。翻译失败不会重新触发评分。永久失败标 -1,不再重试。

成本拆解

整个后端跑在 Cloudflare Workers + Queues + Workflows + KV + Hyperdrive 上:

项目 月成本 说明
LLM(评分 + 改写) ~$55 DeepSeek,prompt 缓存能省不少
LLM(翻译) ~$12 Qwen Plus
LLM(Embedding) ~$8
邮件 $20 Resend Pro
Cloudflare Workers $5 Paid 计划
PostgreSQL ~$0 当前规模免费层够用
合计 ~$100

这里有个重要特性:成本和用户量无关。LLM 费用由内容量决定(180 个源 × 每日文章 × 10 种翻译语言),10 个人看和 10,000 个人看,LLM 账单一分钱不变。只有邮件发送量和数据库连接数会随用户增长。

128MB 内存里做向量聚类

Cloudflare Workers 内存上限 128MB。几千篇文章的向量操作塞不进去。

核心思路:向量计算全部下推到 PostgreSQL。Worker 里不持有任何 embedding 向量,只传 item ID。KNN、相似度计算、均值更新,全在 pgvector 里完成。

Worker 只做编排——决定哪些 item 要聚类、分成 200 个一批丢给数据库、把结果写回来。重活都在库里干。

Cloudflare Workers 上还有几个要注意的限制:

  • Workflow Step 输出 ≤ 1MiB:item 分页加载,每页 5,000 条(约 760KB),正好不超限
  • 单次调用最多 50 个子请求:SQL 操作用 unnest() 数组批量执行,一条 SQL 插几百行,不是逐条 INSERT
  • 连接池会重置会话变量:事务里用 SET LOCAL,不用 SET

几点体会

相似度阈值没有万能值。 0.7 对新闻去重刚好——同一事件的报道在结构和用词上有相似性。但换个领域(比如学术论文),0.7 可能太高或太低。得根据具体数据调。

时间约束和相似度一样重要。 不加 18 小时间隔限制的话,"2025 年日本地震"和 "2026 年日本地震"必然被合进同一个 story。语义上确实像,但不是同一事件。

先匹配再聚类,比全量聚类好很多。 第一阶段匹配已有 story 消化了大部分文章,剩下的才需要跑 UnionFind。不仅快(比较次数少),而且准(已有 story 的 embedding 经过多篇文章打磨,比单篇文章的向量更稳定)。

PostgreSQL + pgvector 被严重低估了。 向量 KNN + 关系过滤(时间范围、来源 ID、状态标志)写在同一条 SQL 里,这是 Pinecone 们做不到的。小规模数据下(几万到几十万条向量),没必要引入专用向量库。

在 Workers 上一切都得批量化。 每个子请求都是限额内的资源。unnest() 批量 SQL、多语言合并一次 LLM 调用、批量发送邮件。逐条和批量操作的差距,就是能不能跑起来的差距。


最终产品在这里:3mins.news——AI 精选全球新闻,3 分钟看完,17 种语言。上面说的整条管线每小时跑一轮,把几百篇文章精选成几条核心 story。


本文采用 CC BY-NC-SA 4.0 许可