我做了个新闻聚合产品 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 或层次聚类?
- 每次操作 O(α(n)),约等于 O(1)
- 纯内存计算,不用再跑数据库
- KNN 出来的边列表直接灌进去就行
- 确定性输出——同样的边永远得到同样的聚类
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_score、needs_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。