Привет, Хабр!

Предлагаю сегодня разобраться, как семантический поиск появился в 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.

На странице курса можно записаться на бесплатные уроки, а также пройти вступительный тест — чтобы узнать, подойдет ли вам программа.

Комментарии (0)