Hotdry.

Article

纯PHP三元组倒排索引:内存受限环境下的全文搜索实现

解析 php-fts 引擎如何用纯 PHP 构建倒排索引,探讨三元组分词策略与 BM25 评分机制在资源受限环境下的工程化实践。

2026-05-06systems

在 Web 开发领域,引入 Elasticsearch 或 Meilisearch 等专用搜索服务意味着额外的运维开销与基础设施成本。对于部署在共享主机、小型 VPS 或追求极致轻量化的项目而言,引入一个独立搜索服务往往不切实际。php-fts 作为一个纯 PHP 实现的无依赖全文搜索引擎,提供了另一种思路:基于三元组(Trigram)的倒排索引构建,配合 BM25 与 IDF 机制实现相关性排名,整个系统仅依赖文件系统存储,无需任何 PHP 扩展。

三元组分词策略与倒排索引构建

传统中文全文搜索通常采用词元(Token)分词方式,需要内置词典或复杂的分词算法。php-fts 选择了三元组索引方案:将文本切分为重叠的三个字符窗口,例如单词 "leather" 会被分解为 "le"、"ele"、"ath" 等多个三元组。这种做法的核心优势在于无需词典即可实现模糊匹配与容错搜索,用户输入拼写错误时仍能通过部分三元组匹配找到目标文档。

倒排索引的结构设计直接决定了搜索性能的上限。php-fts 采用了四个核心文件组织索引数据:documents.bin 存储序列化后的原始文档;trigrams.bin 是一个固定大小的三元组索引表,包含 37³ 共 50653 个条目,每个条目对应一个三元组在 postings.bin 中的位置偏移,实现 O (1) 的常数时间查找;postings.bin 保存每个三元组对应的文档 ID 列表;tombstones.bin 记录已软删除的文档 ID,在压缩时清理。这种固定大小的索引结构意味着,无论文档集如何增长,三元组查找的开销始终可控,避免了树形结构遍历带来的性能抖动。

在索引构建阶段,引擎会对每个待索引字段提取三元组,将文档 ID 追加到对应三元组的 posting 列表中。查询阶段则执行相反的操作:先将搜索词分解为三元组集合,然后获取每个三元组对应的文档 ID 列表,最后通过集合交集运算筛选出同时包含所有查询三元组的候选文档。这一过程无需加载整个索引到内存,而是通过文件指针跳转实现按需读取。

BM25 与 IDF 评分机制

仅找到包含查询词的文档远远不够,还需要根据相关性排序返回结果。php-fts 实现了信息检索领域标准的 BM25 算法作为核心评分机制。BM25 的核心思想有两点:其一,词频饱和(Term Frequency Saturation)—— 一个词在文档中出现 10 次并不比出现 3 次的权重高 10 倍,引擎通过参数 k1 = 1.5 控制饱和曲线;其二,文档长度归一化 —— 较短文档中出现的词项比长文档中出现的同词项更具代表性,参数 b = 0.75 用于平衡这一偏差。BM25 的公式本身并不复杂,但其参数选择直接影响了排序效果的细腻程度,php-fts 采用的 k1 = 1.5 与 b = 0.75 正是 Apache Lucene 与 Elasticsearch 的默认配置。

除了 BM25,引擎还引入了 IDF(逆文档频率)机制。在倒排索引中,某个三元组出现在几乎所有文档中时,其区分度极低,对排名的贡献应该被削弱;反之,仅在少数文档中出现的稀有三元组具有更强的指示意义。IDF 通过计算包含某个三元组的文档数量在总文档数中的占比来赋予不同权重。最终得分会将 BM25 与 IDF 的计算结果加权求和,并归一化到 0 至 100 的区间,方便开发者直接基于分数构建分面统计、相关性阈值过滤或自定义排序逻辑。

字段权重(Boost)是另一个影响排序的关键因素。开发者可以为不同字段赋予不同的 boost 值,例如将 title 字段的 boost 设为 3.0,description 设为 1.0,使得标题中匹配到的词项对最终分数的贡献更大。这种机制在实际业务中尤为实用,例如电商搜索场景下,商品名称的匹配权重通常远高于商品描述。

内存受限环境下的工程化实践

纯 PHP 实现意味着无法利用 C 扩展的高效内存操作,所有数据读写必须通过 PHP 的文件函数完成。在这种约束下,php-fts 的设计哲学体现了几个关键的工程化考量。首先是批量写入优化:单文档插入需要频繁加锁与文件指针重定位,而 bulk 插入模式下整个批次共享一次锁,官方测试数据显示批量插入速度可达单条插入的 2.4 倍。其次是软删除机制:执行删除操作时,文档不会立即从索引中移除,而是标记到 tombstones.bin,避免频繁的磁盘数据迁移;只有在碎片化率超过 20% 时才触发压缩操作(compact),重建索引文件并彻底清理已删除数据。

存储空间方面,10000 个文档的索引文件约为 21.7 MB,平均每个文档仅占用约 2 KB 左右的索引开销。搜索延迟在 Linux 共享主机环境下测试表现为中位数 3.2 毫秒、P95 为 12.5 毫秒,这一性能对于数百到数万量级的文档集合来说已经足够满足大多数 Web 应用的实时搜索需求。需要注意的是,插入操作被设计为离线任务,适合通过定时任务批量执行,而非在请求路径中实时索引。

从技术选型的角度看,php-fts 适合的场景边界清晰:它不适用于需要实时索引更新、海量文档(百万级以上)或复杂地理空间搜索的业务;但对于共享主机环境、极简技术栈追求、文档量在数百到数万区间的产品搜索、文档检索或管理员后台过滤等场景,它提供了一个零依赖、零运维的可行方案。

资料来源:php-fts 官方仓库(https://github.com/olivier-ls/php-fts)与 Trigram 索引原理(https://www.cockroachlabs.com/blog/use-cases-trigram-indexes/)。

systems