WordPress 搜索系统重构:利用 PHP 驱动的 Meilisearch 实现毫秒级语义检索

WordPress 搜索系统重构:利用 PHP 驱动的 Meilisearch 实现毫秒级语义检索

各位码农、前端仔、以及所有被 LIKE '%...%' 折磨到脱发的后端工程师们,大家好。

今天我们要聊的,是 WordPress 搜索。这个话题就像你在旧式洗衣机的桶里找一只掉落的袜子一样,既尴尬,又令人抓狂。

众所周知,WordPress 的默认搜索机制是基于 SQL 的 LIKE 查询。这就像是你把一把钥匙扔进了马里亚纳海沟,然后试图用一根牙签把它捞上来。当你输入“猫咪视频”时,数据库会去扫描每一行数据,看看里面有没有“猫”、“视频”、“汪汪叫”。如果数据库表里只有一篇叫《如何用胶水粘鞋》的文章,它依然会因为标题里有个“用”字而给你返回结果。

更糟糕的是,默认搜索不懂语义。它不懂“车”和“汽车”是一样的,也不懂“设计师”和“UI工程师”可能指的是同一个人。它只认字面,像个只读死书的文盲。

但是,朋友们,这个世界正在向“语义搜索”进化。我们不想再给用户展示一堆不相关的垃圾了。我们需要的是:能听懂人话的搜索引擎

为了实现这个梦想,我们要引入一位新朋友:Meilisearch。这是一个基于 Rust 编写的、像闪电一样快的搜索引擎,它是 OpenSearch 和 Elasticsearch 的平替,但它更亲民、更性感。

今天,我就要带大家手把手,用 PHP 给 WordPress 重构一个“拥有灵魂”的搜索系统。


第一幕:SQL LIKE 的悲喜剧

在动手之前,我们先来吐槽一下现在的搜索有多烂。

当你有一篇 5000 字的文章,用户想找“性能优化”,默认搜索的 SQL 查询大概长这样:

SELECT * FROM wp_posts 
WHERE post_status = 'publish' 
AND post_type IN ('post', 'page') 
AND (post_title LIKE '%性能%' OR post_content LIKE '%性能%' OR post_excerpt LIKE '%性能%');

听听这 SQL,多么朴实无华,又多么枯燥乏味!

  1. 性能灾难LIKE '%keyword%' 是全表扫描(全表扫描!),除非你有 MySQL 的 FULLTEXT 索引,否则这就是一场在泥潭里的赛跑。对于 10 万篇帖子,这大概需要 3 秒。对于 100 万篇?你可能得喝完这杯咖啡再等个半小时。
  2. 相关性糟糕:它不知道哪个词更重要。它只是单纯地告诉你“有这词吗?有就给我”。它不懂“概念”。
  3. 结果排序乱来:它经常把标题里带有关键词的文章排在最前面,但这不代表那篇文章最相关。

Meilisearch 就是来解决这些痛点的。它把索引剥离出数据库,使用内存和磁盘混合存储,支持毫秒级检索。


第二幕:搭建舞台(Docker & Composer)

咱们不搞虚的,直接开干。Meilisearch 的官方推荐部署方式是 Docker,这完美契合了我们不想在服务器上安装一堆乱七八糟依赖的心态。

首先,确保你的 PHP 版本在 7.4 以上(或者 8.0+,因为我们现代 PHP 社区太香了)。安装 Meilisearch PHP Client:

composer require meilisearch/meilisearch-php

接下来,我们需要启动 Meilisearch 服务。这里我们用一个简单的 docker-compose.yml 文件,假装我们在搭建一个微服务架构(虽然它只是个搜索库)。

version: '3.8'
services:
  meilisearch:
    image: getmeili/meilisearch:v1.2
    ports:
      - "7700:7700"
    environment:
      - MEILI_MASTER_KEY=superSecretKey123
      - MEILI_ENV=development
    volumes:
      - ./meili_data:/meili_data

运行 docker-compose up -d。现在,你的搜索引擎已经睡在 Docker 容器里了,它很安静,只需要一杯咖啡就能醒来。


第三幕:连接灵魂(PHP 集成与初始化)

好了,现在让我们在 WordPress 里写代码。我们需要创建一个专门的文件来处理这个搜索逻辑。假设文件名是 class-meili-search.php

首先,我们要创建一个客户端实例。记住,你的代码要优雅,不要到处 new Client()

<?php
// class-meili-search.php

class MeiliSearchWrapper {
    private $client;

    public function __construct() {
        // 这里的地址和 Key 对应 docker-compose 里的配置
        $meili_url = 'http://meilisearch:7700';
        $meili_key = 'superSecretKey123';

        try {
            $this->client = new MeilisearchClient($meili_url, $meili_key);
            $this->initIndex();
        } catch (Exception $e) {
            error_log("Meilisearch connection failed: " . $e->getMessage());
        }
    }

    // 检查索引是否存在,不存在就创建一个
    private function initIndex() {
        // 我们用 'wp_posts' 作为索引名,虽然有点偷懒,但方便管理
        $index = $this->client->index('wp_posts');

        // 配置一下索引设置,比如设置可搜索字段
        $index->updateSettings([
            'filterableAttributes' => ['post_type', 'post_status', 'date'],
            'sortableAttributes'   => ['date'],
            'displayedAttributes'  => ['title', 'excerpt', 'content'],
            'searchableAttributes'  => ['title', 'content'],
        ]);
    }
}

你看,几行代码,搜索引擎的核心服务就起来了。这就像你在乐高积木里随便挑了几块,居然拼成了一个火箭。


第四幕:数据同步(这是最累人的活)

Meilisearch 不会自动从 WordPress 读数据。它需要你喂给它数据。这就好比你开了一家餐厅(WordPress),你不会指望客人自己去厨房买菜做饭(Meilisearch)。

我们需要一个数据同步机制。最简单的方案是使用 WordPress 的 Cron 作业(定时任务),或者使用像 WP-CLI 这样的工具。

假设我们做一个 sync_posts 的函数。

    // ... 在类中添加

    public function syncAllPosts() {
        $args = [
            'post_type'      => 'post', // 你可以搜 page, product 等
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'fields'         => 'ids', // 只拿 ID,性能更好
        ];

        $query = new WP_Query($args);

        // 获取 Meilisearch 的索引
        $index = $this->client->index('wp_posts');

        $documents = [];

        foreach ($query->posts as $post_id) {
            $post = get_post($post_id);

            // 转换 WordPress 对象到 Meilisearch 文档对象
            $document = [
                'id'        => $post->ID,
                'title'     => $post->post_title,
                'content'   => $post->post_content,
                'excerpt'   => $post->post_excerpt,
                'slug'      => $post->post_name,
                'post_type' => $post->post_type,
                'date'      => $post->post_date,
            ];

            $documents[] = $document;
        }

        // 批量添加文档。Meilisearch 非常擅长处理批量操作
        // 注意:这里没有设置 filterableAttributes 的话,需要动态映射,或者先 updateSettings
        $index->addDocuments($documents);
    }

优化建议:
每次添加文章都全量同步是很慢的。更好的做法是只同步新增或修改的。我们可以监听 save_post 钩子,但这会导致高并发下的竞争。更稳健的做法是:

  1. 使用 transition_post_status 钩子(文章发布或草稿变更为发布时)。
  2. 或者,定期跑一个后台任务。
// 监听文章状态变更
add_action('transition_post_status', function($new_status, $old_status, $post) {
    if ($new_status === 'publish' && $old_status !== 'publish') {
        $meili = new MeiliSearchWrapper();
        $meili->syncSinglePost($post->ID);
    }
}, 10, 3);

第五幕:搜索的艺术(Query 构建)

现在,数据已经在 Meilisearch 里了。当我们点击搜索按钮,PHP 就需要把请求转发给 Meilisearch,然后把你想要的结果还给你。

让我们写一个专门处理搜索请求的函数。

    public function search($query, $page = 1, $perPage = 10) {
        $index = $this->client->index('wp_posts');

        // Meilisearch 的搜索配置
        $searchParams = [
            'q'        => $query,
            'limit'    => $perPage,
            'offset'   => ($page - 1) * $perPage,
            'rankingRules' => [
                'words',           // 关键词匹配
                'typo',            // 拼写纠错
                'proximity',       // 词组邻近度
                'attribute',       // 字段权重
                'sort',            // 排序
                'exactness'        // 精确度
            ]
        ];

        // 如果我们需要按日期排序
        if (!empty($_GET['sort'])) {
            $searchParams['sort'] = ['date:desc'];
        }

        $results = $index->search($query, $searchParams);

        return $results;
    }

高级技巧:多条件过滤

WordPress 的搜索通常伴随着筛选条件。比如:“搜索关于‘设计’的文章,且必须是‘新闻’类型,且发布日期在 2023 年之后”。

Meilisearch 对此支持得非常完美。你可以直接传入 filter 参数,它是一个字符串数组。

    public function searchWithFilters($query, $filters = []) {
        $index = $this->client->index('wp_posts');

        // 构建 filter 字符串
        // Meilisearch 的 filter 语法有点像 SQL
        $filterString = '';
        foreach ($filters as $key => $value) {
            // 注意:需要确保这些字段在 initIndex 的 filterableAttributes 里定义过
            $filterString .= "{$key} = '{$value}' AND ";
        }
        $filterString = rtrim($filterString, ' AND ');

        $results = $index->search($query, [
            'filter' => $filterString,
            'limit'  => 20
        ]);

        return $results;
    }

举个例子:

$filters = [
    'post_type' => 'post',
    'post_status' => 'publish'
];
$searchResults = $meili->searchWithFilters('React 框架', $filters);

第六幕:语义搜索的魔法(向量 Embeddings)

好了,兄弟们,这才是今天的重头戏。传统的关键词搜索是“基于字符的”,而 Meilisearch 从 v1.0 开始支持“向量搜索”。

这就好比:关键词搜索是“找照片里有没有猫”,而向量搜索是“找照片里有没有那只橘猫”。

如何实现?我们需要用到 OpenAI 的 Embeddings API(或者本地模型如 Transformers.js)。

  1. 步骤 1:把用户的查询词(比如“如何使用 CSS Grid”)喂给 OpenAI,让它变成一串数字(向量)。
  2. 步骤 2:把数据库里的文章标题和内容也喂给 OpenAI,变成向量。
  3. 步骤 3:用数学公式计算这两个向量之间的“距离”(比如余弦相似度),距离越近,越相关。

Meilisearch 支持直接搜索向量!你只需要在 updateSettings 里开启向量支持。

    public function enableVectorSearch() {
        $index = $this->client->index('wp_posts');

        $index->updateSettings([
            // 启用向量搜索
            'embedders' => [
                'default' => [
                    'source' => 'openai',
                    'model'  => 'text-embedding-ada-002', // 或者其他模型
                    'apiKey' => 'sk-xxxxx', // 你的 OpenAI Key
                    'dimensions' => 1536 // 输出向量的维度
                ]
            ],
            // 默认使用向量作为排名依据
            'rankingRules' => [
                'words', 'typo', 'proximity', 'attribute', 'sort', 'exactness', 'vector' // 最后一个是向量
            ],
            // 可选:如果向量相似度不够,回退到关键词搜索
            'distinct' => 'id'
        ]);
    }

注意: 实际上,Meilisearch 现在推荐的做法是,你在更新设置的时候,同时把每篇文章的向量字段也一起更新进去。

    public function addVectorsToDocument($document, $content) {
        // 调用 OpenAI API 获取向量
        $embeddings = $this->getEmbeddings($content);

        $document['vector'] = $embeddings;
        return $document;
    }

现在的效果是:用户输入“UI 设计教程”,Meilisearch 不仅能找到包含“UI”和“设计”的文章,还能找到语义上最接近的文章。即使那篇文章标题里没有“UI”二字,只要它是关于“界面美学的”,它也能排在第一位。


第七幕:前端 AJAX 的狂欢

如果我们在 WordPress 的搜索表单里直接用 WP_Query,页面就会刷新。这太老土了,用户讨厌页面闪烁。

我们要用 AJAX。WordPress 自带的 REST API 就是最好的桥梁。

1. 后端:创建搜索端点

functions.php 或者插件里注册一个 API 端点。

add_action('rest_api_init', function () {
    register_rest_route('meili-search/v1', '/search', array(
        'methods'  => 'GET',
        'callback' => 'handle_search_request',
    ));
});

function handle_search_request($request) {
    $query = $request->get_param('q');
    $page = $request->get_param('page');

    $meili = new MeiliSearchWrapper();
    $results = $meili->search($query, $page);

    return rest_ensure_response($results);
}

2. 前端:无刷新体验

在主题的 header.php 或者专门的 JS 文件里,监听输入框的 input 事件(或者点击按钮时)。

document.addEventListener('DOMContentLoaded', function() {
    const searchInput = document.querySelector('#search-input');
    const resultsContainer = document.querySelector('#search-results');

    searchInput.addEventListener('input', function(e) {
        const query = e.target.value;

        if (query.length < 2) {
            resultsContainer.innerHTML = '';
            return;
        }

        // 使用 fetch 调用我们的 API
        fetch(`/wp-json/meili-search/v1/search?q=${encodeURIComponent(query)}&page=1`)
            .then(response => response.json())
            .then(data => {
                renderResults(data);
            })
            .catch(error => console.error('Error:', error));
    });

    function renderResults(data) {
        let html = '';
        if (data.hits.length > 0) {
            data.hits.forEach(hit => {
                html += `
                    <div class="search-result-item">
                        <h3><a href="${hit.url}">${hit.title}</a></h3>
                        <p>${hit.excerpt}</p>
                    </div>
                `;
            });
        } else {
            html = '<p>没有找到相关结果。</p>';
        }
        resultsContainer.innerHTML = html;
    }
});

看!这是多么流畅的体验。用户还没敲完字,结果就已经跳出来了。这就是现代 Web 的节奏。


第八幕:优化与陷阱(专家级避坑指南)

虽然 Meilisearch 很强,但作为一个 PHP 开发者,你必须小心驶得万年船。

1. 字符集与分词(Tokenizer)
Meilisearch 默认使用 Unicode 分词。这对于英文没问题,但对于中文、日文、韩文(CJK 语言),默认的空格分词会失效。
Meilisearch 1.0+ 支持中文分词插件(meilisearch-analysis),你需要配置它。否则,“我爱编程”会被拆成“我”、“爱”、“编程”三个词,搜索“编程爱”可能搜不到“我爱编程”。
建议: 在配置索引时,检查 filterableAttributessearchableAttributes,只把确实需要搜索的字段加进去,避免浪费内存。

2. 大文件索引
不要尝试去索引图片、视频的二进制流数据。这会撑爆你的内存。
策略: 只索引标题、摘要、自定义字段。对于附件,只存元数据。

3. 认证安全
不要在客户端(浏览器)直接暴露你的 Master Key
策略: 在 API 端点中,验证用户是否有权限访问。你可以检查 WordPress 的 current_user_can 权限,然后生成一个临时 Token,或者直接用 WordPress 的 Session 来控制。

add_action('rest_api_init', function () {
    register_rest_route('meili-search/v1', '/search', array(
        'methods'  => 'GET',
        'callback' => 'handle_search_request',
        'permission_callback' => function() {
            // 只有登录用户或者有特定权限的人才能搜
            return current_user_can('read');
        },
    ));
});

4. 缓存
虽然 Meilisearch 本身很快,但频繁调用 API 也有开销。如果搜索结果不常变(比如新闻网站),可以用 Redis 缓存 Meilisearch 的查询结果。

function handle_search_request($request) {
    $query = $request->get_param('q');

    // 简单的缓存键
    $cache_key = 'meili_search_' . md5($query);

    // 检查缓存
    $results = wp_cache_get($cache_key);

    if (false === $results) {
        $meili = new MeiliSearchWrapper();
        $results = $meili->search($query, 1);

        // 存入缓存,有效期 1 小时
        wp_cache_set($cache_key, $results, '', 3600);
    }

    return rest_ensure_response($results);
}

第九幕:未来展望

通过引入 Meilisearch,我们的 WordPress 网站不仅仅是一个 CMS,它变成了一个应用程序。

我们实现了:

  1. 毫秒级响应:用户不再有耐心等待。
  2. 语义理解:搜索“车祸”可能会推荐关于“交通安全”的文章,而不是只有字面匹配的报道。
  3. 灵活扩展:以后想加地理位置搜索?加。想加拼写纠错?加。

这不仅仅是技术升级,更是用户体验的升级。你想想,当你把这套系统部署到客户的网站上,看着老板和客户眼睛发亮地说“天哪,搜索怎么这么快”,那种感觉,比喝了一杯冰镇啤酒还爽。

最后,不要忘了给 Meilisearch 官方仓库点个 Star,虽然它不收钱,但它的作者会因此感到快乐,快乐的作者代码写得更好。

好了,今天的讲座就到这里。拿起你的键盘,去重构你的搜索系统吧!记住,代码要优雅,搜索要飞快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注