# 构建一个可搜索的目录，包含筛选器、分面和语义搜索

使用Manticore构建搜索应用的实用指南：自动补全、拼写容错、筛选器、分面、深度分页、语义搜索和相似项目发现，所有功能整合在一个流程中。

一个搜索框很容易。一个在首次查询后仍持续有用的可搜索目录才是更难的部分。

这就是这个演示要解决的问题。它使用了一个小型棋盘游戏目录，但问题的结构是熟悉的：用户输入一些半记得的内容，拼写错误，通过约束条件缩小范围，继续浏览，打开结果，然后想要“类似此项目”而无需重新开始。如果你的产品有这个流程，大部分工作不是UI的打磨。而是要在不把技术栈变成科学项目的情况下，正确实现搜索行为。

在本文中，我们将构建一个可搜索的目录，包含自动补全、拼写容错、筛选器、分面、深度分页、语义搜索和相似项目推荐。

您可以先尝试托管版本：

https://catalog.manticoresearch.com

![带有筛选器和分面的目录搜索结果](./manticore-php-demo/demo.png)

该应用本身是用PHP实现的，但这并不是重点。有趣的部分是，从一个基本的查询框到一个已经感觉像工作目录的系统，所需的仪式感非常少：搜索、筛选器、分面和相似项目发现都能快速出现。

## 本地运行

要在本地运行相同的演示，您只需要PHP 8.1+、Composer和Docker（或任何其他运行Manticore的方式）。

在此设置中，Manticore是目录背后的搜索引擎：它处理索引、筛选、分面和语义检索。该仓库已经包含了一个Docker设置，因此最快的方式是克隆仓库并在项目根目录中启动Manticore：

```bash
git clone https://github.com/manticoresoftware/php-catalog-demo
cd php-catalog-demo
docker compose up -d
```

`docker compose ps` 应该显示容器正在运行。

在克隆的仓库中，创建应用环境文件：

```bash
cp app/.env.example app/.env
```

对于本地运行，重要的是应用如何连接到Manticore：

```env
MANTICORE_HOST=127.0.0.1
MANTICORE_PORT=9308
```

安装依赖项：

```bash
cd app
composer install
```

演示读取这些设置并创建Manticore客户端：

```php
$settings = require $root . '/config/settings.php';

$client = new Client([
    'host' => $settings['manticore']['host'],
    'port' => $settings['manticore']['port'],
    'transport' => 'Http',
]);
```

然后加载演示数据集：

```bash
php bin/bootstrap-demo.php
```

该命令会重新创建演示表并导入初始目录，因此您可以从已知状态开始，而不是调试旧数据。

启动应用：

```bash
php -S localhost:8081 -t public
```

打开 `http://localhost:8081/`，您就有了一个可搜索的目录。

不那么华丽。但仍然值得。很多搜索演示在首次查询前就让人失去兴趣，因为设置过于复杂。这个演示不需要太多。

## 使应用感觉可用的因素

我最关心的部分不是演示返回了结果。很多演示都能做到这一点。而是当用户变得更具体时，搜索流程仍然保持连贯。

### 从自动补全开始

人们通常从片段开始。有时他们记得确切的游戏标题。但很多时候他们不会。

因此，第一层是自动补全：

```php
$payload = [
    'body' => [
        'query' => $term,
        'table' => $this->tableName,
        'options' => ['limit' => $limit, 'force_bigrams' => 1],
    ],
];
$suggestions = $this->client->autocomplete($payload);
```

在这里使用 `force_bigrams` 有助于收紧对短或略微错误输入的拼写容错匹配，这正是自动补全可能变得模糊的地方。

![在查询字段中输入时的实时搜索建议](./manticore-php-demo/live_search.gif)

这是一个小功能，但它立即改变了应用的感觉。用户不再猜测您的目录如何称呼事物。

### 使第一个结果页面宽容

一旦提交查询，即使拼写有些偏差，第一页也需要有用。

```php
$search = (new Search($this->client))
    ->setTable($this->tableName)
    ->limit($limit);

if ($query !== '') {
    $search->search($query);
    if ($fuzzy) {
        $search->option('fuzzy', 1)->option('force_bigrams', 1);
    }
} else {
    $search->search('*');
}
```

模糊模式在这里做着实用的工作：当用户没有准确输入标题时，恢复接近的匹配。

![模糊搜索处理拼写错误查询的接近匹配](./manticore-php-demo/fuzzy_search.gif)

如果您想了解底层细节，请参见 [拼写校正和模糊搜索](https://manual.manticoresearch.com/Searching/Spell_correction#Fuzzy-Search)。

### 让用户无需重写即可缩小范围

这是许多搜索界面变得烦人之处。查询已经足够接近，但结果集仍然太广，因此用户必须从头开始重新表述。

更好的做法是让他们原地缩小范围。

范围筛选器处理价格、玩家数量、游戏时间和发布年份等约束。分面展示了当前结果集的形状，让用户可以点击进入类别或标签，而不是想出更精确的句子。

```php
$attributeFilters = [
    'price_min' => $priceMin,
    'price_max' => $priceMax,
    'play_time_min' => $playTimeMin,
    'play_time_max' => $playTimeMax,
    'player_count_min' => $playerCountMin,
    'player_count_max' => $playerCountMax,
    'release_year_min' => $yearMin,
    'release_year_max' => $yearMax,
];

if ($categoryIds !== []) {
    $search->filter('category_id', 'in', $categoryIds);
}
if ($tagIds !== []) {
    $search->filter('tag_id', 'in', $tagIds);
}
$this->applyNumericFilters($search, $attributeFilters);
$search->facet('category_id')->facet('tag_id');
```

![通过筛选器和分面缩小结果集而无需重写查询](./manticore-php-demo/filters.gif)

这种组合比纸面上看起来更重要。实际上，这是目录开始感觉易于使用的地方：一旦点击进入某个类别或标签，一个广泛查询可以快速缩小，而不会失去原始搜索意图。

### 保持深度分页稳定

如果用户进一步浏览，偏移量分页开始显现出其局限性。请求之间的数据变化，偏移量变得更大，最终“显示更多”变得不如预期可靠。

这个演示使用滚动令牌而不是偏移量分页：

```php
// Page 1 starts a fresh scroll session; next pages continue with returned token.
$effectiveScrollToken = $page > 1 ? $scrollToken : null;
$search->option('scroll', $effectiveScrollToken ?? true);

$resultSet = new ResultSet($this->client->search(['body' => $body], true));
$nextScroll = $resultSet->getScroll();
$hasMore = $nextScroll !== null && (string) $nextScroll !== '';
```

这为深度分页提供了更好的基础：每个请求从返回的令牌继续，而不是重新计算越来越大的偏移量。

![使用滚动状态稳定地继续“显示更多游戏”](./manticore-php-demo/scroll.gif)

从操作上看，这是用户在正常工作时永远不会注意到的选择，但当出现问题时，他们一定会注意到。更多关于机制的内容请参见：[基于滚动的分页](https://manticoresearch.com/blog/pagination/#scroll-based-pagination)。

### 在关键词失效时添加语义检索

关键词搜索能带你走很远。但它并不能解决所有问题。

有时用户用大致正确的语言描述某物，但没有使用目录中使用的相同词汇。这就是混合搜索发挥作用的地方。

#### 在结果页面上使用混合搜索

在这个演示中，一个请求同时包含一个词法 `query` 块和一个语义 `knn` 块，然后通过 `options.fusion_method = rrf` 使用倒数排名融合将它们结合起来：

```php
$body = [
    'query' => ['bool' => ['must' => [['query_string' => ['query' => $query]]]]],
    'knn' => [
        'field' => 'description_vector',
        'query' => $query,
    ],
    'options' => ['fusion_method' => 'rrf'],
    'limit' => $limit,
];
```

向量字段使用自动嵌入，因此应用不需要自己生成查询向量：

```php
'description_vector' => [
    'type' => 'float_vector',
    'options' => [
        'MODEL_NAME' => 'sentence-transformers/all-MiniLM-L6-v2',
        'FROM' => 'description',
    ],
],
```

由于 `knn` 块直接命名了向量字段（`'field' => 'description_vector'`），Manticore 可以为 KNN 搜索自动嵌入查询文本。

这使得应用程序的逻辑比许多团队最初听到“语义搜索”时预期的要简单得多。它还让结果页面保持在一个流程中，而不是将单独的语义体验附加到页面一侧。

#### 在详情页上使用相似项目发现

在项目页面上，相同的向量字段执行不同的任务：“向我展示类似的游戏”，而无需迫使用户重新发明另一个查询。这一部分直接针对当前项目使用 KNN。

```php
$search = new Search($this->client);
$search->setTable($this->tableName)
    ->knn('description_vector', $source->getId(), self::SIMILAR_KNN_LIMIT)
    ->notFilter('id', 'in', [$source->getId()])
    ->limit(self::SIMILAR_RESULT_LIMIT);

$resultSet = $search->get();
$hits = $this->formatResultSet($resultSet)['hits'];
return array_slice($hits, 0, self::SIMILAR_RESULT_LIMIT);
```

![商品页面上的类似游戏推荐](./manticore-php-demo/hybrid_search.gif)

这就是搜索从实用工具转变为帮助发现的时刻。在真实的详情页上，这一部分让用户更容易继续探索，而不是跳回搜索框。

参考内容：[自动嵌入](https://manual.manticoresearch.com/Searching/KNN#Auto-Embeddings-(Recommended)) 和 [KNN 搜索](https://manual.manticoresearch.com/Searching/KNN#KNN-vector-search)。

## 保持写入和搜索结果同步

当数据从不变化时，演示应用程序很容易让人信任。但真实的应用程序无法享受这种奢侈。

在这里，表格通过用户和管理员已经接触的相同应用程序流程保持同步：用于干净基线的引导，从管理界面批量导入，以及针对单个项目的更新/删除操作。

预处理导入使用客户端的批量写入方法：

```php
$table = $this->client->table($this->indexConfig['name']);

if ($appendAsNewIds) {
    $table->addDocuments($batch);
} else {
    $table->replaceDocuments($batch);
}
```

对于单个项目的更改，应用程序直接使用表格 API：

```php
if ($id > 0) {
    $this->table->replaceDocument($document, $id);
} else {
    $this->table->addDocument($document);
}

$this->table->deleteDocument($id);
```

![管理界面更新索引记录](./manticore-php-demo/admin_crud.gif)

如果你想重置实验，管理界面可以删除导入的记录并返回到基线数据集：

```php
$baseMaxId = $this->resolveBaseMaxId();

$this->table->deleteDocuments([
    'range' => [
        'id' => ['gt' => $baseMaxId],
    ],
]);
```

演示中没有额外的后台机制，也没有需要解释的脱离同步的故事。只是将写入操作发送到需要的地方。

## 为什么这很重要

这个演示展示的不仅仅是 Manticore 能够返回结果。它展示了你可以构建一个感觉完整的可搜索目录：用户可以从松散的搜索开始，快速通过筛选器和分面缩小范围，从不完美的查询中恢复，打开一个项目，并继续发现，而无需整个系统变得复杂。

这已经足以让搜索感觉像是产品的一部分，而不是一个附加功能。
