从0实现一个搜索引擎php 从0实现一个PHP搜索引擎 一、搜索引擎核心原理(大白话)想象你有1000本书,要找苹果这个词。 笨办法:一本一本翻,慢死。 聪明办法:提前做一个词典索引,记录每个词出现在哪本书的哪一页。要找苹果时,直接查索引 →秒出结果。 这个反过来的索引(从词 →文档),就叫倒排索引(Inverted Index),是所有搜索引擎的核心。 整个流程分5步:1. 采集:拿到要被搜索的文档2. 分词:把句子切成一个个词3. 建索引:记录哪个词在哪个文档出现过几次4. 存储:把索引存起来(我们用文件,真实项目用Redis/ES)5. 查询:用户搜索时,从索引里找,按相关度排序返回 --- 二、完整代码?php /** * 一个从0实现的迷你搜索引擎 * 文件:search_engine.php */ class MiniSearchEngine{// 存放所有文档的原文,格式[docId文档内容]private$documents[];// 倒排索引,核心数据结构 // 格式:[词[docId该词在此文档出现次数]]// 例:[苹果[13,51]]表示苹果在文档1出现3次,在文档5出现1次 private$invertedIndex[];// 每个文档的总词数,用来算TF(词频率)private$docLength[];// 索引文件保存路径 private$indexFileindex.dat;// 停用词:这些词太常见没意义,不建索引(的、了、是...)private$stopWords[的,了,是,在,和,我,你,他,a,the,is,of];/** * 第1步:添加文档到引擎 * 大白话:告诉引擎这是一篇要被搜索的文章*/ publicfunctionaddDocument($docId,$content){// 把原文存起来,搜索时要返回给用户看$this-documents[$docId]$content;// 分词:把整段文字切成一个个词$words$this-tokenize($content);// 记录这个文档总共有多少个词(算相关度要用)$this-docLength[$docId]count($words);// 遍历每个词,塞进倒排索引 foreach($wordsas$word){// 如果这个词第一次出现,先初始化if(!isset($this-invertedIndex[$word])){$this-invertedIndex[$word][];}// 如果这个词在当前文档第一次出现,计数从0开始if(!isset($this-invertedIndex[$word][$docId])){$this-invertedIndex[$word][$docId]0;}// 该词在该文档出现次数 1$this-invertedIndex[$word][$docId];}}/** * 第2步:分词器 * 大白话:把我爱吃苹果切成[我,爱,吃,苹果]* 这里用最简单的方式:中文按单字切,英文按空格切 * 真实项目用 jieba/结巴分词、IKAnalyzer 等专业工具 */ privatefunctiontokenize($text){// 全部转小写,这样搜Apple和apple是一样的$textmb_strtolower($text,UTF-8);// 去掉标点符号$textpreg_replace(/[^\p{L}\p{N}\s]/u, ,$text);$tokens[];// 按空格先切一刀(主要切英文单词)$segmentspreg_split(/\s/,$text);foreach($segmentsas$segment){if(empty($segment))continue;// 判断是不是纯英文/数字if(preg_match(/^[a-z0-9]$/i,$segment)){// 英文/数字:整个词当一个token$tokens[]$segment;}else{// 中文:按单字切(粗暴但能用)$lenmb_strlen($segment,UTF-8);// 单字索引for($i0;$i$len;$i){$tokens[]mb_substr($segment,$i,1,UTF-8);}// 同时做2字组合(bigram),提高搜索准确度 // 比如苹果会被索引成 苹、果、苹果for($i0;$i$len-1;$i){$tokens[]mb_substr($segment,$i,2,UTF-8);}}}// 过滤停用词$tokensarray_filter($tokens,function($w){return!in_array($w,$this-stopWords)mb_strlen($w,UTF-8)0;});returnarray_values($tokens);}/** * 第3步:保存索引到文件 * 大白话:把内存里的索引写到硬盘,下次启动直接读,不用重建 */ publicfunctionsaveIndex(){$data[documents$this-documents,invertedIndex$this-invertedIndex,docLength$this-docLength,];// serialize 把PHP数据变成字符串,再写文件 file_put_contents($this-indexFile, serialize($data));}/** * 加载索引 */ publicfunctionloadIndex(){if(!file_exists($this-indexFile))returnfalse;$dataunserialize(file_get_contents($this-indexFile));$this-documents$data[documents];$this-invertedIndex$data[invertedIndex];$this-docLength$data[docLength];returntrue;}/** * 第4步:核心查询函数 * 大白话:用户输入苹果手机,我们: *1. 把搜索词也分词:[苹果, 手机, 苹, 果, 手, 机]*2. 从倒排索引找每个词对应的文档 *3. 算每个文档的相关度分数(用TF-IDF)*4. 按分数从高到低排序返回 */ publicfunctionsearch($query,$limit10){// 把查询词也分词$queryTokens$this-tokenize($query);if(empty($queryTokens))return[];// 候选文档分数表[docId分数]$scores[];// 总文档数(算IDF要用)$totalDocscount($this-documents);foreach($queryTokensas$token){// 这个词没在任何文档出现过,跳过if(!isset($this-invertedIndex[$token]))continue;// 这个词在多少个文档出现过(文档频率)$docFreqcount($this-invertedIndex[$token]);// IDF(逆文档频率):一个词在越少文档出现,说明越特别,权重越高 // 比如的在每篇都有,IDF≈0;量子力学只在少数有,IDF很大 // 公式:log(总文档数 / 出现该词的文档数)$idflog($totalDocs/$docFreq);// 遍历包含这个词的每个文档 foreach($this-invertedIndex[$token]as$docId$termFreq){// TF(词频率):一个词在某文档出现越多,说明该文档越相关 // 这里做归一化:出现次数 / 文档总词数$tf$termFreq/$this-docLength[$docId];// TF-IDF 分数TF ×IDF // 累加每个查询词的得分if(!isset($scores[$docId]))$scores[$docId]0;$scores[$docId]$tf*$idf;}}// 按分数从高到低排 arsort($scores);// 取前N条,组装结果$results[];$count0;foreach($scoresas$docId$score){if($count$limit)break;$results[][docId$docId,scoreround($score,4),content$this-documents[$docId], // 高亮匹配的词(给用户看的)highlight$this-highlight($this-documents[$docId],$queryTokens),];$count;}return$results;}/** * 高亮:把命中的关键词用em包起来 */ privatefunctionhighlight($content,$tokens){// 按词长度从长到短排序,先替换长的,避免苹果被先替换成em苹/em果usort($tokens,function($a,$b){returnmb_strlen($b)- mb_strlen($a);});foreach(array_unique($tokens)as$token){if(mb_strlen($token,UTF-8)1)continue;$contentpreg_replace(/(.preg_quote($token,/).)/iu,em$1/em,$content);}return$content;}}//使用示例$enginenew MiniSearchEngine();// 准备一批文档(模拟数据库里的文章)$articles[1苹果公司今天发布了新款iPhone手机,性能大幅提升,2小米手机销量超过苹果,成为全球第二,3我喜欢吃苹果,每天一个苹果医生远离我,4PHP是世界上最好的编程语言,适合做Web开发,5搜索引擎的核心是倒排索引和TF-IDF算法,6华为发布新手机,搭载自研芯片,7苹果树需要充足的阳光才能结出好苹果,];// 把每篇文章喂给引擎 foreach($articlesas$id$text){$engine-addDocument($id,$text);}//(可选)保存索引到文件,下次直接 loadIndex()即可 //$engine-saveIndex();// 搜索!$keyword苹果手机;echo搜索关键词: {$keyword}\n;echostr_repeat(,60).\n;$results$engine-search($keyword,5);if(empty($results)){echo没找到结果\n;}else{foreach($resultsas$i$r){echo($i1).. [文档{$r[docId]}] 得分:{$r[score]}\n;echo {$r[highlight]}\n\n;}}三、运行结果示例 搜索关键词: 苹果手机1.[文档1]得分:0.5234em苹果/em公司今天发布了新款iPhoneem手机/em,性能大幅提升2.[文档2]得分:0.4891 小米em手机/em销量超过em苹果/em,成为全球第二3.[文档3]得分:0.3102 我喜欢吃em苹果/em,每天一个em苹果/em医生远离我4.[文档6]得分:0.1876 华为发布新em手机/em,搭载自研芯片 四、关键概念回顾(大白话总结)┌─────────────────┬────────────────────────────────────────────────────────┐ │ 概念 │ 大白话 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ 倒排索引 │ 不是文档→词,而是词→哪些文档有它。像书后面的索引页│ ├─────────────────┼────────────────────────────────────────────────────────┤ │ 分词 │ 把我爱苹果切成[我,爱,苹果],中文最难,英文按空格 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ 停用词 │的、了、是这种没意义的词,不建索引省空间 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ TF(词频)│ 词在一篇文档出现越多 →文档越相关 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ IDF(逆文档频率)│ 词越罕见(只在少数文档出现)→越能区分文档 →权重越高 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ TF-IDF │ TF ×IDF,经典相关度算法,Google早期就用它 │ ├─────────────────┼────────────────────────────────────────────────────────┤ │ bigram │苹果切成苹、果、苹果,二字组合提升中文搜索准确度 │ └─────────────────┴────────────────────────────────────────────────────────┘ 五、生产环境怎么升级1. 分词 →用 jieba-php、scws 等专业分词库2. 存储 →倒排索引扔进 Redis(Hash结构)或 LevelDB3. 直接上 Elasticsearch / MeiliSearch / TypeSense,本质就是这套原理的工业版4. 加 PageRank / BM25 替代 TF-IDF(BM25 是 ES 默认算法)5. 同义词、纠错、拼音搜索 是后续扩展 把上面代码保存成 search_engine.php,php search_engine.php 就能直接跑。