從非結構化文本中自動提取關鍵詞,是搜索引擎、內容推薦、SEO 分析、輿情監控的核心環節。本文實現三種經典算法:詞頻統計 (TF)、TF-IDF、TextRank,並對比效果、複雜度與適用場景,提供可直接落地的 PHP 代碼。
| 算法 | 核心思想 | 是否需語料庫 | 語義感知 | 計算複雜度 | 典型場景 |
|---|---|---|---|---|---|
| 詞頻 (TF) | 詞在文檔中出現次數越多越重要 | 否 | 無 | O(N) | 單文檔快速摘要、標簽生成 |
| TF-IDF | TF × log(總文檔數/包含詞文檔數),抑制常用詞 | 是 (需 IDF 統計) | 弱 | O(N + M) M=語料庫 | 多文檔檢索、關鍵詞對比、聚類 |
| TextRank | 基於 PageRank 的圖模型,詞共現窗口構建有向加權圖 | 否 (單文檔即可) | 中 (捕捉局部共現關係) | O(V²) V=詞彙量 | 單文檔關鍵句/詞提取、自動摘要 |
PHP 原生不支援中文分詞,必須引入擴展或庫:
<?php
// 統一分詞介面
interface Segmenter {
public function cut(string $text): array; // 返回詞陣列
}
// Jieba 實現
class JiebaSegmenter implements Segmenter {
private \Fukuball\Jieba\Jieba $jieba;
public function __construct() {
$this->jieba = new \Fukuball\Jieba\Jieba();
$this->jieba->init();
// 載入用戶詞典
$this->jieba->loadUserDict(__DIR__ . '/userdict.txt');
}
public function cut(string $text): array {
return $this->jieba->cut($text); // 默認精確模式
}
}
<?php
class TFKeywordExtractor {
private Segmenter $segmenter;
private array $stopWords = []; // 停用詞表
public function __construct(Segmenter $segmenter, string $stopWordsFile = '') {
$this->segmenter = $segmenter;
if ($stopWordsFile && file_exists($stopWordsFile)) {
$this->stopWords = array_flip(array_filter(array_map('trim', file($stopWordsFile))));
}
}
public function extract(string $text, int $topK = 10, int $minLen = 2): array {
$words = $this->segmenter->cut($text);
$freq = [];
foreach ($words as $w) {
$w = trim($w);
if (mb_strlen($w) < $minLen) continue;
if (isset($this->stopWords[$w])) continue;
// 過濾純數字、純標點、單字母
if (preg_match('/^[\d\p{P}\p{S}]+$/u', $w)) continue;
$freq[$w] = ($freq[$w] ?? 0) + 1;
}
arsort($freq);
return array_slice($freq, 0, $topK, true);
}
}
<?php
class TFIDFKeywordExtractor {
private Segmenter $segmenter;
private array $idf = []; // 詞 -> IDF 值
private float $medianIdf; // 未見詞默認 IDF
private array $stopWords = [];
public function __construct(Segmenter $segmenter, string $idfFile, string $stopWordsFile = '') {
$this->segmenter = $segmenter;
$this->loadIdf($idfFile);
if ($stopWordsFile && file_exists($stopWordsFile)) {
$this->stopWords = array_flip(array_filter(array_map('trim', file($stopWordsFile))));
}
}
private function loadIdf(string $file): void {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
[$word, $idf] = explode(' ', $line, 2);
$this->idf[$word] = (float)$idf;
}
$this->medianIdf = $this->idf ? median($this->idf) : 1.0;
}
public function extract(string $text, int $topK = 10): array {
$words = $this->segmenter->cut($text);
$tf = [];
foreach ($words as $w) {
$w = trim($w);
if (mb_strlen($w) < 2 || isset($this->stopWords[$w])) continue;
if (preg_match('/^[\d\p{P}\p{S}]+$/u', $w)) continue;
$tf[$w] = ($tf[$w] ?? 0) + 1;
}
$tfidf = [];
foreach ($tf as $w => $freq) {
$idf = $this->idf[$w] ?? $this->medianIdf;
$tfidf[$w] = $freq * $idf;
}
arsort($tfidf);
return array_slice($tfidf, 0, $topK, true);
}
}
// IDF 文件生成腳本 (離線跑一次)
/*
$corpus = ['文檔1內容...', '文檔2內容...', ...]; // 數萬文檔
$docCount = count($corpus);
$df = []; // 詞 -> 包含文檔數
foreach ($corpus as $doc) {
$words = array_unique($segmenter->cut($doc));
foreach ($words as $w) $df[$w] = ($df[$w] ?? 0) + 1;
}
$fh = fopen('idf.txt', 'w');
foreach ($df as $w => $cnt) {
$idf = log($docCount / ($cnt + 1));
fwrite($fh, "$w $idf\n");
}
fclose($fh);
*/
<?php
class TextRankKeywordExtractor {
private Segmenter $segmenter;
private int $window = 5; // 共現窗口大小
private float $damping = 0.85; // PageRank 阻尼係數
private int $maxIter = 50;
private float $minDiff = 1e-5;
private array $stopWords = [];
public function __construct(Segmenter $segmenter, string $stopWordsFile = '') {
$this->segmenter = $segmenter;
if ($stopWordsFile && file_exists($stopWordsFile)) {
$this->stopWords = array_flip(array_filter(array_map('trim', file($stopWordsFile))));
}
}
public function extract(string $text, int $topK = 10): array {
// 1. 分詞 + 過濾
$words = array_filter($this->segmenter->cut($text), function($w) {
$w = trim($w);
return mb_strlen($w) >= 2
&& !isset($this->stopWords[$w])
&& !preg_match('/^[\d\p{P}\p{S}]+$/u', $w);
});
$wordList = array_values($words); // 重新索引
$vocabSize = count($wordList);
if ($vocabSize === 0) return [];
// 2. 構建詞 -> 索引映射 (去重)
$wordToIdx = [];
$uniqueWords = [];
foreach ($wordList as $w) {
if (!isset($wordToIdx[$w])) {
$wordToIdx[$w] = count($uniqueWords);
$uniqueWords[] = $w;
}
}
$V = count($uniqueWords);
if ($V === 0) return [];
// 3. 構建共現矩陣 (稀疏鄰接表)
$adj = array_fill(0, $V, []);
for ($i = 0; $i < $vocabSize; $i++) {
$wi = $wordToIdx[$wordList[$i]];
for ($j = $i + 1; $j < min($i + $this->window, $vocabSize); $j++) {
$wj = $wordToIdx[$wordList[$j]];
if ($wi === $wj) continue;
$adj[$wi][$wj] = ($adj[$wi][$wj] ?? 0) + 1;
$adj[$wj][$wi] = ($adj[$wj][$wi] ?? 0) + 1; // 無向圖
}
}
// 4. PageRank 迭代
$score = array_fill(0, $V, 1.0 / $V);
for ($iter = 0; $iter < $this->maxIter; $iter++) {
$newScore = [];
$maxDiff = 0.0;
for ($v = 0; $v < $V; $v++) {
$sum = 0.0;
foreach ($adj[$v] as $u => $weight) {
$outSum = array_sum($adj[$u]);
if ($outSum > 0) $sum += $weight / $outSum * $score[$u];
}
$newScore[$v] = (1 - $this->damping) / $V + $this->damping * $sum;
$maxDiff = max($maxDiff, abs($newScore[$v] - $score[$v]));
}
$score = $newScore;
if ($maxDiff < $this->minDiff) break;
}
// 5. 按分數排序輸出
$scoredWords = array_combine($uniqueWords, $score);
arsort($scoredWords);
return array_slice($scoredWords, 0, $topK, true);
}
}
<?php $text = "人工智能技術快速發展,深度學習在圖像識別、自然語言處理、語音識別等領域取得突破性進展。Transformer 架構成為大模型基石,BERT、GPT 系列模型推動 NLP 範式變革。國內外科技巨頭持續加大 AI 投入,算力基礎設施建設加速。"; $segmenter = new JiebaSegmenter(); $stopWords = __DIR__ . '/stopwords.txt'; // 包含:的、了、在、是、等、於、中、與、等 echo "=== TF ===\n"; print_r((new TFKeywordExtractor($segmenter, $stopWords))->extract($text, 8)); echo "\n=== TF-IDF (需預訓練 IDF) ===\n"; // 假設已有 idf.txt // print_r((new TFIDFKeywordExtractor($segmenter, 'idf.txt', $stopWords))->extract($text, 8)); echo "\n=== TextRank ===\n"; print_r((new TextRankKeywordExtractor($segmenter, $stopWords))->extract($text, 8));
=== TF ===
Array
(
[人工智能] => 2
[技術] => 2
[模型] => 2
[領域] => 2
[發展] => 2
[深度學習] => 1
[圖像識別] => 1
[自然語言處理] => 1
)
=== TextRank ===
Array
(
[人工智能] => 0.0342
[深度學習] => 0.0298
[Transformer] => 0.0281
[大模型] => 0.0265
[BERT] => 0.0249
[GPT] => 0.0249
[NLP] => 0.0231
[算力] => 0.0215
)
觀察:TextRank 更能捕捉「Transformer、BERT、GPT、大模型」等低頻但高信息量的專業術語,TF 傾向高頻通用詞。
| 場景 | 推薦算法 | 理由 |
|---|---|---|
| 單文檔、即時、無語料庫 | TextRank | 無監督、捕捉局部語義、單文檔即可 |
| 海量文檔檢索、相關性排序 | TF-IDF / BM25 | IDF 抑制通用詞、倒排索引友好、工業標準 |
| 標簽生成、SEO 關鍵詞、極簡場景 | TF + 詞性過濾 | 極快、無依賴、結果可解釋 |
| 短文本 (標題、微博、評論) | TextRank + 實體提權 | 短文本共現稀疏,實體補強語義 |
| 長文本 (文章、論文、專利) | TextRank (句子級) + TF-IDF (詞級) | 句子級 TextRank 做摘要,詞級 TF-IDF 做標簽 |
關鍵詞提取無「銀彈」,**TextRank 單文檔無監督最通用,TF-IDF 多文檔檢索最經典,TF 極簡場景最省事**。工程上建議:**分詞器 (Jieba/SCWS) + 停用詞 + 詞性過濾** 作為統一前置,**算法層插件化** 按場景切換。若追求 SOTA 效果,可接入 **BERT/ERNIE 微調序列標註模型** (如 KeyBERT、BERT-Keyword),但需 GPU 推理服務,成本與收益需權衡。