一个搜索框很容易。一个在首次查询后仍持续有用的可搜索目录才是更难的部分。
这就是这个演示要解决的问题。它使用了一个小型棋盘游戏目录,但问题的结构是熟悉的:用户输入一些半记得的内容,拼写错误,通过约束条件缩小范围,继续浏览,打开结果,然后想要“类似此项目”而无需重新开始。如果你的产品有这个流程,大部分工作不是UI的打磨。而是要在不把技术栈变成科学项目的情况下,正确实现搜索行为。
在本文中,我们将构建一个可搜索的目录,包含自动补全、拼写容错、筛选器、分面、深度分页、语义搜索和相似项目推荐。
您可以先尝试托管版本:
https://catalog.manticoresearch.com

该应用本身是用PHP实现的,但这并不是重点。有趣的部分是,从一个基本的查询框到一个已经感觉像工作目录的系统,所需的仪式感非常少:搜索、筛选器、分面和相似项目发现都能快速出现。
本地运行
要在本地运行相同的演示,您只需要PHP 8.1+、Composer和Docker(或任何其他运行Manticore的方式)。
在此设置中,Manticore是目录背后的搜索引擎:它处理索引、筛选、分面和语义检索。该仓库已经包含了一个Docker设置,因此最快的方式是克隆仓库并在项目根目录中启动Manticore:
git clone https://github.com/manticoresoftware/php-catalog-demo
cd php-catalog-demo
docker compose up -d
docker compose ps 应该显示容器正在运行。
在克隆的仓库中,创建应用环境文件:
cp app/.env.example app/.env
对于本地运行,重要的是应用如何连接到Manticore:
MANTICORE_HOST=127.0.0.1
MANTICORE_PORT=9308
安装依赖项:
cd app
composer install
演示读取这些设置并创建Manticore客户端:
$settings = require $root . '/config/settings.php';
$client = new Client([
'host' => $settings['manticore']['host'],
'port' => $settings['manticore']['port'],
'transport' => 'Http',
]);
然后加载演示数据集:
php bin/bootstrap-demo.php
该命令会重新创建演示表并导入初始目录,因此您可以从已知状态开始,而不是调试旧数据。
启动应用:
php -S localhost:8081 -t public
打开 http://localhost:8081/,您就有了一个可搜索的目录。
不那么华丽。但仍然值得。很多搜索演示在首次查询前就让人失去兴趣,因为设置过于复杂。这个演示不需要太多。
使应用感觉可用的因素
我最关心的部分不是演示返回了结果。很多演示都能做到这一点。而是当用户变得更具体时,搜索流程仍然保持连贯。
从自动补全开始
人们通常从片段开始。有时他们记得确切的游戏标题。但很多时候他们不会。
因此,第一层是自动补全:
$payload = [
'body' => [
'query' => $term,
'table' => $this->tableName,
'options' => ['limit' => $limit, 'force_bigrams' => 1],
],
];
$suggestions = $this->client->autocomplete($payload);
在这里使用 force_bigrams 有助于收紧对短或略微错误输入的拼写容错匹配,这正是自动补全可能变得模糊的地方。

这是一个小功能,但它立即改变了应用的感觉。用户不再猜测您的目录如何称呼事物。
使第一个结果页面宽容
一旦提交查询,即使拼写有些偏差,第一页也需要有用。
$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('*');
}
模糊模式在这里做着实用的工作:当用户没有准确输入标题时,恢复接近的匹配。

如果您想了解底层细节,请参见 拼写校正和模糊搜索 。
让用户无需重写即可缩小范围
这是许多搜索界面变得烦人之处。查询已经足够接近,但结果集仍然太广,因此用户必须从头开始重新表述。
更好的做法是让他们原地缩小范围。
范围筛选器处理价格、玩家数量、游戏时间和发布年份等约束。分面展示了当前结果集的形状,让用户可以点击进入类别或标签,而不是想出更精确的句子。
$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');

这种组合比纸面上看起来更重要。实际上,这是目录开始感觉易于使用的地方:一旦点击进入某个类别或标签,一个广泛查询可以快速缩小,而不会失去原始搜索意图。
保持深度分页稳定
如果用户进一步浏览,偏移量分页开始显现出其局限性。请求之间的数据变化,偏移量变得更大,最终“显示更多”变得不如预期可靠。
这个演示使用滚动令牌而不是偏移量分页:
// 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 !== '';
这为深度分页提供了更好的基础:每个请求从返回的令牌继续,而不是重新计算越来越大的偏移量。

从操作上看,这是用户在正常工作时永远不会注意到的选择,但当出现问题时,他们一定会注意到。更多关于机制的内容请参见: 基于滚动的分页 。
在关键词失效时添加语义检索
关键词搜索能带你走很远。但它并不能解决所有问题。
有时用户用大致正确的语言描述某物,但没有使用目录中使用的相同词汇。这就是混合搜索发挥作用的地方。
在结果页面上使用混合搜索
在这个演示中,一个请求同时包含一个词法 query 块和一个语义 knn 块,然后通过 options.fusion_method = rrf 使用倒数排名融合将它们结合起来:
$body = [
'query' => ['bool' => ['must' => [['query_string' => ['query' => $query]]]]],
'knn' => [
'field' => 'description_vector',
'query' => $query,
],
'options' => ['fusion_method' => 'rrf'],
'limit' => $limit,
];
向量字段使用自动嵌入,因此应用不需要自己生成查询向量:
'description_vector' => [
'type' => 'float_vector',
'options' => [
'MODEL_NAME' => 'sentence-transformers/all-MiniLM-L6-v2',
'FROM' => 'description',
],
],
由于 knn 块直接命名了向量字段('field' => 'description_vector'),Manticore 可以为 KNN 搜索自动嵌入查询文本。
这使得应用程序的逻辑比许多团队最初听到“语义搜索”时预期的要简单得多。它还让结果页面保持在一个流程中,而不是将单独的语义体验附加到页面一侧。
在详情页上使用相似项目发现
在项目页面上,相同的向量字段执行不同的任务:“向我展示类似的游戏”,而无需迫使用户重新发明另一个查询。这一部分直接针对当前项目使用 KNN。
$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);

这就是搜索从实用工具转变为帮助发现的时刻。在真实的详情页上,这一部分让用户更容易继续探索,而不是跳回搜索框。
保持写入和搜索结果同步
当数据从不变化时,演示应用程序很容易让人信任。但真实的应用程序无法享受这种奢侈。
在这里,表格通过用户和管理员已经接触的相同应用程序流程保持同步:用于干净基线的引导,从管理界面批量导入,以及针对单个项目的更新/删除操作。
预处理导入使用客户端的批量写入方法:
$table = $this->client->table($this->indexConfig['name']);
if ($appendAsNewIds) {
$table->addDocuments($batch);
} else {
$table->replaceDocuments($batch);
}
对于单个项目的更改,应用程序直接使用表格 API:
if ($id > 0) {
$this->table->replaceDocument($document, $id);
} else {
$this->table->addDocument($document);
}
$this->table->deleteDocument($id);

如果你想重置实验,管理界面可以删除导入的记录并返回到基线数据集:
$baseMaxId = $this->resolveBaseMaxId();
$this->table->deleteDocuments([
'range' => [
'id' => ['gt' => $baseMaxId],
],
]);
演示中没有额外的后台机制,也没有需要解释的脱离同步的故事。只是将写入操作发送到需要的地方。
为什么这很重要
这个演示展示的不仅仅是 Manticore 能够返回结果。它展示了你可以构建一个感觉完整的可搜索目录:用户可以从松散的搜索开始,快速通过筛选器和分面缩小范围,从不完美的查询中恢复,打开一个项目,并继续发现,而无需整个系统变得复杂。
这已经足以让搜索感觉像是产品的一部分,而不是一个附加功能。
