Search by

vergil-lai / lexsift

vergil

PHP extension for fast keyword and phrase matching using Aho–Corasick.

Package info

github.com/vergil-lai/lexsift

Language:Rust

Type:php-ext

Ext name:ext-lexsift

pkg:composer/vergil-lai/lexsift

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.1.0 2026-09-22 21:38 UTC

This package is auto-updated.

Last update: 2026-09-23 03:29:55 UTC


README

LexSift 是使用 Rust 和 ext-php-rs 实现的 PHP 扩展,以 Rust aho-corasick 为后端,适用于敏感词检测、关键词检索和文本替换。支持全角与半角转换、大小写统一、忽略空白等文本处理,并提供重叠匹配、短语白名单和原文脱敏。

通过 LexSift\Matcher 创建独立的匹配器实例。词库在构造或替换时编译,后续查询直接复用。

环境要求

  • PHP 8.1 或以上,以及与目标 PHP 匹配的开发头文件、phpizephp-config
  • Rust stable 工具链和 Cargo,需支持 Rust 2024 edition;建议使用当前 stable。
  • C 编译器、Clang/libclang、make、autoconf 和平台开发工具。
  • 通过 PIE 安装时还需 PIE,并满足 PIE 自身的运行要求。

Linux 通常需要发行版的 PHP 开发包;macOS 需要 Xcode Command Line Tools 和包含开发工具的 PHP 安装。phpphpizephp-config 必须对应同一套 PHP。源码目录及其父目录不能含空格,这是本项目使用的 phpize/configure 构建路径的限制。

安装

PIE

使用 PIE 安装并检查扩展:

pie install vergil-lai/lexsift
php --ri lexsift

应用可在 composer.json 中声明 "ext-lexsift": "*"。本仓库的 Composer 包仅用于 PIE 安装,不包含 PHP 用户态实现。IDE 类型声明见 stub

Docker(install-php-extensions)

在自己的官方 PHP 镜像 Dockerfile 中,安装好 Rust/Cargo 和 Clang/libclang 后,添加:

COPY --from=ghcr.io/mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions "vergil-lai/lexsift@<commit-or-tag>"

<commit-or-tag> 替换为包含 package.xml 的提交或版本标签。安装后会自动启用扩展。当前需使用这种源码安装方式,暂不支持直接运行 install-php-extensions lexsift

完整的依赖安装和多阶段构建示例见 Dockerfile。如需从本地源码构建验证镜像:

docker build -f docker/Dockerfile -t lexsift-php .
docker run --rm lexsift-php php --ri lexsift

源码构建

在不含空格的仓库目录执行:

sh scripts/build.sh
php -n -d extension="$PWD/target/php-build/modules/lexsift.so" --ri lexsift

选择另一套 PHP 开发工具时:

PHP_CONFIG=/path/to/php-config PHPIZE=/path/to/phpize sh scripts/build.sh

构建产物位于 target/php-build/modules/lexsift.so。如需持久启用,可执行 make -C target/php-build install,在目标 PHP 的配置中添加 extension=lexsift.so,并重启对应服务。

使用示例

以下示例展示匹配、脱敏和更新词库:

<?php
declare(strict_types=1);

$filter = new LexSift\Matcher(
    terms: ['赌博', '博彩', 'bad word', '微信'],
    whitelist: ['合法博彩说明', '微信支付'],
    options: ['lowercase' => true, 'remove_emoji' => true],
);

$text = '前赌 博后,微信支付';
var_dump($filter->contains($text));
print_r($filter->scan($text));
echo $filter->mask($text), "\n";

$filter->replaceTerms(['新的词语', '另一个词语']);
$filter->replaceWhitelist(['允许出现的完整短语']);
var_dump($filter->contains('新的词语'));

scan()赌 博 的命中如下;空格属于原文匹配范围:

[
    'term' => '赌博',
    'text' => '赌 博',
    'start' => 3,
    'end' => 10,
]

API 与输入约定

方法 行为
__construct(array $terms, array $whitelist = [], array $options = []) 构建独立实例;未指定的选项使用默认值
contains(string $text): bool 找到首个未被白名单排除的匹配即停止匹配迭代
scan(string $text): array 返回所有有效重叠匹配
mask(string $text, string $replacement = '*'): string 合并有效原文范围后替换
replaceTerms(array $terms): void 完整替换当前实例词库
replaceWhitelist(array $whitelist): void 完整替换当前实例白名单

词库与白名单只接受字符串值,数组键不参与匹配,顺序采用 PHP 数组遍历顺序。空数组合法;空字符串及经过文本处理后为空的词抛出 ValueError。原始重复词和处理后相同的词均保留首次出现者,包括返回的原始 term。替换操作成功后立即生效,失败时保留旧状态,不影响其他实例。

所有文本入口,包括词库、白名单、正文和 replacement,必须为合法 UTF-8;损坏字节抛出 ValueError,不会被静默修复。类型错误抛出 TypeError;未知或非法 options 键抛出 ValueError。不提供模糊匹配、拼音、词干分析、持久化或全局词库。

文本处理选项

仅接受以下六个布尔选项;可只传其中部分,01 或字符串不代替布尔值。

选项 默认值 行为
unicode_nfkc true 统一字符形式(Unicode NFKC),包括全角转换、字符展开与组合
lowercase true Unicode 逐码点小写转换
remove_whitespace true 移除 Unicode 空白
remove_punctuation false 移除 Unicode 标点类别字符
remove_symbols false 移除 Unicode 符号类别字符
remove_emoji true 按原始 extended grapheme cluster 整簇移除 emoji

词库、白名单和正文始终使用同一套文本处理规则。这些处理只用于匹配,返回的原词、命中文本和未命中的原文不会被改写。

小写使用 Rust char::to_lowercase(),允许一字符展开为多字符;不执行 Unicode case folding、语言环境相关转换或希腊 final sigma 等上下文映射,也不删除变音符。

Emoji 使用宽泛规则:原始 grapheme 含 Extended_Pictographic、Emoji_Presentation、Emoji_Modifier、Regional_Indicator、VS16 或 keycap enclosing mark 时整簇删除,涵盖 ZWJ、肤色、旗帜及 keycap。普通数字、#* 保留;©©︎©️ 均会被删除。这不等同于仅删除 RGI emoji;需要保留这些符号时设置 remove_emoji: false

白名单与字节偏移

白名单是普通字符串短语。经过文本处理后,敏感词匹配范围完整包含于某一个白名单匹配范围时才被忽略;部分重叠不豁免,多个白名单范围也不会联合形成豁免。例如 微信支付 可豁免其中的 微信,但白名单 f 不会豁免原文 经 NFKC 展开后的敏感词 i

scan() 返回普通数组,每项只有 termtextstartendterm 为词库原词,text 为命中的原文切片。偏移为原始 UTF-8 字符串的字节偏移,采用 [start, end),保证 substr($text, $start, $end - $start) 等于匹配项的 text,不是字符序号。

支持重叠,按 start 升序、同起点较长范围优先排列;相同范围的不同词按词库顺序排列。同一词映射到同一原文范围时只返回一次。位置映射覆盖完整来源 grapheme,因此组合字符不会被从中截断;跨越被移除字符时,这些内部字符也包含在匹配跨度中。独立的边缘被移除字符不会被扩大包含。

mask() 先排除白名单,再合并原文字节空间中重叠或相邻的有效范围,每个合并范围替换为一次 replacement,不按字符数重复 replacement。空 replacement 表示删除,多字符 replacement 合法。替换不会修改未匹配原文,也不会因前面的替换导致后续偏移失效。

验证与 benchmark

运行 Rust 检查、PHP 集成测试与基准测试:

cargo fmt --all -- --check
cargo clippy --no-default-features --all-targets -- -D warnings
cargo test --no-default-features --locked
cargo clippy --features php --lib -- -D warnings
sh scripts/build.sh
COMPOSER=$(php -r 'echo PHP_VERSION_ID < 80200 ? "composer-php81.json" : (PHP_VERSION_ID >= 80500 ? "composer-php85.json" : "composer.json");') \
  composer install --working-dir=tests/php
sh scripts/test-php.sh
php -n -d extension="$PWD/target/php-build/modules/lexsift.so" benchmarks/run.php
sh scripts/test-pie.sh

Benchmark 覆盖 100、1,000、10,000 词,短/长文本,无命中、首部/尾部命中及大量重叠,分别测量构建、contains()scan()mask()。可追加 --iterations=100 调整迭代数;运行结果以 JSON 输出。contains() 仍需完成输入校验;无白名单时可流式处理,在首个有效命中后提前结束匹配,有白名单时则需全文归一化和白名单定位。

平台支持

平台 支持情况
Linux 支持源码构建与 PIE
macOS 支持源码构建与 PIE
Windows 暂不支持

Windows 需要与 PHP ABI、架构及线程安全模式匹配的预构建 DLL;本项目未提供 Windows 构建产物,PIE 元数据仅声明 Linux 和 macOS。

php --ri lexsift 可查看扩展版本、Aho–Corasick 后端、Unicode 实现与数据版本。

纯 PHP 版本

如果不便安装原生扩展,可以使用 LexSift PHP。它以 PHP 实现 Aho–Corasick 匹配,方法、参数和返回值约定与本扩展一致。纯 PHP 版本使用 VergilLai\LexSift\Matcher,本扩展使用 LexSift\Matcher;不同环境的 Unicode 数据版本可能导致个别字符的处理结果不同。

性能对比

同一台 macOS arm64 机器上实测,PHP 8.5.4;LexSift 扩展使用 V0.1.0,LexSift PHP 使用 V0.1.0。通过 php -n 隔离系统配置并显式加载扩展,关闭 CLI OPcache 和 JIT。两个实现使用相同的 10,000 词词库、默认文本处理选项;除标明白名单的场景外,均无白名单。

每场景预热 5 次,查询取 7 组均值的中位数,构建取 5 组中位数。查询每组按约 20 ms 自动选择 10–2,000 次迭代,构建每组 1 次;查询耗时不含构建,构建耗时包含对象生命周期。按“扩展 → PHP → PHP → 扩展”串行运行两轮,下表取两轮中位数的平均值,单位为 毫秒/次。加速比为 PHP 耗时除以扩展耗时。

场景 LexSift 扩展 LexSift PHP 扩展加速比
构建 10,000 词实例 28.0201 132.3320 4.7 倍
scan():100 中文字,无命中 0.0093 0.2632 28.3 倍
scan():10,000 中文字,无命中 0.7120 17.3654 24.4 倍
scan():10,000 ASCII 字符,无命中 0.4309 2.6258 6.1 倍
scan():1,000 次命中 0.6440 15.4235 23.9 倍
contains():长中文首部命中 0.0251 11.3638 453.4 倍
contains():长中文中部命中 0.3656 14.0836 38.5 倍
contains():长中文尾部命中 0.7118 16.8322 23.6 倍
contains():长中文无命中 0.7139 16.7897 23.5 倍
contains():长 ASCII 无命中 0.3768 2.3365 6.2 倍
contains():有白名单,首部有效命中 0.7133 17.2344 24.2 倍
contains():有白名单,唯一命中被豁免 0.7276 17.7609 24.4 倍
contains():白名单豁免后,尾部有效命中 0.7131 17.7839 24.9 倍
mask():1,000 个分隔的命中 0.6265 17.3728 27.7 倍

词库为 敏感词00000敏感词09999,命中词为最后一项;中文填充字符为“文”,ASCII 为 x。长文本使用 10,000 个填充字符,首部、中部、尾部场景额外插入命中词。scan() 密集命中输入为命中词重复 1,000 次(8,000 码点、14,000 字节),mask() 场景在每个命中后加入“文”。白名单为 敏感词09999safe,用于对照有效命中、完全豁免和豁免后再次命中。

约 453 倍仅对应长中文首部命中,不能代表整体性能。 测试版本中,扩展在无白名单时可以流式处理并提前结束匹配;PHP 的非 ASCII 路径则先收集全文字符并完成 Unicode 组合,之后才开始匹配。两边实际处理的工作量不同。有白名单时,两边均需全文归一化;本次全文扫描的差距约为 6–28 倍。另一次长度对照中,首部命中后追加 100、1,000、10,000、30,000 个中文字,差距分别约为 19、137、472、586 倍,也说明该比值随文本长度变化。

本次对性能场景及全角、零宽字符、组合字符、白名单输入进行的 57 项 contains()scan()mask() 返回值对照均一致。两边均返回字节偏移,并对每个合并命中范围替换一次,因此表中的扫描和替换采用相同语义。这是固定合成输入的本机测试,未覆盖生产语料、内存、并发吞吐或开启 JIT 后的表现,不代表所有 Unicode 输入等价,也不是业务请求整体加速承诺。

协议

MIT