PAYATHON 2026

PHP 使用 preg_match 或 str_replace 匹配最佳同义词

支付小周

结论

把每组同义词中的第一个词或短语作为匹配词,按长度从长到短排列并组成一个正则表达式,再用一次 preg_replace_callback() 完成所有替换。

需要注意两点:

  • be aware of 必须排在 be aware、aware of 和 aware 前面。
  • 整个句子只能扫描一次。不能逐条调用 str_replace() 或 preg_replace(),否则后续规则还会处理新生成的 {...} 内容。

实现代码

<?php

$synonyms = [
    'a company|an organization|a business',
    'auto|automobile|car|vehicle',
    'aware|conscious|mindful|informed|knowledgeable',
    'aware of|conscious of|mindful of',
    'be aware|bear in mind|remember',
    'be aware of|concentrate on|pay attention to|know about|be familiar with',
    'carefully|cautiously|meticulously|very carefully|thoroughly|properly',
    'cases|instances|circumstances|situations|scenarios|conditions',
    'comes|arrives|will come|happens',
    'company|business|organization|firm|corporation|provider',
    'coverage|protection',
    'in most|in many',
    'in most cases|generally|usually|normally|typically|most often',
    'in the|within the|inside the|while in the|from the|during the',
    'increases|raises|will increase|boosts|improves',
    'information|info|data|details|facts|information and facts',
    'insurance|insurance coverage|insurance policy|insurance plan|insurance policies',
    'on the|around the|within the|to the|about the|over the',
    'once|as soon as|when|after|the moment|at the time',
    'once you|when you|as soon as you|after you|when you finally',
    'or a|or perhaps a|or possibly a|or even a|or maybe a|or simply a',
    'package|package deal|bundle|deal|offer',
    'payment|fee|cost',
    'policy|coverage|plan',
    'premium|top quality|high quality',
    'question|query|concern|issue|problem',
    'rates|prices|charges|premiums|costs|fees',
    'receive|get|obtain|acquire|be given',
    'receive a|get a',
    'representative|consultant',
    'review|evaluation|assessment|critique|overview|evaluate',
    'the new|the brand new|the newest|the modern',
    'time to|time for you to',
    'with your|together with your|along with your|with the',
    "you have|you've|you've got|you might have|you may have|you have got",
];

$sentence = "When it comes time to renew your auto insurance policy,  be aware of  how your carrier handles renewals. In most cases, you receive a renewal package in the mail with your new policy and rates. Review this information carefully, and question any increases in premium with your agent or a company representative. In most cases, once you submit a payment on the new policy, you have renewed your coverage.";

/*
 * 生成规则表:
 *
 * [
 *     'be aware of' => [
 *         'be aware of',
 *         'concentrate on',
 *         ...
 *     ],
 * ]
 */
$rules = [];

foreach ($synonyms as $row) {
    $terms = array_map('trim', explode('|', $row));
    $terms = array_values(array_filter(
        $terms,
        static fn(string $term): bool => $term !== ''
    ));

    if ($terms === []) {
        continue;
    }

    $search = strtolower($terms[0]);
    $rules[$search] = $terms;
}

/*
 * 期望输出中没有把 “a company” 作为整体替换,
 * 所以必须明确禁用这条规则。
 *
 * 如果希望得到 {a company|an organization|a business},
 * 删除下面这一行即可。
 */
unset($rules['a company']);

/*
 * 较长短语必须出现在正则表达式前面。
 * PCRE 的分支匹配在同一个起始位置按从左到右选择。
 */
$searches = array_keys($rules);

usort(
    $searches,
    static fn(string $a, string $b): int =>
        strlen($b) <=> strlen($a)
);

/*
 * preg_quote() 防止搜索短语中的正则特殊字符被误解析。
 * 把普通空格改为 \s+,允许短语内部出现一个或多个空白字符。
 */
$alternatives = array_map(
    static function (string $search): string {
        $quoted = preg_quote($search, '~');

        return str_replace(' ', '\s+', $quoted);
    },
    $searches
);

/*
 * 自定义单词边界比 \b 更明确:
 * 匹配内容前后不能直接连接字母、数字或下划线。
 *
 * 这样 “once” 不会错误匹配 “concern” 中间的字符。
 */
$pattern = '~(?<![\p{L}\p{N}_])(?:'
    . implode('|', $alternatives)
    . ')(?![\p{L}\p{N}_])~iu';

$output = preg_replace_callback(
    $pattern,
    static function (array $match) use ($rules): string {
        $matchedText = $match[0];

        /*
         * 正则允许 \s+,所以查表前要把连续空白标准化。
         */
        $key = strtolower(
            preg_replace('/\s+/u', ' ', trim($matchedText))
        );

        $replacements = $rules[$key];

        /*
         * 原短语以大写字母开头时,将所有候选项的首字母大写。
         * 例如:
         *
         * In most cases
         * =>
         * {In most cases|Generally|Usually|Normally|Typically|Most often}
         */
        if (preg_match('/^\p{Lu}/u', $matchedText) === 1) {
            $replacements = array_map(
                static fn(string $term): string => ucfirst($term),
                $replacements
            );
        }

        return '{' . implode('|', $replacements) . '}';
    },
    $sentence
);

if ($output === null) {
    throw new RuntimeException(
        'Regular expression failed: ' . preg_last_error_msg()
    );
}

echo $output;

为什么不会发生嵌套替换

preg_replace_callback() 只用一个正则表达式扫描原始 $sentence。回调返回的内容会直接写入结果,不会再次交给同一个正则表达式匹配。

例如:

insurance

替换为:

{insurance|insurance coverage|insurance policy|insurance plan|insurance policies}

其中的 coverage、policy 等内容不会再被处理,因此不会出现下面这样的嵌套结果:

insurance {coverage|protection}

逐条替换则会重复处理已经插入的内容:

foreach ($rules as $search => $replacement) {
    $output = str_replace($search, $replacement, $output);
}

即使提前按长度排序,后续循环仍会扫描并替换前面循环插入的同义词。

最长短语为何能优先匹配

排序后,正则表达式中的相关部分相当于:

be aware of|be aware|aware of|aware

扫描到 be aware of 时,PCRE 会先尝试第一个分支,所以完整短语会被整体替换为:

{be aware of|concentrate on|pay attention to|know about|be familiar with}

同样:

in most cases

会优先于:

in most
cases

而:

receive a

会优先于:

receive

这种排序解决的是匹配起始位置相同时的候选冲突。正则表达式仍会从句子中最靠前的位置开始匹配。例如,在 on the new 中,正则会先从 on 开始匹配 on the。匹配完成后只剩 new,无法再从中间的 the 匹配 the new。

规则冲突需要明确处理

现有规则包含:

a company|an organization|a business

但期望结果是:

a {company|business|organization|firm|corporation|provider}

两者存在冲突。只要启用 a company 规则,按照最长短语优先的原则,结果就应该是:

{a company|an organization|a business} representative

如果必须得到期望结果,就要像示例代码那样禁用这条规则:

unset($rules['a company']);

实际项目中,更稳妥的做法是在数据结构中为规则增加 enabled、priority 或上下文条件,不能只依靠字符串长度。长度排序可以处理短语重叠,却无法决定业务上应该启用哪组同义词。

备注:内容仅供参考。