PHP项目实现TF-IDF:从算法原理到实战代码的完整指南
目录导读
- 什么是TF-IDF?核心概念与数学公式
- PHP实现TF-IDF的准备工作与数据结构设计
- 文本预处理与分词(中文与英文)
- 计算词频(TF)
- 计算逆文档频率(IDF)
- 整合TF-IDF权重并排序
- 实战案例:基于TF-IDF的简单搜索引擎Demo
- 性能优化与常见问题解答
- 常见FAQ问答
什么是TF-IDF?核心概念与数学公式
TF-IDF(Term Frequency-Inverse Document Frequency)是一种用于信息检索与文本挖掘的经典加权技术,它的核心思想是:一个词在某一文档中出现的频率越高,同时在所有文档中出现的频率越低,则该词对这篇文档的区分度越大。

数学上,TF-IDF由两部分组成:
- TF(词频) = 某个词在文档中出现的次数 / 该文档的总词数
- IDF(逆文档频率) = log( 文档总数 / 包含该词的文档数 + 1 )
- TF-IDF = TF × IDF
在技术文章中,“PHP”这个词可能频繁出现(高TF),但在整个语料库中其他文档也经常提到“PHP”(低IDF),那么它的TF-IDF值反而不如“逆文档频率”这种生僻词高,这种特性使TF-IDF成为关键词提取和文档相似度计算的基石。
PHP实现TF-IDF的准备工作与数据结构设计
在编写代码前,我们需要明确数据存储结构,假设我们有一组文档(如博客文章或产品描述),推荐使用以下PHP数据结构:
// 文档集合:二维数组,每篇文章包含id和content
$documents = [
['id' => 1, 'content' => 'PHP是一种通用开源脚本语言...'],
['id' => 2, 'content' => '使用PHP框架Laravel可以高效开发...'],
// ...
];
// 最终TF-IDF结果存储:词 => [文档id => 权重]
$tfidfResults = [];
为了便于计算,我们还需要一个倒排索引结构:word => [doc1_count, doc2_count, ...],它既能统计词频,也能快速得到包含某词的文档数。
文本预处理与分词(中文与英文)
PHP原生没有中文分词功能,推荐使用开源扩展或API,这里以英文为例演示通用逻辑,中文可配合scws或jieba-php。
function tokenize($text) {
// 转为小写,去除标点符号
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9\s]/', '', $text);
// 按空格分割
return preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
}
重要:中文必须调用分词器,否则“我爱PHP”会被当成一个词,导致TF计算失真。
计算词频(TF)
对每个文档,统计每个词的出现次数并除以总词数:
function calculateTF($tokens) {
$totalWords = count($tokens);
$tokensCount = array_count_values($tokens);
$tf = [];
foreach ($tokensCount as $word => $count) {
$tf[$word] = $count / $totalWords;
}
return $tf;
}
此步骤输出的$tf数组即文档中每个词的归一化频率。
计算逆文档频率(IDF)
IDF的计算需要全局文档信息,假设我们有N篇文档,对于每个词,统计它在多少篇文档中出现过(非零TF即算出现):
function calculateIDF($allDocumentsTokens, $totalDocs) {
$docCount = [];
foreach ($allDocumentsTokens as $docTokens) {
$uniqueWords = array_unique($docTokens);
foreach ($uniqueWords as $word) {
$docCount[$word] = ($docCount[$word] ?? 0) + 1;
}
}
$idf = [];
foreach ($docCount as $word => $count) {
// 平滑处理:加1防止除零
$idf[$word] = log(($totalDocs + 1) / ($count + 1)) + 1;
}
return $idf;
}
关键点:这里的totalDocs是文档总数,$count是包含该词的文档数,平滑处理能避免新词导致IDF无穷大的情况。
整合TF-IDF权重并排序
将每个文档的TF值与全局IDF相乘,得到每个词在每篇文档中的权重:
function computeTFIDF($documents, $idf) {
$result = [];
foreach ($documents as $doc) {
$tokens = tokenize($doc['content']);
$tf = calculateTF($tokens);
$docId = $doc['id'];
foreach ($tf as $word => $tfValue) {
if (isset($idf[$word])) {
$result[$docId][$word] = $tfValue * $idf[$word];
}
}
// 按权重降序排序
arsort($result[$docId]);
}
return $result;
}
运行后,$result[1]['php']即表示文档1中“php”这个词的TF-IDF权重。
实战案例:基于TF-IDF的简单搜索引擎Demo
我们来实现一个函数,根据用户查询词返回最相关的文档:
function search($query, $documents, $tfidfResults) {
$queryTokens = tokenize($query);
$scores = [];
foreach ($documents as $doc) {
$docId = $doc['id'];
$score = 0;
foreach ($queryTokens as $qt) {
$score += $tfidfResults[$docId][$qt] ?? 0;
}
$scores[$docId] = $score;
}
arsort($scores);
return array_keys(array_slice($scores, 0, 3)); // 返回top3文档
}
测试时输入“PHP脚本”,系统会计算所有文档中“php”和“脚本”的TF-IDF和,返回得分最高的文档ID。
性能优化与常见问题解答
Q1:文档数量很大时,内存占用高怎么办?
将TF-IDF结果持久化到数据库(如MongoDB或MySQL的JSON字段),或使用Redis缓存,PHP程序只负责写入和读取,避免全量加载。
Q2:为什么我算出来的IDF值全是负数?
检查log()计算中是否出现小于1的输入,例如当totalDocs = 5且某词出现在6篇文档中(不可能),或者平滑公式写错,正确公式应保证分子≥分母。
Q3:如何让TF-IDF支持中文?
安装scws扩展或使用jieba-php类库,中文分词后,需要手动处理停用词(如“的”“了”“在”),这些词的TF很高但IDF几乎为零,会干扰结果。
Q4:TF-IDF与BM25算法的区别是什么?
BM25是TF-IDF的进化版,引入了文档长度归一化和单词频率饱和,在搜索引擎中表现更优,如果对精度要求高,建议升级到BM25算法。
常见FAQ问答
问:TF-IDF能否用于长文本的关键词提取?
答:可以,将单篇文档作为输入,把文档集合设为同一篇文档的多个分段,或直接使用外部语料库计算IDF,单篇文档无IDF则退化为TF。
问:PHP项目中使用TF-IDF有什么第三方库推荐?
答:nlp-tools/nlp-tools是一个纯PHP的自然语言处理库,包含TF-IDF功能。team-reflex/lexer也可用于分词和权重计算。
问:TF-IDF的IDF部分是否一定要用log?
答:大多数实现都使用自然对数或10为底的对数,对数变换是为了平滑权重差异,避免少数高频词(如“the”)权重过大,你也可以尝试平方根或其他衰减函数。
问:如何避免TF-IDF计算中大量重复计算?
答:建立预处理管道,先对所有文档进行分词、去停用词,生成词袋模型,然后一次性计算IDF,最后按需计算TF-IDF,对于只增不减的语料库,可以增量更新IDF。
问:PHP的TF-IDF性能足够用于生产环境吗?
答:对于小到中型站内搜索(文档数<10万,单文档字数<5000),纯PHP完全可以胜任,超过此规模建议使用Elasticsearch或Solr,它们内置了TF-IDF/BM25引擎。
通过以上从原理到代码的完整讲解,你应该已经掌握在PHP项目中实现TF-IDF的全部技能,建议先从英文文档测试,确认逻辑正确后再加入中文分词组件,如果你在实现过程中遇到任何具体问题,欢迎在评论区留言讨论。