Một sự kiện lớn thường được hàng chục tờ báo đưa tin cùng lúc, dưới những cái tít khác nhau. Đọc từng bài thì khó thấy bức tranh chung. Câu hỏi đáng trả lời hơn không phải "hôm nay có tin gì", mà là: chủ đề nào đang nóng lên, chủ đề nào đang nguội đi, và vì sao?
Để trả lời câu hỏi đó, mình làm Tin tức hôm nay có gì?, một hệ thống đọc khoảng 95 nguồn RSS tiếng Việt, tự gom các bài cùng nói về một chuyện thành chủ đề, theo dõi vòng đời của từng chủ đề, rồi nhờ LLM tóm tắt điều gì đang thúc đẩy nó. Bài này ghi lại cách nó chạy và những con số mình đã chọn.
Kiến trúc tổng quan
Toàn bộ là một service viết bằng TypeScript chạy trên Bun, dữ liệu nằm trong một file SQLite (bun:sqlite). Pipeline chạy theo từng lượt, mỗi lượt đi qua các bước:
- Ingest: đọc RSS, bỏ bài trùng.
- Embed: biến tiêu đề và mô tả của mỗi bài thành một vector.
- Cluster: gom các vector gần nhau thành cụm bằng HDBSCAN.
- Lifecycle: cập nhật vận tốc, gia tốc và giai đoạn của từng chủ đề.
- Label và Insight: LLM đặt tên chủ đề và phân tích nguyên nhân.
- Digest: tổng hợp gửi email.
Cùng một image có thể chạy ở chế độ web (dashboard và API), pipeline hoặc cả hai. Khi chạy thật, hai vai trò này được tách riêng để dashboard không bị ảnh hưởng khi pipeline đang bận.
Lịch chạy theo nhịp đọc báo của người Việt
Tin không ra đều trong ngày, nên pipeline cũng không chạy đều. Lịch được tính theo giờ Việt Nam:
| Khung giờ | Chu kỳ |
|---|---|
| 6h–9h và 17h–22h | 15 phút |
| 9h–17h | 30 phút |
| Còn lại (đêm) | 60 phút |
Các lượt được căn vào mốc tròn (ví dụ 7:00, 7:15, 7:30), nên dashboard luôn báo được "lượt kế tiếp lúc mấy giờ".
Bỏ bài trùng
Một bài có thể xuất hiện trong nhiều feed của cùng một tờ báo (trang chủ, chuyên mục, tin mới). Mình băm URL thành content_hash và bỏ những bài đã có hash trong database. Cách này đơn giản và đủ dùng, vì cùng một bài thì URL gần như luôn giống nhau. Còn hai tờ báo khác nhau viết cùng một sự kiện thì không coi là trùng: đó chính là tín hiệu để phân cụm.
Embedding cho tiếng Việt
Mặc định mình dùng jina-embeddings-v3 qua API của Jina AI, vì nó đa ngôn ngữ và xử lý tiếng Việt tốt. Nếu không có API key, service tự chuyển sang chạy LaBSE ngay trong máy (qua @xenova/transformers). Chậm hơn, nhưng pipeline không phụ thuộc vào dịch vụ bên ngoài để sống.
Vector được lưu cùng bài viết, mỗi lượt chỉ embed bài mới, tối đa 1.000 bài một lần.
Vì sao chọn HDBSCAN
Với tin tức, mình không biết trước có bao nhiêu chủ đề, và phần lớn bài viết là nhiễu: tin lẻ, chẳng thuộc câu chuyện nào lớn. K-means buộc phải chọn số cụm và nhét mọi bài vào một cụm nào đó. HDBSCAN thì tự tìm số cụm và trả về nhãn -1 cho những điểm không thuộc cụm nào. Với bài toán này, đó đúng là hành vi mình cần.
Thuật toán chạy trong một tiến trình Python nhỏ (thư viện hdbscan), Bun gửi vector vào qua stdin và nhận nhãn qua stdout. Cấu hình hiện tại:
HDBSCAN(
min_cluster_size=8,
min_samples=2,
metric="euclidean",
cluster_selection_method="leaf",
)
Vài lựa chọn đáng nói:
- Chuẩn hoá L2 trước khi phân cụm. Với vector độ dài 1, khoảng cách Euclid và độ tương đồng cosine liên hệ trực tiếp với nhau (d² = 2 − 2·cos), nên dùng
euclideancho kết quả xếp theo đúng thứ tự như cosine, mà HDBSCAN lại chạy nhanh hơn với metric này. leafthay vìeom.eomưu tiên các cụm lớn và ổn định, nên dễ gộp những câu chuyện liên quan nhưng khác nhau (chẳng hạn bão, mưa lũ và sạt lở) thành một.leafcho cụm nhỏ và cụ thể hơn, hợp với việc theo dõi từng sự kiện.min_cluster_size=8. Một "chủ đề" cần ít nhất 8 bài trong một lượt. Thấp hơn thì dashboard đầy chủ đề lặt vặt; cao hơn thì bỏ lỡ chuyện mới nổi.- Mỗi lượt phân cụm tối đa 3.000 bài. Bài bị coi là nhiễu được thử lại trong 2 tiếng tiếp theo, phòng khi câu chuyện của nó chỉ mới bắt đầu.
Từ cụm sang chủ đề sống qua nhiều lượt
HDBSCAN chỉ biết về các bài trong lượt hiện tại. Để một chủ đề sống qua nhiều giờ, mỗi chủ đề lưu một centroid (trung bình các vector của nó), và mỗi cụm mới được so với centroid của các chủ đề đang hoạt động:
| Quy tắc | Ngưỡng cosine |
|---|---|
| Cụm mới nhập vào chủ đề có sẵn | ≥ 0,75 |
| Gộp hai cụm trong cùng một lượt | ≥ 0,82 |
| Gộp hai chủ đề giữa các lượt | ≥ 0,78 |
| Tách chủ đề đã quá to | từ 30 bài |
Ngưỡng gộp cao hơn ngưỡng nhập là có chủ ý: gộp nhầm hai cụm thì khó gỡ, còn để chúng đứng riêng thêm một lượt thì gần như không mất gì. Sau mỗi lần gộp, centroid được tính lại ngay để tránh hiệu ứng dây chuyền (A gộp B, rồi AB lại gộp tiếp C chỉ vì nó đã phình ra).
Vòng đời của một chủ đề
Mỗi chủ đề đi qua một máy trạng thái đơn giản, dựa trên vận tốc (tốc độ ra bài mới, đo trong cửa sổ 2 giờ) và gia tốc (vận tốc thay đổi ra sao):
- Signal → Emerging: có từ 5 bài và vận tốc dương.
- Emerging → Growing: gia tốc dương.
- Growing → Hot: từ 10 bài và vận tốc trên 1.
- Hot → Peak: gia tốc gần bằng 0 so với vận tốc (dưới 10%).
- Declining: vận tốc âm 2 cửa sổ liên tiếp.
- Dead: 3 cửa sổ liên tiếp không có bài mới. Nếu sau đó có bài mới, chủ đề "sống lại" ở Emerging.
Mọi lần chuyển trạng thái đều được ghi lại kèm lý do (ví dụ "Count >= 10 and velocity > 1.0"), nên khi một chủ đề nhảy lung tung, mình đọc lịch sử là biết vì sao.
Chấm điểm "độ nóng"
Để xếp hạng, mỗi chủ đề có một điểm từ 0 đến 100, cộng từ sáu thành phần đã chuẩn hoá về 0–1:
| Thành phần | Trọng số | Chạm trần khi |
|---|---|---|
| Vận tốc (2 giờ gần nhất) | 25 | 10 bài |
| Khối lượng (24 giờ) | 20 | 150 bài |
| Gia tốc | 20 | 5 |
| Số nguồn khác nhau | 15 | 8 nguồn |
| Vận tốc so với mức trung bình | 10 | gấp 3 lần |
| Độ mới của chủ đề | 10 | giảm dần về 0 sau 7 ngày |
Số nguồn có trọng số riêng vì một chuyện chỉ một tờ báo đăng 20 bài khác hẳn một chuyện 8 tờ cùng đưa tin. Sau đó điểm bị nhân với hệ số suy giảm theo thời gian kể từ bài gần nhất: không giảm trong giờ đầu, giảm 20% sau 1 giờ, 40% sau 6 giờ, 60% sau 12 giờ, 80% sau 24 giờ, và về 0 sau 48 giờ.
LLM: đặt tên và giải thích
LLM được gọi qua API tương thích OpenAI (mặc định là DeepSeek), với hai loại yêu cầu:
- Đặt tên: tối đa 5 từ tiếng Việt, gửi theo lô nhiều chủ đề một lần, mỗi chủ đề chỉ kèm tối đa 10 tiêu đề bài.
- Phân tích: trả về JSON gồm
driving_force(nguyên nhân chính),trend_rationale(vì sao đang nóng hoặc lắng xuống),opportunity(cơ hội hoặc rủi ro cụ thể cho ngành nào),watch_next(diễn biến cần theo dõi) vàopportunity_scoretừ 0 đến 10.
Gọi LLM tốn tiền, nên phần chọn chủ đề nào cần phân tích quan trọng không kém prompt:
- Chỉ chủ đề còn hoạt động (từ Signal tới Peak) mới được xét; chủ đề đang Declining hay Dead thì không.
- Một chủ đề được phân tích lại khi: chưa từng có phân tích, có từ 5 bài mới kể từ lần trước, được đánh dấu cần làm lại, hoặc bản cũ đã quá 24 giờ.
- Ứng viên được xếp theo điểm độ nóng, và mỗi lượt có trần (500 chủ đề để đặt tên, 300 để phân tích).
- Cache theo nội dung: mỗi bản phân tích lưu hash của danh sách bài nó dựa vào. Nếu danh sách bài không đổi thì bỏ qua, không gọi LLM.
Lượng token mỗi lượt được ghi lại để theo dõi chi phí. Chỉ cần đặt LLM_ENABLED=false là cả pipeline vẫn chạy, chỉ thiếu tên và phần phân tích.
Vận hành, và những chỗ có thể cải thiện
Hằng ngày, service không cần trông nhiều:
- Dữ liệu: database SQLite hiện khoảng 3,8 GB. Chủ đề đã chết, bài không thuộc chủ đề nào và log cũ hơn 30 ngày bị xoá mỗi ngày.
- Đo lường: mỗi lượt ghi thời gian của từng bước (embed, cluster, label, insight, lifecycle, digest) và số token đã dùng. Dashboard của app hiện các số này, và hệ thống giám sát chung cũng theo dõi chúng (xem bài tự vận hành server cho dự án cá nhân).
Vẫn còn vài chỗ có thể làm tốt hơn:
- Phân cụm lại theo cửa sổ trượt thay vì chỉ phân cụm bài mới rồi so centroid. Cách hiện tại rẻ, nhưng centroid có thể trôi dần khi chủ đề sống lâu.
- Đánh giá có nhãn. Các ngưỡng 0,75 / 0,78 / 0,82 hiện là các giá trị cố định trong code. Một bộ vài trăm cặp bài được gán nhãn "cùng chuyện / khác chuyện" sẽ giúp chọn ngưỡng có cơ sở hơn.
- Bỏ trùng theo nội dung, không chỉ theo URL, để bắt được các bài đăng lại.
Tóm tắt
- Một hệ thống theo dõi xu hướng trả lời "chủ đề nào đang nóng lên, chủ đề nào đang nguội đi, và vì sao", không chỉ "hôm nay có tin gì".
- HDBSCAN hợp với tin tức vì nó tự tìm số cụm và đánh dấu các tin lẻ là nhiễu;
leafgiữ các sự kiện liên quan nhưng khác nhau ở các cụm riêng. - Chủ đề sống qua nhiều lượt nhờ centroid được lưu lại và các ngưỡng cosine, trong đó ngưỡng gộp đặt chặt hơn ngưỡng nhập vì gộp nhầm thì khó gỡ.
- Vận tốc và gia tốc điều khiển một vòng đời đơn giản từ Signal tới Dead, và mỗi lần chuyển trạng thái đều ghi lý do để đọc lại lịch sử của chủ đề.
- Điểm độ nóng dành trọng số riêng cho số nguồn khác nhau, vì 8 tờ báo cùng đưa một chuyện có ý nghĩa hơn 20 bài từ một tờ.
- Chi phí LLM được kiểm soát bằng cách chọn chủ đề để phân tích: chỉ chủ đề còn hoạt động, chỉ khi tập bài của nó thay đổi, xếp theo điểm và có giới hạn mỗi lượt.
Bạn có thể xem bản đang chạy tại news.vuminhhoa.com. Nếu bạn cũng đang làm gì đó với tin tức tiếng Việt, mình rất muốn nghe: email ở trang chủ.