Привет, Хабр!
Предлагаю сегодня разобраться, как семантический поиск появился в Laravel и PostgreSQL.
Обычный полнотекстовый поиск ищет записи, содержащие конкретные слова. Но что если нужно искать по смыслу, а не по точным слофвам? Например, хочется найти все отзывы о завтраках — это и блинчики, и вафли, и яичница. Ключевое слово «завтрак» напрямую не упоминается в каждом таком отзыве, и даже гибкий поиск по похожим словам («панкейки» vs «панкейк») не поймает все блюда на завтрак. Вы‑то и так понимаете, что блины, овсянка и яйца — это завтрак, но как этому научить поиск?
Решение — использовать векторные эмбеддинги. Проще говоря, это числовые представления данных, отражающие их смысловое содержание. Современные языковые модели могут преобразовать текст в точку в многомерном пространстве — эмбеддинг. Близкие по смыслу тексты оказываются рядом друг с другом в этом пространстве. В нашем примере все отзывы, связанные с завтраком, скучкуются в одном регионе, независимо от конкретных слов. Тогда при поиске по слову «завтрак» мы тоже превращаем его в вектор и ищем ближайшие точки — получаем отзывы про блины, вафли, яйца и так далее, даже если слово «завтрак» там не фигурирует явно.
Векторный подход позволяет искать концептуально: по категориям, темам, похожести смысла, а не только по совпадению символов. Это мощный инструмент для умного поиска. Исторически для такого приходилось поднимать отдельную «векторную» базу данных (например, milvus, ElasticSearch с плагинами и пр.). Но теперь всё проще — есть расширение pgvector для PostgreSQL, которое позволяет хранить эмбеддинги и искать по ним прямо в вашем любимом Postgres.
pgvector: векторные данные в PostgreSQL
pgvector — это расширение Postgres, добавляющее новый тип столбца VECTOR
для хранения эмбеддингов, а также специальные операторы для сравнения векторов по различным метрикам расстояния. Проще говоря, мы сможем сохранить массив чисел (например, 1536 чисел с плавающей точкой — эмбеддинг текста) прямо в таблице и выполнять запросы «найти ближайший вектор» с помощью SQL.
Прежде чем использовать VECTOR
, нужно установить расширение на сервере БД. В некоторых сборках Postgres (Postgres.app, Docker‑образ ankane/pgvector
, облачные PG вроде Supabase, Neon) pgvector уже включён. Если нет — придётся установить вручную. Для локального PostgreSQL (начиная с версии 12) можно скачать исходники pgvector и скомпилировать:
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make && sudo make install # устанавливаем расширение
Либо воспользоваться пакетным менеджером (пример для Ubuntu 20.04 + PG12: пакета может не быть, тогда только через компиляцию). В общем, установка pgvector зависит от окружения. После установки расширения надо включить его в конкретной базе данных командой SQL:
CREATE EXTENSION IF NOT EXISTS vector;
Эту команду можно выполнить через psql, pgAdmin или даже через Laravel‑миграцию (с помощью сырых запросов). Но будьте внимательны: для CREATE EXTENSION
обычно требуются права суперпользователя в БД. На хостинге вроде RDS может понадобиться отдельное разрешение или включение расширения через админку.
Проверить, доступно ли расширение, можно запросом:
SELECT *
FROM pg_available_extensions
WHERE name = 'vector';
Если в результате есть строка с vector
, можно смело создавать. Итак, предположим, pgvector включён.
Миграция с vector‑столбцом. Laravel начиная с версии 11.25 добавил нативную поддержку типа vector
в Schema Blue. Значит, можно просто написать миграцию:
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->vector('embedding', dimensions: 1536); // столбец для эмбеддинга
$table->timestamps();
});
Здесь 1536
— размерность эмбеддинга. Почему 1536? Потому что популярная модель OpenAI text-embedding-ada-002
(о ней чуть позже) выдаёт вектор именно длины 1536. Можно задать другое число, соответствующее выбранной модели (например, 768, 1024 и так далее). Тип vector(1536)
под капотом хранится эффективно и занимает в таблице 1536 * 4 байта (float4) = ~6 КБ на запись.
Если у вас версия Laravel постарше или вы не уверены в поддержке, можно добиться того же через сырые запросы. Сначала включаем расширение, потом добавляем столбец:
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
// расширение vector должно быть уже включено к этому моменту!
$table->timestamps();
});
// Отдельная миграция для расширения и столбца:
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
DB::statement('ALTER TABLE articles ADD COLUMN embedding VECTOR(1536)');
Но так как теперь есть $table->vector()
, лучше им воспользоваться для чистоты миграции. Только помните: Laravel не включает расширение автоматически, это всё равно ваша забота (возможно, вручную на БД или через вызов CREATE EXTENSION
).
После применения миграций у нас есть таблица articles
со столбцом embedding
. Теперь PostgreSQL готов хранить эмбеддинги и мерять расстояния между ними.
Генерация эмбеддингов текста
Хранить‑то мы их можем, но откуда брать сами эмбеддинги? Здесь на помощь приходит модель машинного обучения. Обычно используют готовый сервис/модель, чтобы перевести текст в вектор. Вариантов несколько:
OpenAI API — сервис генерации эмбеддингов (модель Ada-002) от OpenAI. Просто отправляем запрос с текстом — получаем массив из 1536 чисел. Качество высокое, но есть стоимость и внешняя зависимость (нужен интернет, API‑ключ, платите за токены).
Локальная модель — например, обёртка над SentenceTransformer (HuggingFace) на Python. Можно запускать свой сервис (в Docker) рядом с Laravel‑приложением. В статье на Medium автор описывает FastAPI‑сервис, который принимает текст и возвращает эмбеддинг через
sentence_transformers
. Такой подход убирает внешние запросы, но требует поддерживать работоспособность ML‑модели на сервере и тянет ресурсы (CPU/GPU).Другие API / сервисы — например, доступные через HuggingFace Hub, Cohere, и пр. В нашем примере будем считать, что используем OpenAI для простоты.
Выбор модели и размерность. Я возьму text-embedding-ada-002
— она очень распространена для семантического поиска. Она возвращает вектор из 1536 значений (float). Есть и другие: более старые модели OpenAI (например, Curie) возвращали 4096-мерные, а новые могут ужимать до 768 или 512 без сильной потери качества. Но мы не будем сейчас уменьшать размер — используем 1536, раз уж колонку такую создали. Имейте в виду: чем больше размерность, тем больше памяти кушает один эмбеддинг и тем медленнее сравнение, но зачастую более высокое качество поиска.
PHP‑код для получения эмбеддинга. В Laravel нет из коробки клиента OpenAI, но есть несколько пакетов и просто HTTP‑клиенты. Я покажу понятный способ через Guzzle HTTP:
use GuzzleHttp\Client;
class EmbeddingService
{
protected Client $http;
protected string $openaiApiKey;
public function __construct()
{
$this->http = new Client(['base_uri' => 'https://api.openai.com/v1/']);
$this->openaiApiKey = env('OPENAI_API_KEY');
}
public function getEmbedding(string $text): array
{
// Запрос к OpenAI Embeddings API
$response = $this->http->post('embeddings', [
'headers' => [
'Authorization' => 'Bearer ' . $this->openaiApiKey,
'Content-Type' => 'application/json',
],
'json' => [
'model' => 'text-embedding-ada-002',
'input' => $text,
],
]);
$data = json_decode((string) $response->getBody(), true);
if (!isset($data['data'][0]['embedding'])) {
throw new \RuntimeException('Не удалось получить эмбеддинг от OpenAI');
}
return $data['data'][0]['embedding']; // массив из 1536 чисел
}
}
Сервис делает POST‑запрос на /v1/embeddings
OpenAI, передавая текст. OpenAI вернёт JSON с полем data[0].embedding
. Мы достаём массив чисел и возвращаем. Конечно, нужно указать свой API‑ключ (через .env
или конфиг). Так же стоит добавить кеширование результатов, потому что генерация платная — вдруг один и тот же текст нужно векторизовать не раз.
Альтернативный подход — использовать готовую PHP‑библиотеку. Например, openai‑php/client способен вызывать все методы OpenAI API в удобной обёртке. Но суть та же: либо сами делаем HTTP запрос, либо через пакет.
Нормализация и хранение. Эмбеддинг — просто массив чисел. PostgreSQL ожидает вектор в виде VECTOR(1536)
. К счастью, Laravel умеет автоматически преобразовывать PHP‑массив в строку вида '[x1,x2,...,x1536]'
при вставке, если мы передадим его правильно. У нас column тип vector
, поэтому нужно либо пользоваться сырым запросом, либо кастомным типом. Чуть позже с этим разберёмся при сохранении.
Отмечу: некоторые предпочитают нормировать эмбеддинги (например, привести к единичной длине вектора), чтобы использовать косинусную близость вместо евклидовой. В pgvector есть разные операторы: <->
вычисляет евклидово расстояние (L2), а <=>
— косинусную схожесть (по сути, это 1 — cosine_similarity, где больше — ближе). Косинусная метрика удобна тем, что не зависит от длины векторов, а только от угла между ними. OpenAI‑эмбеддинги, например, обычно сравнивают косинусно (их часто нормируют). Однако для простоты можно и евклидово, результат ранжирования будет аналогичным, если все векторы одной модели. В нашей реализации возьмём оператор <->
(евклидово расстояние) — меньше значит ближе по смыслу.
Поиск ближайших эмбеддингов в Postgres
Теперь, имея в БД эмбеддинги, научим Postgres искать ближайшие к заданному. Самый простой способ: передать pgvector‑оператору нужный вектор и сделать ORDER BY embedding <-> :query_vector ASC
— то есть отсортировать записи по расстоянию до нашего запроса. Ближайшая (самая маленькая дистанция) — самая релевантная по смыслу.
Пример SQL‑запроса:
SELECT id, title, content
FROM articles
ORDER BY embedding <-> '[0.123, 0.456, ... , -0.789]' -- 1536 чисел внутри
LIMIT 5;
Здесь строка в квадратных скобках — литерал вектора (не забудьте кавычки). Такое ORDER BY ... <-> ...
вернёт топ-5 статей, наиболее близких к заданному вектору по евклидову расстоянию.
Конечно, писать все 1536 чисел вручную нереально — но наш код на PHP как раз будет генерировать этот вектор программно. В чистом SQL это для демонстрации.
В Laravel Query Builder можно сделать так:
$vector = /* массив 1536 чисел, полученный от EmbeddingService */;
$placeholders = implode(',', array_fill(0, count($vector), '?'));
$queryVectorLiteral = '[' . $placeholders . ']';
$results = DB::select(
"SELECT id, title, content, embedding <-> ?::vector(1536) AS distance
FROM articles
ORDER BY embedding <-> ?::vector(1536)
LIMIT 5",
[
'{' . implode(',', $vector) . '}', // PG понимает vector из format: {x,y,...}
'{' . implode(',', $vector) . '}'
]
);
Да, выглядит немного громоздко из‑за передачи массива. Кстати, обратите внимание на трюк: передаем вектор как строку '{...}'
— PostgreSQL распарсит это в vector, если добавить приведение ::vector(1536)
. На самом деле, можно и проще: $results = Article::orderByRaw('embedding <-> ?', [$vectorLiteral])->take(5)->get();
при условии, что $vectorLiteral
— готовая строка [x,y,...]
. Но у Laravel могут возникнуть сложности с подстановкой массива как параметра. Поэтому иногда используют DB::select
для подобного запроса.
Про расстояние и сортировку. В результате запроса можно также получать значение расстояния. Я добавил в SELECT embedding <-> ? AS distance
. Это полезно: можно показать пользователю «оценку схожести» или использовать программно. Чем больше расстояние — тем менее похож документ. Например, расстояние 0.27 ближе, чем 0.9 (векторы нормированы — диапазон примерно от 0 до 2 для косинуса, для евклида зависит от размерности).
Оптимизация поиска. Если записей много (тысячи и больше), полный перебор всех векторов при каждом запросе — штука тяжелая. К счастью, pgvector поддерживает Approximate Nearest Neighbors через специальный индекс IVFFLAT
. Смысл: вы можете построить индекс, который быстро приближённо ищет ближайшие векторы. Он жертвует точностью (может не идеально отсортировать или пропустить что‑то очень редкое), но значительно ускоряет запросы. Пример создания индекса:
-- Создаём индекс для L2 (евклидовых) расстояний на столбце embedding
CREATE INDEX articles_embedding_l2_idx
ON articles
USING ivfflat (embedding vector_l2_ops)
WITH (lists = 100);
Здесь lists = 100
— параметр, определяющий на сколько кластеров разобьются векторы внутри (подбирается экспериментально: например, ~N/1000 для N записей. Для cosine расстояния есть vector_cosine_ops
и vector_ip_ops
(inner product) варианты.
После создания такого индекса, чтобы запрос его задействовал, нужно установить сессионо переменную ivfflat.probes
— количество кластеров для проверки при поиске (чем больше, тем точнее и медленнее, обычно probes = lists/10
как старт). Пример использования в запросе:
SET ivfflat.probes = 10;
SELECT ... FROM articles
ORDER BY embedding <-> '[...]' LIMIT 5;
Но это лирика на будущее. В нашем демо‑приложении объем данных небольшой, так что обойдемся без индекса. Помните про эту возможность, если будете делать продакшн с тысячами документов.
Интеграция с Laravel Scout
Настало время подружить всё это с Laravel. Нам нужно, чтобы при сохранении новой статьи автоматически получался её эмбеддинг и сохранялся в БД, а поисковый запрос вызывал нужный SQL. Можно было бы «вручную» вызывать наш EmbeddingService
при каждом сохранении статьи и писать кастомный запрос на выборку. Но есть более элегантный путь — Laravel Scout.
Laravel Scout — это официальная библиотека Laravel, облегчающая реализацию поиска по моделям. Scout умеет интегрироваться с различными движками поиска (Algolia, MeiliSearch, Elastic и так далее). Он использует Model Observer: как только вы помечаете модель как Searchable, Scout следит за её событиями created/updated/deleted
и обновляет индекс поиска. Например, с Algolia — отсылает обновленный JSON. В нашем случае индекс — это Postgres, а обновлять надо эмбеддинги.
Scout поддерживает расширение через кастомные драйверы. Значит, мы можем написать свой драйвер «pgvector», который будет знать, как индексировать модели (генерировать и сохранять эмбеддинг) и как выполнять поиск (генерировать вектор запроса и делать SELECT).
Настройка Scout. Сначала устанавливаем Scout: composer require laravel/scout
. Дальше добавляем в config/scout.php
новый драйвер. Например, пропишем:
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'pgvector'),
Теперь Scout будет пытаться использовать pgvector
. Нам надо зарегистрировать реализацию. В App\Providers\AppServiceProvider::boot()
добавим:
use Laravel\Scout\EngineManager;
use App\ScoutEngines\PgvectorEngine;
public function boot(): void
{
resolve(EngineManager::class)->extend('pgvector', function($app) {
return new PgvectorEngine();
});
}
Здесь мы говорим Scout'у: «если драйвер = pgvector, используй наш класс PgvectorEngine
».
Реализация PgvectorEngine. Этот класс должен расширять Laravel\Scout\Engines\Engine
и реализовать ряд методов: update
, delete
, search
, paginate
, mapIds
, map
, getTotalCount
, flush
. Пугающе много, но для минимальной работоспособности достаточно реализовать ключевые: update
, delete
, search
, map
, mapIds
, getTotalCount
— остальное можно заглушками или пользоваться базовыми. Давайте набросаем упрощённую версию:
namespace App\ScoutEngines;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
use App\Services\EmbeddingService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class PgvectorEngine extends Engine
{
protected EmbeddingService $embedder;
public function __construct()
{
$this->embedder = new EmbeddingService();
}
public function update($models)
{
// Вызывается, когда модели созданы или изменены
/** @var \Illuminate\Database\Eloquent\Model $model */
foreach ($models as $model) {
// Получаем текст для индексации
$text = $model->toSearchableArray();
$combinedText = implode(' ', $text);
// Генерируем эмбеддинг
$vector = $this->embedder->getEmbedding($combinedText);
// Сохраняем эмбеддинг в БД
// Предположим, столбец embedding есть в самой таблице модели:
$model->embedding = $vector;
$model->save();
// В реальности тут нюанс: чтобы не зациклить Scout (сохранение вызовет update снова),
// можно отключить наблюдатель Scout на время или обновлять через Query.
// В целях упрощения опустим детали.
}
}
public function delete($models)
{
// Удалять эмбеддинг; если хранится в той же таблице, он удалится вместе с записью
// Если бы хранили отдельно, удалили бы связанный vector.
return;
}
public function search(Builder $builder)
{
// Выполняет поиск по запросу $builder->query
$query = $builder->query;
if (is_string($query)) {
// 1. Получаем эмбеддинг для строки запроса
$qVector = $this->embedder->getEmbedding($query);
} elseif (is_array($query)) {
// Если передали напрямую вектор (например, для "похожих статей"), допускаем это
$qVector = $query;
} else {
$qVector = [];
}
$vectorLiteral = '[' . implode(',', $qVector) . ']';
// 2. Выполняем SQL: получаем первичные ключи и расстояния
$table = $builder->model->getTable();
$key = $builder->model->getKeyName();
$limit = $builder->limit ?: 500; // если не указано ->get() или ->limit(), поставим некоторый максимум
$results = DB::select(
"SELECT {$key} as id, embedding <-> '{$vectorLiteral}' AS distance
FROM {$table}
ORDER BY embedding <-> '{$vectorLiteral}'
LIMIT {$limit}"
);
return $results;
}
public function mapIds($results)
{
// Извлекаем id моделей из результатов поиска
return collect($results)->pluck('id');
}
public function map(Builder $builder, $results, $model)
{
// Получаем сами модели по найденным id
$ids = $this->mapIds($results)->all();
if (count($ids) === 0) {
return Collection::make();
}
// Сохраняем порядок сортировки по расстоянию
$orderedIds = implode(',', $ids);
$models = $model->whereIn($model->getKeyName(), $ids)
->orderByRaw(DB::getTablePrefix().$model->getTable().'.'.$model->getKeyName()." FIELD({$model->getKeyName()}, {$orderedIds})")
->get();
return $models;
}
public function getTotalCount($results)
{
return count($results);
}
public function paginate(Builder $builder, $perPage, $page)
{
// Можно реализовать постраничный выбор (LIMIT/OFFSET), опустим для краткости.
return $this->search($builder);
}
public function flush($model)
{
// Очистка индекса модели - можно удалить все эмбеддинги (TRUNCATE), нечасто нужно
return;
}
}
Я немного упростил (например, orderByRaw FIELD()
— это не Portable SQL, но идея ясна: сохранить сортировку результатов). Важно: toSearchableArray()
обычно возвращает массив данных модели, которые нужно индексировать. Мы объединяем их в строку, получаем эмбеддинг и сохраняем.
Обратите внимание на тонкий момент: внутри update()
я делаю $model->save()
. Но сохранение модели, помеченной Searchable
, снова вызовет Scout. Чтобы избежать бесконечного цикла, в реальном мире надо отключать наблюдатель Scout при обновлении самого поля embedding. Например, можно воспользоваться фасадом Scout: Model::withoutSyncingToSearch(fn() => $model->save());
— тогда Scout не будет реагировать на этот save. Такие детали опускаю, но помните про них.
Метод search()
в нашем движке делает то, что раньше делали руками: берет запрос, превращает в вектор, и выполняет SQL с сортировкой по <->
. Он возвращает «сырые» результаты (я выбрал возвращать массив объектов с полями id
и distance
).
Scout ожидает, что search()
вернёт абстрактный результат, который потом пойдёт в map()
и mapIds()
. Мы можем вернуть хоть массив, хоть объект — у нас массив. В mapIds
мы достаем список найденных ID. В map
по этим id загружаем реальные модели из БД. Тут я схитрил с сохранением порядка: нам же важно, чтобы первый элемент списка имел наименьшую дистанцию. В SQL мы отсортировали, но когда делаем $model->whereIn()->get()
, порядок не гарантируется. Поэтому я вручную строю FIELD(id, id1, id2, ...)
— этот трюк работает в MySQL, а в Postgres можно иначе: использовать ORDER BY CASE id WHEN ... THEN ... END
. Но чтобы не перегружать, можно просто использовать пакет spatie/laravel-collection-macros
и метод Collection::sortByIds($ids)
... Ладно, не будем отклоняться. Главное — мы возвращаем коллекцию моделей.
getTotalCount
— возвращаем общее число результатов (у нас равно количеству id, так как весь набор уже ограничен LIMIT
). paginate
— можно реализовать через OFFSET, но для семантического поиска обычно используют просто LIMIT N
и «подгрузи ещё». Оставим простую реализацию. flush
— для полноты, но мы не будем удалять все эмбеддинги на практике.
Подключение модели. Теперь, когда Scout готов, нужно модель пометить как Searchable. Допустим, у нас есть модель Article
:
use Laravel\Scout\Searchable;
class Article extends Model
{
use Searchable;
// ...
public function searchableAs(): string
{
return 'articles'; // имя индекса, можем назвать как таблицу
}
public function toSearchableArray(): array
{
// Какие поля пускать на вход модели эмбеддинга
return [
'title' => $this->title,
'content' => $this->content
];
}
}
Trait Searchable
подключает модель к Scout. Метод toSearchableArray()
определяет, что именно из модели нужно брать для индексирования — обычно текстовые поля. Мы возвращаем массив с заголовком и контентом (можно и одно поле content, но иногда заголовок тоже несёт инфу). Scout сам вызовет этот метод внутри нашего Engine при апдейте модели.
Также модель теперь получает методы search()
статический и пр. Например, Article::search('запрос')
будет создавать Scout Builder, который в итоге дернет наш PgvectorEngine@search
.
Проверяем, как это работает. Когда мы, например, создадим новую Article через Eloquent: $article = Article::create([...]);
:
Scout (Searchable trait) улавливает событие
created
и вызываетScout::queueUpdate
/update
с этой моделью.Scout найдёт наш движок pgvector и вызовет
PgvectorEngine::update
с коллекцией моделей (у нас одна).Наш update сгенерирует эмбеддинг и сохранит его.
Теперь запись в БД имеет заполненный столбец
embedding
.
Когда мы вызываем поиск: Article::search('что-то')->get()
:
Scout создает Builder с query = «что‑то».
Вызывает
PgvectorEngine::search($builder)
.Мы генерируем эмбеддинг запроса, делаем SELECT с ORDER BY <→.
Получаем список подходящих статей (id + расстояние).
Scout затем вызывает
map()
— мы загружаем модели по этим id.Возвращается коллекция Article моделей, обёрнутая в
ScoutBuilder
результат. Мы получаем ее как Eloquent Collection, на ней даже пагинацию можно вызвать или пройтись.
Красота в том, что нам в контроллере не надо писать SQL и вызывать сервисы вручную — всё спрятано в движке Scout. Мы просто работаем с моделью.
Наполняем и ищем
Давайте теперь на практике соберём мини‑приложение. Допустим, у нас Laravel‑проект с миграцией для articles
(как выше) и настроенным Scout. Осталось загрузить данные и выполнить запрос.
Seeder для статей. Предположим, у нас есть парочка текстов, по которым будем искать. Можно написать сидер, который создаст записи и их эмбеддинги:
use Illuminate\Database\Seeder;
use App\Models\Article;
class ArticleSeeder extends Seeder
{
public function run()
{
$articles = [
['title' => 'Рецепт блинов', 'content' => 'Как приготовить вкусные блины на завтрак...'],
['title' => 'Обзор смартфона', 'content' => 'Сегодня мы рассмотрим новейший смартфон...'],
['title' => 'Яичница с беконом', 'content' => 'Классический рецепт яичницы с беконом на утро...'],
['title' => 'Новости технологий', 'content' => 'Последние тренды в области ИИ и машинного обучения...'],
];
foreach ($articles as $data) {
// Создаст модель и вызовет Scout->update -> эмбеддинг будет сгенерирован
Article::create($data);
}
}
}
Здесь 2 статьи про завтраки (блины, яичница) и 2 не про еду (смартфон, технологии). После запуска сидера (например, через php artisan db:seed --class=ArticleSeeder
), база будет заполнена и эмбеддинги рассчитаны (Scout автоматически в update их сделал).
Демо API для поиска. Сделаем конечную точку, куда пользователь отправляет строку, а мы возвращаем JSON с найденными статьями. В routes/web.php
или API маршрутах:
use App\Models\Article;
Route::get('/search', function(\Illuminate\Http\Request $request) {
$query = $request->query('q');
if (!$query) {
return response()->json(['error' => 'Нет запроса'], 400);
}
// Ищем через Scout
$articles = Article::search($query)->get();
// Формируем ответ (включим дистанцию для интереса)
$result = $articles->map(function(Article $article) {
return [
'id' => $article->id,
'title' => $article->title,
'distance' => $article->embedding_distance ?? null,
// Примечание: embedding_distance у нас явно не сохранено в модели,
// но можно было через selectRaw вернуть. Тут для упрощения опустим.
];
});
return response()->json($result);
});
Теперь, отправив запрос GET /search?q=завтрак
, мы должны получить статьи про блины и яичницу в топе. Почему? Потому что мы превратим слово «завтрак» в эмбеддинг, а он будет ближе всего к эмбеддингам статей про утреннюю еду. Статьи про технологии окажутся далеко в векторном пространстве.
Если бы мы сделали запрос /search?q=смартфон
, то, напротив, статья «Обзор смартфона» вылезет первой, даже если в тексте запрос может быть не буквально слово «смартфон», а близкие понятия.
Для наглядности, можем вывести и расстояние. В нашем движке search()
мы уже считали embedding <-> query
как distance. Мы не сохраняли его в модель, но мы могли бы сделать lifehack: временно положить расстояние в атрибут модели, например, в map()
присвоить $model->embedding_distance = $distance
. Тогда в JSON мы бы его вернули. В пакете Ben Bjurstrom это реализовано: он сохраняет дистанцию в связанной модели Embedding и отдает как neighbor_distance
.
Результат работы. Если все настроено верно, семантический поиск оживает. Вы можете задать любой осмысленный запрос — и получите релевантные результаты даже без точного совпадения слов. В приложении, похожем на отзывы о еде, запрос «завтрак» найдет упоминания блинов, вафель, каши; запрос «камера» найдет статью про смартфон с хорошей камерой и так далее
Заключение
Стоит отметить, что в продакшене нужно учесть несколько моментов:
API‑ключи и лимиты: запросы не бесплатны, оптимизируйте число вызовов.
Очереди: генерацию эмбеддингов при обновлении лучше выносить в очередь, чтобы не тормозить основной поток. Scout это поддерживает (достаточно включить
queue
в настройках).Безопасность: храните API ключи в безопасном месте, не логируйте лишнего. Векторные поиски сами по себе безопасны, но помните, что эмбеддинг может частично восстанавливать исходный текст (потенциально утечка данных, если храните чувствительное).
Синхронизация: если много моделей или сложные связки, следите, чтобы Scout индексировал то, что надо, и не дублировал (наш пример с
withoutSyncingToSearch
для embed).Версионность эмбеддингов: когда обновляется модель генерации (например, OpenAI выпустит Ada-003 с другой размерностью или просто другие координаты), придется пересчитать все эмбеддинги заново для консистентности. Продумайте миграцию (например, хранить версию модели/эмбеддинга, иметь возможность пересчитать оффлайн).
Но все эти трудности решаемы. Зато взамен вы получаете умный поиск. Не бойтесь экспериментировать с AI‑инструментами в своих проектах — это проще, чем кажется, особенно когда есть такие штуки, как pgvector, делающие тяжёлую работу за нас.
Если вам близок подход Laravel — ясный код, лаконичная архитектура и внимание к деталям — стоит углубиться в сам фреймворк системно. На курсе Framework Laravel вы шаг за шагом разберёте внутренние механизмы: от контейнера зависимостей и сервис‑провайдеров до очередей, событий и middleware.
На странице курса можно записаться на бесплатные уроки, а также пройти вступительный тест — чтобы узнать, подойдет ли вам программа.