# Think全文检索 **Repository Path**: zsping1989/think-scout ## Basic Information - **Project Name**: Think全文检索 - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-30 - **Last Updated**: 2026-10-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # laraveladmin/think-scout > ThinkPHP 8 ORM 的 Laravel Scout 风格全文搜索:`Model::search()` 一套 API, > 底层可切 MySQL / PostgreSQL / MongoDB / Meilisearch / Collection / Null 六种引擎。 ```php use LaravelAdmin\ThinkScout\Searchable; class CustomerModel extends \think\Model { use Searchable; } $customers = CustomerModel::search('张三') ->where('status', 1) ->orderBy('updated_at', 'desc') ->paginate(15); ``` 与 [`laravel/scout`](https://github.com/laravel/scout) 的模型侧 API 保持一致: `Searchable`、`search()`、`searchable()`、`unsearchable()`、`makeAllSearchable()`、 `toSearchableArray()`、`searchableAs()`、`shouldBeSearchable()`、`getScoutKey()`、 `getScoutKeyName()`、`searchableUsing()`、`withoutSyncingToSearch()`、`scout:import` 等。 --- ## 一、为什么又做一个包 | 方案 | 结论 | |---|---| | `tp5er/think-scout` | ThinkPHP 6 时代、以 Elasticsearch 为主,模型事件需要手工注册,未覆盖 MySQL/PG/Mongo 原生全文索引。 | | 业务里直接写 `LIKE` / `regex` | 没有相关度、没有分页总数、没有索引生命周期,中文与多字段检索要各写一遍。 | | `laravel/scout` | API 设计成熟,但依赖 Eloquent / Laravel 容器,不能直接用于 think-orm。 | 本包保留 Scout 的用法,把 Eloquent 依赖替换为 think-orm,并补齐三种数据库的索引存储实现。 --- ## 二、安装 ```bash composer require laraveladmin/think-scout ``` 包通过 `extra.think.services` 自动注册 `scout:*` 命令,通过 `extra.think.config` 自动合并 `config/scout.php`,无需手工改 `config/console.php`。 > **PostgreSQL 源模型额外一步**:think-orm 4 的 Pgsql 连接器依赖 `table_msg()` 函数, > 且官方 `pgsql.sql` 使用 PostgreSQL 12 已删除的 `pg_attrdef.adsrc`。如果模型本身 > 连接 PostgreSQL(而不是只在 PG 里存索引),先在业务库执行一次: > > ```bash > php think scout:pgsql-install --connection=pgsql > ``` > > 等价于执行包内 `resources/pgsql.sql`(已改为 `pg_get_expr`,可重复执行)。 > 本仓库开发时 `server/composer.json` 使用 Composer `path` 仓库 > (`../packages/think-scout`,symlink);生产只部署 `server/` 时请把包发布到 Packagist > 或改为 VCS 仓库,不能保留指向 `../packages` 的 path 仓库。 ### 依赖 | 引擎 | 前置条件 | |---|---| | `mysql` | MySQL 5.7+/8.0,建议 ngram parser(5.7.6+ 内置) | | `pgsql` | PHP `pdo_pgsql`;中文/子串检索建议安装 `pg_trgm`(无权限时自动降级) | | `mongo` | PHP `ext-mongodb`、MongoDB 4.2+ | | `meilisearch` | Meilisearch 1.x 服务;`meilisearch/meilisearch-php` 已作为包依赖自动安装 | | `collection` / `null` | 无 | 队列同步需要 `topthink/think-queue`;未安装时自动同步,不影响使用。 --- ## 三、配置 包自带 `config/scout.php`,业务可新建 `config/scout.php` 覆盖同名配置。 ```php return [ 'driver' => env('SCOUT_DRIVER', 'mysql'), // 全局默认引擎 'prefix' => env('SCOUT_PREFIX', ''), // 索引名前缀 'queue' => env('SCOUT_QUEUE', false), // 模型事件是否入队 'queue_connection' => env('SCOUT_QUEUE_CONNECTION', ''), 'queue_name' => env('SCOUT_QUEUE_NAME', 'scout'), 'after_commit' => env('SCOUT_AFTER_COMMIT', false), // Scout 11 兼容项 'identify' => env('SCOUT_IDENTIFY', false), // Scout 11 兼容项 'sync' => env('SCOUT_SYNC', true), // 总开关 'soft_delete' => env('SCOUT_SOFT_DELETE', false), 'chunk' => [ 'searchable' => 500, 'unsearchable' => 500, ], 'per_page' => 15, // Meilisearch 顶层配置(与 Scout 11 config/scout.php 对齐) 'meilisearch' => [ 'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'), 'key' => env('MEILISEARCH_KEY', ''), 'wait' => env('SCOUT_MEILISEARCH_WAIT', false), 'enable_contains' => env('SCOUT_MEILISEARCH_CONTAINS', false), 'index-settings' => [ // 'crm_customer' => ['filterableAttributes' => ['status']] ], 'model-settings' => [ // \app\common\model\plugin\CustomerModel::class => ['sortableAttributes' => ['updated_at']] ], ], 'engines' => [ 'mysql' => [ 'driver' => 'mysql', 'connection' => env('SCOUT_MYSQL_CONNECTION', 'mysql'), 'table' => env('SCOUT_MYSQL_TABLE', 'scout_index'), 'parser' => env('SCOUT_MYSQL_PARSER', 'ngram'), 'ngram_size' => 2, 'mode' => 'auto', ], 'pgsql' => [ 'driver' => 'pgsql', 'connection' => env('SCOUT_PGSQL_CONNECTION', 'pgsql'), 'table' => env('SCOUT_PGSQL_TABLE', 'scout_index'), 'language' => env('SCOUT_PGSQL_LANGUAGE', 'simple'), 'mode' => 'auto', 'extension' => 'pg_trgm', ], 'mongo' => [ 'driver' => 'mongo', 'connection' => env('SCOUT_MONGO_CONNECTION', 'mongo'), 'collection' => env('SCOUT_MONGO_COLLECTION', 'scout_index'), 'mode' => 'auto', ], 'meilisearch' => [ 'driver' => 'meilisearch', // host/key/wait/index-settings/model-settings 默认取顶层 scout.meilisearch ], 'collection' => ['driver' => 'collection'], 'null' => ['driver' => 'null'], ], // 模型 → 引擎名 'models' => [ // \app\common\model\plugin\CustomerModel::class => 'pgsql', ], ]; ``` 引擎优先级:模型 `searchableUsing()` > `scout.models[模型类]` > `scout.driver`。 **Meilisearch 的配置放在哪** - 顶层 `scout.meilisearch` 是单一配置源(与 Scout 11 `config/scout.php` 对齐): - `host` / `key`:Meilisearch HTTP 服务地址与 API Key; - `wait`:写入/建索引后是否等待任务; - `enable_contains`:是否允许 `like` 映射为 `CONTAINS`(需开启 Meilisearch 实验特性); - `index-settings[索引名]`:filterable/sortable/ranking 等索引设置; - `model-settings[模型类]`:按模型覆盖索引设置。 - `engines.meilisearch` 是引擎注册项,`driver=meilisearch` 必需;其中的同名键会**覆盖**顶层配置。 所以只写 `driver` 时全部取顶层,不会重复。 - **不要放进 `config/database.php`**:`database.connections` 是 ThinkPHP 的 PDO/Mongo 连接, `Db::connect()` 需要 `ConnectionInterface`;Meilisearch 是 HTTP API,放进去不会被使用, 语义也不对。需要集中管理时用 `scout.meilisearch`(或单独 `config/meilisearch.php` 再在此引用)。 > **数据库连接不重复配置**:`engines.mysql/pgsql/mongo.connection` 只写 > `config/database.php` 里的连接名;host/port/database/username/password/prefix > 全部由 `Db::connect($connection)` 从 `database.connections.` 读取。 > `scout.php` 只保留 `table`/`collection`、`parser`、`language`、`mode` 等索引专属参数。 > Meilisearch 不是数据库连接,host/key 仍放在 `scout.meilisearch`(与 Scout 11 一致)。 | 模式 | 取值 | 说明 | |---|---|---| | MySQL `mode` | `auto` / `fulltext` / `like` | `auto`:查询短于 ngram_size 时走 LIKE | | PostgreSQL `mode` | `auto` / `fulltext` / `trigram` | `auto`:含 CJK 走 ILIKE,否则 tsvector | | MongoDB `mode` | `auto` / `text` / `regex` | `auto`:含 CJK 走 regex,否则 `$text` | --- ## 四、模型接入 ```php declare(strict_types=1); namespace app\common\model\plugin; use app\common\model\OrmModel; use LaravelAdmin\ThinkScout\Searchable; class CustomerModel extends OrmModel { use Searchable; protected $name = 'customer'; /** * 只索引需要检索的字段(默认 toArray() 会把整行写入索引)。 * * @return array */ public function toSearchableArray(): array { return [ 'id' => (int) $this->id, 'name' => (string) $this->fullname, 'phone' => (string) $this->phone, 'company' => (string) $this->company, 'status' => (int) $this->status, ]; } public function shouldBeSearchable(): bool { return (int) $this->status === 1; } } ``` ### 索引名 默认 `SCOUT_PREFIX + 表名`。需要固定名称时覆盖: ```php public function searchableAs(): string { return 'crm_customer'; } ``` ### 自动同步 `AfterInsert` / `AfterUpdate` / `AfterDelete` / `AfterRestore` 自动维护索引。 如果模型自己实现了同名事件方法,需要手动调用 `searchableSync()` / `unsearchableSync()`。 ```php CustomerModel::disableSearchSyncing(); // 批量操作... CustomerModel::enableSearchSyncing(); CustomerModel::withoutSyncingToSearch(function () { // ... }); ``` > `Model::where(...)->update()` 不触发模型事件;批量改状态后请显式 > `makeAllSearchable()` 或 `searchableSync()`,与 Laravel Scout 一致。 ### 软删除 - `scout.soft_delete=false`(默认):软删除 = 从索引删除; - `scout.soft_delete=true`:索引保留文档并写 `__soft_deleted=1`, 查询默认只返回未删除,`withTrashed()` / `onlyTrashed()` 可切换。 --- ## 五、搜索 API ```php use app\common\model\plugin\CustomerModel; CustomerModel::search('张三')->get(); CustomerModel::search('张三')->first(); CustomerModel::search('张三')->keys(); CustomerModel::search('张三')->allKeys(); // 翻页取全部命中主键,适合源库二次过滤 CustomerModel::search('张三')->count(); CustomerModel::search('张三')->raw(); CustomerModel::search('张三') ->where('status', 1) ->whereIn('department_id', [12, 26]) ->whereNotIn('type', ['test']) ->whereNull('deleted_at') ->whereNotNull('phone') ->orderByDesc('updated_at') ->take(20) ->skip(0) ->get(); CustomerModel::search('张三') ->query(fn ($query) => $query->where('tenant_id', 1)) ->paginate(15); CustomerModel::search('张三')->within('crm_customer_alias')->get(); CustomerModel::search('')->withTrashed()->onlyTrashed()->get(); ``` | 方法 | 说明 | |---|---| | `where($field, $value)` / `where($field, $operator, $value)` | 索引文档字段过滤;三参数支持 `= != <> > >= < <= like` / `not like` | | `whereIn` / `whereNotIn` | 集合过滤 | | `whereNull` / `whereNotNull` | 空值 | | `whereBetween($field, [min, max])` | 区间 | | `orderBy` / `orderByDesc` / `latest` / `oldest` | 排序 | | `take` / `skip` | 限制/偏移 | | `within($index)` | 指定索引名 | | `query($callback)` | 修改源库查询(租户隔离等) | | `options(array)` | 传给引擎的额外参数 | | `withRawResults($callback)` | 观察/改写原始结果 | | `get` / `first` / `keys` / `allKeys` / `count` / `raw` | 结果;`allKeys` 对分页引擎翻页取全量 | | `paginate` / `simplePaginate` | `think\Paginator` | | `withTrashed` / `onlyTrashed` | 软删除 | | `cursor` | 一次性惰性集合包装 | | `semantic` / `hybrid` | Scout 11 API 对齐;内置数据库引擎没有向量索引,执行时抛 `NotSupportedException` | 过滤字段必须是 `[A-Za-z_][A-Za-z0-9_]*`(可带 `.` 表示嵌套),非法字段/操作符抛 `InvalidArgumentException`。三参数 `where` 与 Scout 11 对齐,例如: ```php CustomerModel::search('张三')->where('age', '>=', 18)->get(); CustomerModel::search('张三')->where('name', 'like', '张')->get(); ``` 分页返回与 ThinkPHP 列表一致: ```php $page = CustomerModel::search('张三')->paginate(15); $page->total(); $page->items(); ``` --- ## 六、索引存储 索引与业务表分离:MySQL/PG 建 `scout_index` 表,Mongo 建 `scout_index` 集合。 同一个索引存储可服务多个模型,用 `index_name + model_type` 区分。 ### MySQL ```sql CREATE TABLE `crm_scout_index` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `index_name` VARCHAR(191) NOT NULL, `model_type` VARCHAR(191) NOT NULL, `model_key` VARCHAR(191) NOT NULL, `document` LONGTEXT NOT NULL, `search_text` LONGTEXT NOT NULL, `updated_at` TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `scout_index_unique` (`index_name`, `model_type`, `model_key`), FULLTEXT KEY `scout_fulltext` (`search_text`) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` - 查询:`MATCH(search_text) AGAINST (? IN NATURAL LANGUAGE MODE)`; 短查询走 LIKE; - 写入:`INSERT ... ON DUPLICATE KEY UPDATE`,按 200 条批量; - 过滤:`JSON_EXTRACT(document, '$."field"')`。 ### PostgreSQL ```sql CREATE TABLE "scout_index" ( id BIGSERIAL PRIMARY KEY, index_name VARCHAR(191) NOT NULL, model_type VARCHAR(191) NOT NULL, model_key VARCHAR(191) NOT NULL, document JSONB NOT NULL, search_text TEXT NOT NULL, updated_at TIMESTAMP NULL, CONSTRAINT scout_index_unique UNIQUE (index_name, model_type, model_key) ); CREATE INDEX scout_index_tsv ON "scout_index" USING GIN (to_tsvector('simple', search_text)); CREATE INDEX scout_index_trgm ON "scout_index" USING GIN (search_text gin_trgm_ops); ``` - 英文/数字:`to_tsvector @@ plainto_tsquery` + `ts_rank`; - 中文/子串:`ILIKE` + `similarity`(`pg_trgm` 不可用时 rank 为 0,仍可检索); - 写入:`INSERT ... ON CONFLICT ... DO UPDATE`。 ### MongoDB ```js // 索引集合:{prefix}scout_index db.scout_index.createIndex( { index_name: 1, model_type: 1, model_key: 1 }, { unique: true, name: "scout_index_unique" } ); db.scout_index.createIndex( { search_text: "text" }, { name: "scout_text" } ); ``` - 英文:`$text` + `textScore`; - 中文:`$regex` 子串匹配; - 写入:`update` 命令 upsert;删除:`delete` 命令。 ### Meilisearch - 索引名默认 `SCOUT_PREFIX + 表名`,可通过 `searchableAs()` / `indexableAs()` 覆盖; - 写入:`addDocuments($objects, $primaryKey)`;删除:`deleteDocuments()`;清空:`deleteAllDocuments()`; - 搜索:`rawSearch()`,支持 `filter` / `sort` / `hitsPerPage` / `page`; - 索引设置:`filterableAttributes` / `sortableAttributes` 优先取 `scout.meilisearch.index-settings[索引名]`, 再合并 `model-settings[模型类]`;未配置时自动使用 `toSearchableArray()` 的字段,保证 `where` / `orderBy` 可用; - 软删除:`__soft_deleted` 会自动加入 `filterableAttributes`; - 写入后默认异步(不等待任务),`wait=true` 或 `SCOUT_MEILISEARCH_WAIT=true` 时等待任务完成; `scout:index` / `scout:sync-index-settings` 会等待建索引/设置任务; - 修改 settings 后执行 `php think scout:sync-index-settings --all`,不必重建索引。 ### Collection / Null - `collection`:直接查源模型,PHP 层过滤;适合开发与测试; - `null`:写入 no-op、搜索恒空;用于关闭搜索。 --- ## 七、命令 ```bash # 一条命令:建索引 + 同步设置 + 导入所有 use Searchable 的模型 php think scout:build # 只处理单个模型 / config("scout.models") 列表 / 清空后重导 php think scout:build "app\\common\\model\\plugin\\CustomerModel" php think scout:build --all php think scout:build --fresh --chunk=500 # 建索引(表/集合 + 全文索引) php think scout:index "app\\common\\model\\plugin\\CustomerModel" php think scout:index --all --recreate # 分批导入(幂等 upsert) php think scout:import "app\\common\\model\\plugin\\CustomerModel" --chunk=500 php think scout:import --all # 清空某模型索引数据(保留结构) php think scout:flush "app\\common\\model\\plugin\\CustomerModel" --force php think scout:flush --all --force # 删除某模型索引数据(不删表结构) php think scout:delete-index "app\\common\\model\\plugin\\CustomerModel" php think scout:delete-index --all # PostgreSQL 源模型:安装 table_msg() 辅助函数 php think scout:pgsql-install --connection=pgsql # 同步外部引擎(Meilisearch)的索引设置,不重建索引 php think scout:sync-index-settings "app\\common\\model\\plugin\\CustomerModel" php think scout:sync-index-settings --all ``` `--all` 从 `config('scout.models')` 的键读取模型列表。模型必须 `use Searchable`。 --- ## 八、队列 ```php 'queue' => true, 'queue_connection' => 'redis', // 留空 = queue.default 'queue_name' => 'scout', ``` - 事件只入队,不阻塞请求; - Job 只带模型类与主键,worker 端重新查源库; - 未安装 `think-queue` 或 `queue=false` 时同步执行; - 入队失败抛 `ScoutException`,不静默丢索引; - 队列失败记录日志(模型、主键数量、异常),超过 3 次删除。 --- ## 九、自定义引擎 实现 `LaravelAdmin\ThinkScout\Engine`: ```php use LaravelAdmin\ThinkScout\Engine; class MyEngine extends Engine { public function update(iterable $models): void {} public function delete(iterable $models): void {} public function search(Builder $builder): array { return ['keys' => [], 'total' => 0]; } public function paginate(Builder $builder, int $perPage, int $page): array { /* ... */ } public function mapIds(array $results): array { return []; } public function map(Builder $builder, array $results, \think\Model $model): \think\model\Collection { /* ... */ } public function getTotalCount(array $results): int { return 0; } public function count(Builder $builder): int { return 0; } public function flush(\think\Model $model): void {} public function createIndex(\think\Model $model, array $options = []): void {} public function deleteIndex(\think\Model $model): void {} } ``` 注册: ```php 'engines' => [ 'my' => [ 'driver' => \app\search\MyEngine::class, ], ], ``` 模型覆盖: ```php public function searchableUsing(): \LaravelAdmin\ThinkScout\Engine { return app(\LaravelAdmin\ThinkScout\ScoutManager::class)->engine('my'); } ``` --- ## 十、限制与注意事项 - 一期只支持**单主键**;复合主键会抛 `ScoutException`。 - Meilisearch 的 `where` 依赖 `filterableAttributes`、`orderBy` 依赖 `sortableAttributes`; 未配置时包会自动把 `toSearchableArray()` 字段写入 settings,显式配置可覆盖。 - Meilisearch 的 `like` 过滤默认**不支持**:`CONTAINS` 需要开启 `contains filter` 实验特性; 开启后设置 `enable_contains=true` 才会映射为 `CONTAINS`。建议改用 `where` 等值/IN, 或把关键词放在 `search()` 查询里。 - Meilisearch 写入默认异步,搜索前未完成的任务可能暂时不可见;测试/导入可用 `wait=true`。 - `document` 过滤字段不是独立索引列/字段,超大数据量下过滤性能有限; MySQL 可后续加函数索引,Mongo 可加复合索引。 - MongoDB 一个集合只能有一个 text index,文本字段固定为 `search_text`; 中文 `$regex` 子串匹配不走索引。 - PostgreSQL 的中文分词依赖 `pg_trgm` 子串匹配,不做拼音/同义词/语义搜索。 - MySQL ngram 对 1 个汉字查询会回退 LIKE;`ngram_size` 可配置。 - 索引命中但源库已删除的记录在回表时自动跳过;分页总数以索引命中数为准。 - 软删除记录是否保留由 `scout.soft_delete` 决定,不是每个模型独立配置。 - `after_commit` / `identify` 是 Scout 11 配置兼容项:think-orm 没有事务提交事件, 本包不会延迟到 commit 后同步;`identify` 仅外部引擎使用,内置引擎忽略。 - `semantic()` / `hybrid()` 是 Scout 11 API 兼容项,内置数据库引擎抛 `NotSupportedException`(没有向量列/向量索引);需要向量检索时实现自定义引擎。 --- ## 十一、测试 包内测试优先使用 `server/vendor` 的依赖(monorepo 本地开发): ```bash cd packages/think-scout ../../server/vendor/bin/phpunit -c phpunit.xml ``` - MySQL / MongoDB 使用临时表/集合,测试后清理; - Meilisearch 需要 `MEILISEARCH_HOST` / `MEILISEARCH_KEY`,无服务时自动 `markTestSkipped`; - PostgreSQL 无连接时自动 `markTestSkipped`; - 不调用外部网络服务。 服务端安装验收: ```bash cd server vendor/bin/phpunit -c phpunit.xml --testsuite scout ``` --- ## 十二、目录 ``` packages/think-scout/ ├── config/scout.php ├── src/ │ ├── Searchable.php │ ├── Builder.php │ ├── Engine.php │ ├── ScoutManager.php │ ├── Service.php │ ├── Engines/ │ │ ├── AbstractIndexEngine.php │ │ ├── MySqlEngine.php │ │ ├── PgsqlEngine.php │ │ ├── MongoEngine.php │ │ ├── MeilisearchEngine.php │ │ ├── CollectionEngine.php │ │ └── NullEngine.php │ ├── Commands/ │ ├── Jobs/ │ └── Support/ └── tests/ ```