站内搜索自己写:Typecho 关键词表、中文分词与结果排序

站点做到几百篇之后,搜索引擎带来的流量会趋于平稳,真正能留住人的反而是站内搜索:一个读者从搜索结果点进来,看完一篇想找同类的另一篇,第一反应是在搜索框里敲关键词。如果这时候返回「没有找到相关内容」,或者给出一堆毫不相干的结果,人就走了。

我用的是 Typecho,自带的搜索是基于 LIKE '%关键词%' 的全表扫描。文章上百篇之后,每次搜索都要扫一遍 typecho_contents 表,而且只能做最粗暴的子串匹配——搜「Nginx 缓存」和搜「缓存 Nginx」结果完全一样。这篇文章记录我把它换成「关键词表 + 分词 + 权限过滤」的完整过程,包括四个我踩过的坑。

一、先量化:自带搜索到底差在哪

动手之前我先测了一遍原生搜索。做法很简单,在搜索页的模板里加一行计时,或者直接在 SQL 层跑:

SELECT cid, title FROM typecho_contents
WHERE type = 'post' AND status = 'publish'
AND (title LIKE '%缓存%' OR text LIKE '%缓存%');

LIKE '%...%' 前置通配符无法走索引,这是硬伤,改不了。text 字段是 longtext,行数到五千时这条查询要 400 毫秒以上,而且随着文章数线性增长。更麻烦的是排序——原生实现的排序基本等于「按发布时间倒序」,搜「Docker」时最新的一篇水文排在十年前那篇真正的教程前面。

我把问题拆成四个:匹配质量(只能子串匹配,不分词,不排相关性)、性能(全表扫)、排序(没有相关性权重)、安全(拼 SQL 与通配符滥用)。后面每一步都对应解决其中一项。

二、方案选型:别一上来就上 Elasticsearch

我评估过四种方案,最后选了第三种:

方案一:MySQL 全文索引(FULLTEXT)。给 title、text 建 FULLTEXT 索引,用 MATCH ... AGAINST 查询。优点是零外部依赖,缺点是中文分词几乎不可用——MySQL 默认按空格和标点切词,中文整段会被当成一个词,除非用 WITH PARSER ngram。ngram 分词器能用,但默认 bigram 会让索引体积膨胀得厉害,而且相关性排序(MATCH 返回值)在中文上表现一般。

方案二:Elasticsearch / Meilisearch。效果最好,中文分词也成熟,但要多维护一个进程、多一份内存(ES 起步就要 1G 堆内存),还要处理数据同步的一致性。我这是一台 2C4G 的机器,跑着 Nginx、PHP-FPM、MySQL,再加 ES 直接就把内存吃满了。

方案三:自建关键词表 + 轻量分词。建一张 search_index 表,发布文章时把标题和正文切成词,存成「词 → 文章 cid → 权重」的映射。查询时把用户输入也切一遍,按词匹配,用倒排的方式拿候选集,再算相关性。索引体积小、查询走索引、完全可控。

方案四:站内搜索交给外部。比如嵌 Google 自定义搜索。问题是国内访问不稳定,而且会把读者导向站外,页面加载也多一次外部请求。排除。

选方案三的核心考虑是:站点规模在五千篇以内、单机部署、不想引入第二个状态存储。这个规模下自建倒排完全够用,而且省掉了「数据同步」这个最容易出错的部分——索引和文章在同一个库里,发布时同一个事务写入,永远不会不一致。

三、表结构与分词策略

索引表设计得比较简单,三列就够:

CREATE TABLE search_index (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  cid INT UNSIGNED NOT NULL,
  word VARCHAR(40) NOT NULL,
  weight TINYINT UNSIGNED NOT NULL DEFAULT 1,
  PRIMARY KEY (id),
  UNIQUE KEY uniq_word_cid (word, cid),
  KEY idx_word (word)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

UNIQUE (word, cid) 保证同一个词在同一篇文章里只出现一次:同一篇文章重复命中同一个词没有意义,只会让结果膨胀。weight 用来区分「标题里出现」和「正文里出现」,也可以按出现次数分级(出现 1 次记 1,标题命中记 5,出现 3 次以上记 2)。

分词是最关键的一步。我没有引入 jieba 这种级别的库(PHP 生态里也没有特别成熟的对应实现),而是用了一个务实的三段式策略:

英文与数字按非字母数字字符切分,转小写,过滤掉长度小于 2 的词和停用词(the、and、of 这类)。中文用 bigram(相邻两字组合)+ 整词双写:对「服务器缓存」切出「服务」「务器」「器缓」「缓存」,同时把连续中文段也作为一个整体词存入。也就是不追求精确分词,只看「能被搜到」。长度过滤:单个汉字不单独建索引,因为单字命中量太大,反而让结果变噪声——搜「的」返回全站文章毫无意义。

这个策略的代价是索引行数偏多。一篇文章 2000 字,bigram 大约产生 1000~1500 个唯一词,五千篇文章就是五百万到七百万行。听起来吓人,但 word 是短字符串、有索引,实际占用在几百 MB 级别,对单机来说完全可接受。

建索引的代码放在文章保存的钩子里,先删后插:

// 伪代码,Typecho 插件里挂 Widget_Contents_Post_Edit 的 finishPublish
$db->query('DELETE FROM search_index WHERE cid = ?', $cid);
$words = split_words($title . ' ' . strip_tags($text));
foreach ($words as $w => $weight) {
    $db->query('INSERT IGNORE INTO search_index (cid, word, weight) VALUES (?, ?, ?)',
              $cid, $w, $weight);
}

INSERT IGNORE 配合唯一键,天然去重,不用在 PHP 里再判一遍,省一半时间。写完记得在钩子里同时处理「文章删除」和「改回草稿」——这是第一个坑:只挂发布钩子的话,删掉的文章在索引表里留着,用户搜到一条点进去是 404。

四、查询:倒排取候选、再打分排序

用户输入先过一遍同样的分词,然后按 weight 降序取词,最多取前 6 个(取多了候选集会爆炸,取少了召回不足)。查询分两步:

-- 第一步:取候选 cid 与命中词数
SELECT cid, COUNT(*) AS hit_words, SUM(weight) AS score
FROM search_index
WHERE word IN ('缓存','服务','务器','器缓')
GROUP BY cid
ORDER BY hit_words DESC, score DESC
LIMIT 200;

ORDER BY 的第一关键字是 hit_words(命中的词数),第二个才是 score。这个顺序很重要:一篇文章命中 3 个不同的词,说明它跟查询主题相关;另一篇只命中 1 个词但这个词出现 20 次,往往是「碰巧提到」的噪声文章。

候选集拿到 200 条之后,第二步回表取标题和摘要,同时做二次过滤:只保留 status = 'publish' 且 type = 'post' 的记录。这里有个容易忽略的问题——如果直接在主查询里 join 内容表,MySQL 可能先做全表扫再过滤,倒排索引白做了。正确做法是先把候选 cid 拿来,用 WHERE cid IN (...) 一次取回,200 个 id 的 IN 查询走主键,几乎是瞬时的。

第三步是在 PHP 里做最终的排序调整。我在 score 基础上加了两个修正:一是标题命中的额外加成(标题命中比正文命中重要三倍左右),二是按文章发布时间做轻度衰减(两年前的文章乘 0.8)。衰减系数不能太狠,否则搜「Nginx 配置」时老但经典的教程会被新水文挤掉,这是我调整过两轮的参数。

五、坑一:输入清洗与通配符滥用

用户输入永远不能直接进 SQL。原生 Typecho 的搜索做过基本转义,但自己写的查询要格外小心。我的做法是三层:

第一层,用 Typecho 的 $db->quote() 或 PDO 预处理绑定参数,杜绝 SQL 注入。第二层,限制输入长度到 50 字符、过滤掉 % _ \ 这些通配符与转义字符——注意 % 在 LIKE 里是通配符,用户输入 %%%%%% 会让查询变成全表扫,这是最廉价的拒绝服务攻击。第三层,限制分词后词的数量(最多 6 个)和单个词长度(不超过 20 字节)。

另外建议加一层轻量的频率限制。搜索页是站内最容易被打的入口,因为 ?q= 参数可以让缓存完全失效。我的做法是按 IP 做「每分钟 20 次」的令牌桶,超了直接返回 429 而不是排队等待,配置见下一节。

六、坑二:缓存与结果分页

搜索查询本身不贵(倒排加 IN 查询,通常 10 毫秒级),贵的是每次都回表取摘要、每次都渲染模板。我加了两个层次的缓存。

第一层是查询结果缓存,把「分词后的词集合」做哈希当 key,结果存 Redis,TTL 一小时。相同查询直接命中,命中率在站内搜索这种长尾场景下有 30% 左右——不高,但省下的都是最慢的那些请求。

第二层是「空结果缓存」。用户经常搜一些站里根本没有的词,这类查询如果每次都走全流程,等于白白消耗资源。我给它设了更长的 TTL(六小时)和单独的计数,顺便能看出读者在找什么东西而站里没有——这其实是很好的选题来源。这个数据帮我发现了三个内容缺口,后来写的文章现在每月带来稳定流量。

分页方面有个细节:倒排结果的候选集只取前 200 条,意味着最多只能翻到第 20 页。更深的页面对用户没有价值,对爬虫更是浪费预算。我在搜索页加了 noindex, follow 的 X-Robots-Tag 头,并且从 sitemap 里排除搜索页——把搜索结果页收录进去,等于给自己制造了几万条重复内容。

七、坑三:权限与状态过滤

索引表里存的是全量文章的 cid,包括草稿、私密、回收站里的。如果查询时忘了过滤,用户搜到的标题可能是你还没写完的草稿,或者已经被删掉的文章。这个 bug 我在测试环境复现过一次:搜索「未完成」直接返回了一条草稿标题。

所以候选集回表那一步的过滤条件必须写全:type = 'post' AND status = 'publish'。如果站点有密码保护的文章或会员内容,还要加上作者与权限判断。别指望「反正数据库里查出来的就是能看的」,索引和内容是两张表,任何过滤缺失都会直接暴露。

另一个相关的坑是「文章改标题后索引没更新」。因为索引只在发布时写入,如果事后直接改数据库改了标题,索引里还是旧词,搜新标题搜不到、搜旧标题反而能搜到。我的处理是加了一个每月的校验脚本:把 title 分词后和索引表比对,发现不一致就重刷该篇。五千篇文章跑一遍不到一分钟。

八、坑四:中文搜索的相关性调参

这一步最费时间,也最没有标准答案。上线第一周我收集了 200 条真实搜索词,人工看了前 5 条结果,发现三类问题。

第一类是「短词霸屏」。搜索「配置」,命中的文章有六十多篇,排在前面的全是「配置」出现最多的——比如某篇讲 Docker Compose 配置的长文。但用户搜「配置」真正想要的可能是「Nginx 配置入门」这种主题集中的文章。解决办法是给 score 除以一个「文章长度因子」,让长文在命中词数相同时不占优势,即用「密度」而不是「绝对次数」来打分。

第二类是「一词多义」。搜索「缓存」,可能指的是浏览器缓存、CDN 缓存、OPcache、Redis 缓存,而这几篇的主题完全不同。只靠词匹配无法区分。我在结果页加了「相关分类」的引导:如果候选结果跨了三个以上的分类,就在顶部显示分类标签,让用户自己缩小范围。这比试图用算法猜意图更省事,效果也更好。

第三类是「英文缩写与全称」。搜「CDN」和搜「内容分发网络」,在索引里是完全不同的词。解决方式是在分词之外维护一张小的同义词表,把常见缩写映射到全称,查询时两边都匹配。这张表我手写了三十来条,覆盖面不大但覆盖了实际搜索量的绝大部分。

九、效果与收尾

替换完成之后,站内搜索的平均响应时间从 400 毫秒以上降到 20 毫秒以内,P95 在 40 毫秒。更重要的指标是「搜索后点击率」——从搜索页跳到具体文章的比例,改造前是 18%,改造后升到 41%。这个数字比响应时间更能说明问题:读者能搜到想要的东西了。

最后提醒一点,自建搜索的维护成本主要不在代码,在「增量更新」。新文章发布要建索引、改文章要重建、删文章要清索引,这三个动作必须挂在同一套钩子里,漏掉一个就会慢慢积累脏数据。我现在的做法是每周跑一次全量对账脚本,把索引表和内容表的 cid 集合求差集,多出来的删掉、缺的重建,五分钟跑完,作为兜底。

Last modification:November 12th, 2026 at 10:24 pm

Leave a Comment