Pipeline tổng quan
- Tiền xử lý: xác định ngôn ngữ
vi/en, ghép stopwords mặc định + tùy chỉnh, chuẩn hóa Unicode, hạ chữ thường, loại ký tự không hợp lệ theo ngôn ngữ. - Tạo tập văn bản: mỗi dòng tiêu đề được nhân 3 lần để tăng trọng số (“đề mục ngắn” không bị thiệt), sau đó tách từ.
- BM25 cho unigram: chấm điểm từng từ đơn trên toàn bộ tập tiêu đề.
- Đếm n-gram: tạo n-gram theo các độ dài user chọn (mặc định ≥2).
- NPMI gần đúng cho n-gram: so sánh tần suất n-gram với tích xác suất các unigram cấu thành.
- LLR cho bigram liên tiếp: cộng dồn LLR của từng cặp từ kề nhau trong n-gram để boost cụm dính thật.
- Điểm cuối:
score = freq × ΣBM25(token) × (1 + max(NPMI, 0)) × boost(LLR), sau đó lọc và chọn ra top K đa dạng.
Tiền xử lý: tokenization & stopwords
Source dùng danh sách stopwords nội bộ (riêng vi có thêm “chap, chương”…) và list người dùng nhập. Token việt hóa dùng biểu thức ký tự Unicode (\p{L}\p{N}), tiếng Anh dùng a–z0–9.
function _processText(t, stop, isVI) {
try { t = t.normalize("NFC"); } catch(_) {}
t = _decodeHtmlEntities(t || "").toLowerCase().trim()
.replace(/[\u2010-\u2212-]+/gu, "-") // chuẩn hóa dấu gạch
.replace(/ /g, " ");
t = isVI ? t.replace(/[^\p{L}\p{N}\s-]/gu, " ")
: t.replace(/[^a-z0-9\s-]/g, " ");
const toks = t.replace(/\s+/g, " ").trim().split(" ");
const out = [];
for (const x of toks) {
if (!x) continue;
if (stop[x]) continue; // stopword
if (x.length < 2) continue; // token quá ngắn
if (/^\d+$/u.test(x)) continue; // chỉ số
out.push(x);
}
return out;
}
BM25 cho unigram
BM25 tính trên toàn bộ tập tiêu đề (sau nhân ba) để lấy “độ quan trọng” của mỗi từ đơn. Công thức trong code đã gồm IDF và chuẩn hóa độ dài.
function _calculateBM25(docs, weights, k1 = 1.5, b = 0.75) {
const N = docs.length;
if (!N) return new Map();
const df = new Map(), lens = new Array(N).fill(0);
let avgLen = 0;
for (let i = 0; i < N; i++) {
const tokens = docs[i];
lens[i] = tokens.length;
avgLen += lens[i];
const uniq = new Set(tokens);
for (const t of uniq) df.set(t, (df.get(t) || 0) + 1);
}
avgLen = Math.max(avgLen / N, 1);
const score = new Map();
for (let i = 0; i < N; i++) {
const tf = new Map();
for (const t of docs[i]) tf.set(t, (tf.get(t) || 0) + 1);
for (const [t, f] of tf) {
const n = df.get(t) || 0;
const idf = Math.log((N - n + .5) / (n + .5) + 1);
const denom = f + k1 * (1 - b + b * (lens[i] / avgLen));
const s = idf * (f * (k1 + 1)) / Math.max(denom, 1e-9);
score.set(t, (score.get(t) || 0) + s * (weights[i] || 1));
}
}
return score;
}
NPMI “gọn nhẹ” cho n-gram
NPMI đo độ gắn kết của cả cụm so với giả định độc lập giữa các thành phần. Trong code, tần suất n-gram được so với tích xác suất unigram để tính một dạng NPMI chuẩn hóa, cắt ngưỡng âm về 0 khi kết hợp.
// Giả mã hóa đoạn tính NPMI trong vòng lặp n-gram:
const rel = (ngCount + 1) / totalNgramCount; // P(ngram)
let prod = 1;
for (const tok of tokensInGram) {
const pTok = Math.max(unigramCount.get(tok) || 0, 1) / totalUnigram;
prod *= Math.max(pTok, 1e-12);
}
const npmi = Math.log(rel / Math.max(prod, 1e-12)) / Math.max(-Math.log(rel), 1e-9);
// Dùng (1 + max(npmi, 0)) như hệ số tăng cường
LLR để “chốt hạ” collocation
Để tránh cụm “dính giả”, mỗi n-gram còn được cộng dồn LLR cho từng cặp bigram kề nhau bên trong. Code dùng công thức 2×log-likelihood (Dunning). Tổng LLR được scale nhẹ để tạo boost tối đa +3.
function _llrDunning(k11, k12, k21, k22) {
const T = k11 + k12 + k21 + k22; if (T === 0) return 0;
const R1 = k11 + k12, R2 = k21 + k22, C1 = k11 + k21, C2 = k12 + k22;
const E11 = R1 * C1 / Math.max(T, 1), E12 = R1 * C2 / Math.max(T, 1);
const E21 = R2 * C1 / Math.max(T, 1), E22 = R2 * C2 / Math.max(T, 1);
const safeLL = (k, e) => (k === 0 || e === 0) ? 0 : k * Math.log(k / e);
return 2 * (safeLL(k11, E11) + safeLL(k12, E12) + safeLL(k21, E21) + safeLL(k22, E22));
}
// Trong vòng chấm n-gram, cộng dồn LLR cho từng cặp kề nhau:
let llrSum = 0;
for (let i = 0; i < tokens.length - 1; i++) {
const a = tokens[i], b = tokens[i+1];
const k11 = bigram.counts.get(`${a} ${b}`) || 0;
const leftA = bigram.left.get(a) || 0;
const rightB = bigram.right.get(b) || 0;
const k12 = Math.max(leftA - k11, 0);
const k21 = Math.max(rightB - k11, 0);
const k22 = Math.max((bigram.N || 0) - k11 - k12 - k21, 0);
llrSum += _llrDunning(k11, k12, k21, k22);
}
// Boost giới hạn trong [1, 4]:
const boost = 1 + Math.min(llrSum / 12, 3);
Điểm cuối & bộ lọc
Điểm của một n-gram là:
const score = freq * sumBM25Tokens * (1 + Math.max(npmi, 0)) * boostLLR;
- Bộ lọc cơ bản: loại toàn số, cụm quá ngắn/dài, token 1 ký tự…
- Ngôn ngữ: loại cụm rỗng nghĩa kiểu “là gì”, “and the”… (allowlist/denylist nhẹ).
- Đa dạng đầu ra: sau khi sort theo điểm, thuật toán chọn khôn ngoan để hạn chế trùng lặp thành phần (giữ nửa trên theo điểm, nửa còn lại đa dạng hóa).
Ví dụ thực tế
Giả sử nhập tiêu đề (mỗi dòng một tiêu đề):
one piece chapter 1000
attack on titan season 4
jujutsu kaisen shibuya arc
Với stopwords mặc định + n-gram độ dài 2–3, output điển hình:
one piece (152.7)
attack on titan (139.3)
shibuya arc (88.4)
season 4 (41.9)
chapter 1000 (38.1)
“one piece” và “attack on titan” nổi lên nhờ: BM25 cao cho “one/piece/attack/titan”, NPMI dương cho n-gram, và LLR strong giữa từng cặp từ kề nhau.
Ghép nối mọi thứ trong hàm extractKeywords()
Đây là cấu trúc rút gọn (tương ứng source):
function extractKeywords() {
const lang = (document.documentElement.lang || "").toLowerCase().startsWith("vi") ? "vi" : "en";
const defaults = lang === "vi" ? ["là","và","của","các","chap","chương", /* ... */] : ["is","are","the","and", /* ... */];
const userStop = (document.getElementById("stopwords-input")?.value || "")
.split(",").map(x => x.trim().toLowerCase()).filter(Boolean);
const stop = Object.fromEntries(new Set([...defaults, ...userStop]).values());
const raw = (document.getElementById("title-input")?.value || "").trim();
if (!raw) return setOutput(i18n[lang].pleaseInput);
const lines = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const docs = [], weights = [], unigramCount = new Map(); let unigramTotal = 0;
for (const line of lines) {
const tokens = _processText(`${line}\n${line}\n${line}`, stop, lang === "vi");
if (!tokens.length) continue;
docs.push(tokens); weights.push(1);
for (const t of tokens) { unigramCount.set(t, (unigramCount.get(t) || 0) + 1); unigramTotal++; }
}
if (!docs.length) return setOutput(i18n[lang].noKeyword);
const bm25 = _calculateBM25(docs, weights);
const ngrams = _buildNgramCounts(docs, getSelectedLengthsOrDefault());
const bigram = _buildBigramStats(docs);
const scores = new Map();
for (const [nStr, table] of Object.entries(ngrams)) {
const n = parseInt(nStr, 10), totalN = _calcNTotals(ngrams).get(n) || 1;
for (const [gram, cnt] of table.entries()) {
const toks = gram.split(" ");
let sumBM = 0; for (const t of toks) sumBM += bm25.get(t) || 0;
const rel = (cnt + 1) / totalN;
let prod = 1; for (const t of toks) prod *= Math.max((unigramCount.get(t) || 0) / Math.max(unigramTotal,1), 1e-12);
const npmi = Math.log(rel / Math.max(prod, 1e-12)) / Math.max(-Math.log(rel), 1e-9);
// LLR boost
let llrSum = 0;
for (let i = 0; i < toks.length - 1; i++) {
const a = toks[i], b = toks[i+1], k11 = bigram.counts.get(`${a} ${b}`) || 0;
const k12 = Math.max((bigram.left.get(a)||0) - k11, 0);
const k21 = Math.max((bigram.right.get(b)||0) - k11, 0);
const k22 = Math.max((bigram.N||0) - k11 - k12 - k21, 0);
llrSum += _llrDunning(k11, k12, k21, k22);
}
const boost = 1 + Math.min(llrSum / 12, 3);
if (_passesBasicFilters(gram)) {
const sc = cnt * sumBM * (1 + Math.max(npmi, 0)) * boost;
scores.set(gram, (scores.get(gram) || 0) + sc);
}
}
}
// Lọc theo ngôn ngữ & đa dạng hóa
const filtered = _filterByLanguage(scores, lang);
const sorted = Array.from(filtered.entries())
.sort((a,b) => b[1] - a[1] || a[0].length - b[0].length || a[0].localeCompare(b[0]));
const top = sorted.slice(0, 50).map(([k]) => k);
const final15 = _smartKeywordSelection(top, 15);
setOutput(final15.length ? final15.map(k => `${k} (${_fmtScore(new Map(sorted).get(k))})`).join("\n")
: i18n[lang].noKeyword);
}
Gợi ý triển khai thực tế
- Chọn n-gram 2–3 cho tiêu đề; 4+ chỉ nên bật khi corpus lớn.
- Thêm stopwords domain-specific (ví dụ “season”, “chapter”, “tập”) để giảm nhiễu.
- Tinh chỉnh hệ số LLR (
/12và trần +3) nếu corpus thay đổi mạnh. - Nhân bản dòng (×3) có chủ ý: tiêu đề ngắn cần “điểm nhấn” để không thua thiệt trước n-gram dài.
Kết luận
BM25 xử lý mức độ quan trọng theo tài liệu, NPMI đo gắn kết n-gram, còn LLR xác nhận collocation thật. Kết hợp cả ba trong Init Keyword Extractor tạo nên bộ trích xuất từ khóa gọn nhẹ, chạy ngay trên trình duyệt nhưng vẫn đủ “khôn” để hiểu cụm từ người dùng đang tìm. Muốn kết quả sạch và hữu ích, đừng chỉ tăng n-gram hay sắp xếp theo tần suất – hãy để bộ ba này làm việc.
Bình luận