Это вторая часть статьи.
Часть 1: Практика без Python и data science

AI в PHP: не теория, а место, с которого можно начать

В своей прошлой статье я описал на довольно общем уровне почему тема AI вроде бы везде, но при этом почти не пересекается с повседневной PHP-разработкой. Не потому что PHP "не подходит", а потому что сам разговор обычно идёт мимо наших задач и привычного способа мышления. Ну и, конечно, о том, что почти нет материала, который объясняет AI именно для PHP-разработчиков, их задач и их мышления.

После публикации мне несколько раз задали один и тот же вопрос, в разных формулировках:

Окей, допустим. А с чего конкретно начать?

И это, пожалуй, самый интересный вопрос из тех, что я получил. Ниже я попытаюсь дать на него ответ.

Про "точку входа"

Когда вы слышите "использование AI в проекте", скорее всего в вашей голове сразу возникает слишком много лишнего: инфраструктура, обучение моделей, эксперименты, отдельные сервисы, новые роли в команде и т.д.

Но если честно, в большинстве PHP-проектов нам это просто не нужно (хотя разобраться в этом самому - жутко интересная задача).

Но всё же, нам с вами не нужно:

  • обучать модели с нуля

  • разбираться в градиентном спуске

  • собирать датасеты и т.д.

Нужно понять, что AI ≠ Data Science. Друзья, в большинстве PHP-проектов никто не обучает модели, пожалуйста, запомните это! По крайней мере в прикладных PHP-проектах, где мы говорим об использовании готовых моделей, а не об исследованиях и обучении.

Нам нужно вз��ть готовый инструмент и аккуратно встроить его в уже существующую логику. Так же, как мы это делаем с любой другой библиотекой.

И вот тут начинается интересное.

Что это вообще за зверь такой TransformersPHP и с чем его едят?

Про использование LLM через API знают уже все. Это полезно и удобно, но в таком виде AI остаётся чем-то внешним: сервисом, к которому ты просто отправляешь текст. Внутри – туман.

В какой-то момент мне захотелось посмотреть на более "приземлённый" уровень: не генерация текста, а представление семантики: эмбеддинги, задачи классификации и поиска. Именно на этом уровне решается большинство прикладных задач в бэкенд-системах: поиск по тексту, сравнение и автоматическая классификация, а не диалоговое взаимодействие человека с моделью.

И здесь неожиданно выяснилось, что есть инструменты, которые позволяют делать это напрямую в PHP, без Python-стека. Один из них – TransformersPHP.

Важно сразу понять: это не попытка превратить PHP в Python и не универсальное решение. Это библиотека для inference (инференс) – использования уже обученных моделей.

Как по мне, TransformersPHP – один из самых интересных и показательных проектов в современной PHP ML-экосистеме. Отдельное спасибо его автору - Kyrian Obikwelu за то что создал этот проект и продолжает над ним работать. В общем это библиотека, которая позволяет использовать трансформер-модели (BERT, RoBERTa, DistilBERT и др.) напрямую из PHP, без Python и без внешних API.

По сути, это PHP-ориентированная обертка над идеями Hugging Face Transformers, адаптированная под PHP-экосистему и реальные прикладные сценарии.

Ключевая особенность библиотеки – локальный инференс. Модели загружаются и выполняются на стороне PHP-приложения (через ONNX Runtime), что открывает важные архитектурные возможности:

  • отсутствие сетевых вызовов к LLM API

  • полный контроль над данными (это может быть важно для privacy (конфиденциальности) в вашей работе)

  • предсказуемое и стабильное время обработки запроса

  • возможность оффлайн-работы (после первого запуска и загрузки модели)

TransformersPHP поддерживает типовые задачи NLP, такие как: получение эмбеддингов, классификацию текста, семантическое сравнение и прочее.

Что мне нравится больше всего

Самое ценное ощущение – отсутствие разрыва контекста.

Я остаюсь в PHP, я пишу тот же код, у меня тот же деплой и у меня те же подходы к архитектуре. Ничего не изменилось. Модель для меня в этом случае – не некий "магический объект", а просто ещё один источник данных. Да, непривычный, но вполне объяснимый - не хуже и не лучше других.

И это сильно меняет отношение к всей теме. Вы согласны?

Запуск модели за 10 минут

Один из самых важных моментов – это первый запуск.
Если он сложный, на этом всё обычно и заканчивается.

В случае с TransformersPHP ощущение как раз обратное: это больше похоже на работу с обычной зависимостью.

Полное руководство по установке можно найти на сайте документации.

Мы же с вами для простоты запустим докер контейнер со всеми необходимыми зависимостями. Да, Docker file выглядит объёмно – но это одноразовая инфраструктура. Сам запуск и первый демо-пример действительно укладываются в несколько минут.

Что нам нужно (требования)

  • PHP 8.1 или выше

  • Composer

  • Расширение PHP FFI

  • JIT-компиляция (опционально, для повышения производительности)

  • Увеличенный лимит памяти (для сложных задач, таких как генерация текста)

Структура проекта

/project/
  ├── app/
  │    ├── demo.php
  │    ├── semantic-search.php
  ├── docker/
  │    ├── Dockerfile
  ├── docker-composer.yaml
  ├── composer.json

Установка (Docker)

Файл: docker-compose.yml
networks:
  ai-for-php-developers:
    driver: bridge

services:
  app:
    build:
      context: .
      dockerfile: docker/Dockerfile
    volumes:
      - .:/var/www
    ports:
      - "8088:8088"
    command: php -S 0.0.0.0:8088 -t app
    networks:
      - ai-for-php-developers
Файл: docker/Dockerfile
# ------------------------------
# Install system dependencies
# ------------------------------
RUN apt-get update && apt-get install -y \
    libzip-dev \
    zip \
    unzip \
    git \
    libxml2-dev \
    libcurl4-openssl-dev \
    libpng-dev \
    libonig-dev \
    && rm -rf /var/lib/apt/lists/*

# ------------------------------
# Install PHP extensions
# ------------------------------
RUN docker-php-ext-install zip pdo_mysql bcmath xml mbstring curl gd pcntl

# ------------------------------
# Enable FFI
# ------------------------------
RUN apt-get update && apt-get install -y \
    libffi-dev \
    pkg-config \
    && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install ffi
RUN echo "ffi.enable=1" > /usr/local/etc/php/conf.d/ffi.ini

# ------------------------------
# Install ONNX Runtime
# ------------------------------
ENV ONNXRUNTIME_VERSION=1.17.1

RUN curl -L https://github.com/microsoft/onnxruntime/releases/download/v${ONNXRUNTIME_VERSION}/onnxruntime-linux-x64-${ONNXRUNTIME\_VERSION}.tgz \
    | tar -xz \
    && cp onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}/lib/libonnxruntime.so* /usr/lib/ \
    && ldconfig \
    && rm -rf onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}


# ------------------------------
# Install Composer
# ------------------------------
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Set working directory
WORKDIR /var/www

# Copy existing application directory contents
COPY . /var/www

# Configure PHP
RUN echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/docker-php-ram-limit.ini
RUN echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/docker-php-max-execution-time.ini

# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]

И для установки самого TransformersPHP

Файл: composer.json
{
    "type": "project",
    "minimum-stability": "stable",
    "prefer-stable": true,
    "require": {
        "codewithkyrian/transformers": "~0.6.2"
    },
    "config": {
        "allow-plugins": {
            "codewithkyrian/platform-package-installer": true
        }
    }
}

Команда для запуска окружения

docker compose build --pull
docker compose up -d
docker compose exec app /bin/bash -c "composer install"

Идея простая: чтобы пример можно было поднять локально без ручной настройки окружения.

Базовый демо-пример

Начнём с самого простого: анализа настроений.

Если всё, что описано выше сработало хорошо и установка прошла нормально, можно запустить пример, описанный ниже (примите во внимание, что первый запуск может занять несколько секунд):

docker compose exec app php app/demo.php

Пример использования выглядит концептуально просто: вы загружаете предобученную модель и применяете ее к тексту так же, как это делали бы в Python – но уже внутри PHP-кода. TransformersPHP предлагает простой pipeline API для задач вроде анализа настроений, классификации текста, семантического сравнения и т.д. В примере ниже модель определяет тональность двух фраз и показывает метку и score.

Файл: app/demo.php

require_once __DIR__ . '/../vendor/autoload.php';

use function Codewithkyrian\Transformers\Pipelines\pipeline;

// Выделить конвейер для анализа настроений
$classifier = pipeline('sentiment-analysis');

$out = $classifier(['I love transformers!']);
echo 'I love transformers!';
echo print_r($out, true);

$out = $classifier(['I hate transformers!']);
echo 'I hate transformers!';
echo print_r($out, true);

Результат, конечно, же вполне ожидаемый:

I love transformers!
Array ( 
  [label] => POSITIVE 
  [score] => 0.99978870153427 
)

I hate transformers!
Array ( 
  [label] => NEGATIVE 
  [score] => 0.99863630533218 
)

Цель этого примера – снять у вас психологический барьер: эта модель в PHP — это просто ещё один объект, с которым можно работать.

Этот же пример можно запустить онлайн.

Важно понимать архитектурную роль TransformersPHP.

Эта библиотека не конкурирует с большими LLM-сервисами вроде GPT или Claude. Она закрывает другой, очень важный слой:

  • быстрые эмбеддинги

  • локальная классификация

  • семантический поиск

  • lightweight NLP без внешних зависимостей

В связке с PHP это выглядит особенно логично. PHP остается центральным слоем бизнес-логики, а трансформеры становятся встроенным инструментом, а не удаленным сервисом. TransformersPHP – это хороший пример того, как современный ML постепенно перестает быть "чужим" для PHP и становится частью его нативной экосистемы, пусть и через аккуратные инженерные мосты вроде ONNX.

Реальный кейс: семантический поиск по событиям

Учебные примеры хороши, но быстро надоедают. Гораздо интереснее посмотреть на задачу, которая реально встречается в бэкенде. Сейчас мы с вами сделаем кое-что поинтересней.

Для запуска этого примера используйте следующую команду

docker compose exec app php app/semantic-search.php

Сценарий

Есть события с коротким текстовым описанием. Пользователь ищет, например:

  • "санкции против IT-компаний"

  • "космическая гонка среди стран региона"

При этом, как вы понимаете, в самих данных таких формулировок может не быть вообще. Классический поиск по словам здесь начинает усложняться: синонимы, морфология, разные языки, костыли поверх костылей и прочее.

Небольшое мысленное упражнение

Допустим, у нас есть лента событий или материалов, где каждое событие описано парой предложений. Что-то вроде:

  • введены новые ограничения в отношении технологических корпораций

  • страны региона наращивают инвестиции в спутниковые программы

  • обострение конфликта на политической почве в нескольких провинциях

Теперь пользователь вводит запрос: "космическая гонка среди стран региона".

Ни одно из этих слов буквально не обязано встречаться в описании событий. Упс… И это нормально – люди редко формулируют мысли так же, как их описывают системы.

Идея решения

Вместо того чтобы пытаться угадать слова, можно попробовать искать по смыслу. Не в философском смысле, а в инженерном:

  • описание события → вектор,

  • запрос пользователя → вектор,

  • дальше – обычный поиск ближайших значений.

То есть, нам нужно использовать эмбеддинги как универсальный индекс смысла.

Таким образом мы:

  1. Берём массив событий {id, title, description}

  2. Считаем эмбеддинг только по description (заголовок часто слишком короткий, неинформативный и может добавлять шум)

  3. Эмбеддим пользовательский запрос

  4. Ищем ближайшие векторы

  5. Сортируем и возвращаем результат

Без обучения моделей и без сложной инфраструктуры. Эмбеддинги для событий считаются один раз и могут храниться где угодно. Запрос пользователя обрабатывается в момент поиска. Дальше – сортировка и вывод результатов.

И... вуаля!
Никакого обучения моделей.
Никакой магии.
Просто другой способ представить текст.

Логика работы

Поместим логику работы в отдельный класс SemanticEventSearch. Этот класс не претендует на звание лучшего кода на планете, и написан только в в демонстрационных целях - поэтому опустим замечания по его качеству.

Класс SemanticEventSearch
final class SemanticEventSearch
{
    private string $model = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
    private string $cachePath;
    private string $defaultQuery = 'санкции против IT-компаний';
    private int $topN;

    private ?string $query = null;

    /** @var list<array{id:int,title:string,description:string}> */
    private array $events;

    /** @var array<int, list<float|int>> */
    private array $eventEmbeddingsById = [];

    private $embedder;

    /**
     * Create a new semantic search instance.
     *
     * @param int $topN Number of results to return.
     */
    public function __construct(int $topN = 3)
    {
        $this->cachePath = __DIR__ . '/../embeddings.events.json';
        $this->events = [];
        $this->embedder = null;
        $this->topN = $topN;
    }

    /**
     * Inject events that will be indexed/searched.
     *
     * @param list<array{id:int,title:string,description:string}> $events
     * @return $this
     */
    public function setEvents(array $events): self
    {
        $this->events = $events;
        $this->eventEmbeddingsById = [];
        return $this;
    }

    /**
     * Set the embeddings model identifier.
     *
     * Switching model invalidates in-memory embeddings.
     *
     * @param string $model
     * @return $this
     */
    public function setModel(string $model): self
    {
        $this->model = $model;
        $this->embedder = null;
        $this->eventEmbeddingsById = [];
        return $this;
    }

    /**
     * Set the query to be searched.
     *
     * @param string $query
     * @return $this
     */
    public function setQuery(string $query): self
    {
        $q = trim($query);
        $this->query = $q === '' ? null : $q;
        return $this;
    }

    /**
     * Run the end-to-end semantic search pipeline (cache -> embed query -> score -> top-N).
     *
     * @return array{query:string,results:list<array{score:float,event:array{id:int,title:string,description:string}}>}
     * @throws RuntimeException If events are not set or embeddings output is unexpected.
     */
    public function run(): array
    {
        if (count($this->events) === 0) {
            throw new RuntimeException('Events list is empty. Call setEvents() before run().');
        }

        if ($this->embedder === null) {
            $this->embedder = pipeline('embeddings', $this->model);
        }

        $this->loadEmbeddingsFromCacheIfCompatible();
        $this->ensureAllEventEmbeddings();

        $query = $this->query ?? $this->defaultQuery;
        $queryVec = $this->embedText($query);

        $results = $this->search($queryVec);
        return [
            'query' => $query,
            'results' => $results,
        ];
    }

    /**
     * Compute an embedding vector for a single text.
     *
     * @param string $text
     * @return list<float|int>
     * @throws RuntimeException
     */
    private function embedText(string $text): array
    {
        $emb = ($this->embedder)($text, normalize: true, pooling: 'mean');
        if (!is_array($emb) || !isset($emb[0]) || !is_array($emb[0])) {
            throw new RuntimeException('Unexpected embeddings output format');
        }

        return $emb[0];
    }

    /**
     * Cosine similarity between two vectors.
     *
     * @param list<float|int> $a
     * @param list<float|int> $b
     * @return float
     */
    private function cosineSimilarity(array $a, array $b): float
    {
        $n = min(count($a), count($b));

        $dot = 0.0;
        $normA = 0.0;
        $normB = 0.0;

        for ($i = 0; $i < $n; $i++) {
            $x = (float) $a[$i];
            $y = (float) $b[$i];

            $dot += $x * $y;
            $normA += $x * $x;
            $normB += $y * $y;
        }

        if ($normA <= 0.0 || $normB <= 0.0) {
            return 0.0;
        }

        return $dot / (sqrt($normA) * sqrt($normB));
    }

    /**
     * Load a JSON file and decode to array.
     *
     * @param string $path
     * @return array|null
     */
    private function loadJsonFile(string $path): ?array
    {
        if (!is_file($path)) {
            return null;
        }

        $raw = file_get_contents($path);
        if ($raw === false) {
            return null;
        }

        $data = json_decode($raw, true);
        return is_array($data) ? $data : null;
    }

    /**
     * Encode and save data to JSON file.
     *
     * @param string $path
     * @param array $data
     * @throws RuntimeException
     */
    private function saveJsonFile(string $path, array $data): void
    {
        $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        if ($json === false) {
            throw new RuntimeException('Failed to encode JSON');
        }

        $ok = file_put_contents($path, $json);
        if ($ok === false) {
            throw new RuntimeException('Failed to write cache file: ' . $path);
        }
    }

    /**
     * Load cached event embeddings only if they were produced by the current model.
     *
     * @return void
     */
    private function loadEmbeddingsFromCacheIfCompatible(): void
    {
        $cached = $this->loadJsonFile($this->cachePath);

        if (!is_array($cached) || !isset($cached['model'], $cached['events']) || !is_array($cached['events'])) {
            return;
        }

        if ($cached['model'] !== $this->model) {
            return;
        }

        foreach ($cached['events'] as $row) {
            if (isset($row['id'], $row['embedding']) && is_array($row['embedding'])) {
                $this->eventEmbeddingsById[(int) $row['id']] = $row['embedding'];
            }
        }
    }

    /**
     * Ensure embeddings exist for all events and persist them to cache.
     *
     * @return void
     * @throws RuntimeException
     */
    private function ensureAllEventEmbeddings(): void
    {
        $missing = [];
        foreach ($this->events as $event) {
            $id = (int) $event['id'];
            if (!isset($this->eventEmbeddingsById[$id])) {
                $missing[] = $event;
            }
        }

        if (count($missing) === 0) {
            return;
        }

        foreach ($missing as $event) {
            $id = (int) $event['id'];
            $text = (string) $event['description'];

            $this->eventEmbeddingsById[$id] = $this->embedText($text);
        }

        $toCache = [
            'model' => $this->model,
            'events' => array_values(array_map(
                fn(array $event): array => [
                    'id' => (int) $event['id'],
                    'embedding' => $this->eventEmbeddingsById[(int) $event['id']],
                ],
                $this->events
            )),
        ];

        $this->saveJsonFile($this->cachePath, $toCache);
    }

    /**
     * Score all events against the query embedding and return the top-N results.
     *
     * @param list<float|int> $queryVec
     * @return list<array{score:float,event:array{id:int,title:string,description:string}}>
     */
    private function search(array $queryVec): array
    {
        $scored = [];

        foreach ($this->events as $event) {
            $id = (int) $event['id'];
            $score = $this->cosineSimilarity($queryVec, $this->eventEmbeddingsById[$id]);

            $scored[] = [
                'score' => $score,
                'event' => $event,
            ];
        }

        usort($scored, static fn(array $a, array $b): int => $b['score'] <=> $a['score']);

        return array_slice($scored, 0, $this->topN);
    }

    /**
     * Render results as plain text.
     *
     * @param string $query
     * @param list<array{score:float,event:array{id:int,title:string,description:string}}> $results
     * @return void
     */
    public function render(string $query, array $results): void
    {
        echo "Query: {$query}\n\n";
        foreach ($results as $row) {
            $event = $row['event'];
            $score = (float) $row['score'];

            echo "[" . number_format($score, 4) . "] #{$event['id']} {$event['title']}\n";
            echo "  {$event['description']}\n\n";
        }
    }
}

Подготовим данные

Предположим, что это наши данные, собранные из разных источников. Для простоты поместим их в массив.

Массив $events
$events = [
    [
        'id' => 1,
        'title' => 'Ограничения против технологических корпораций',
        'description' => 'Введены новые экономические меры в отношении крупных технологических компаний.',
    ],
    [
        'id' => 2,
        'title' => 'Развитие космических программ',
        'description' => 'Несколько стран региона увеличили финансирование национальных спутниковых проектов.',
    ],
    [
        'id' => 3,
        'title' => 'Эскалация политического конфликта',
        'description' => 'Обострение конфликта на политической почве в нескольких провинциях.',
    ],
    [
        'id' => 4,
        'title' => 'Ограничения против ИТ-сектора',
        'description' => 'Правительство объявило о новых ограничениях для компаний, работающих в сфере информационных технологий.',
    ],
    [
        'id' => 5,
        'title' => 'Рост инфляции и пересмотр ключевой ставки',
        'description' => 'Центральный банк повысил ключевую ставку на фоне ускорения инфляции и роста цен на импортные товары.',
    ],
    [
        'id' => 6,
        'title' => 'Запуск программы поддержки малого бизнеса',
        'description' => 'Власти объявили о льготных кредитах и налоговых послаблениях для малого и среднего бизнеса в регионах.',
    ],
    [
        'id' => 8,
        'title' => 'Утечка данных в сфере онлайн-ритейла',
        'description' => 'Интернет-магазин расследует утечку персональных данных клиентов после компрометации учётных записей сотрудников.',
    ],
    [
        'id' => 9,
        'title' => 'Прорыв в медицине: новый метод диагностики',
        'description' => 'Исследователи представили метод ранней диагностики заболеваний по биомаркерам, сокращающий время анализа.',
    ],
    [
        'id' => 10,
        'title' => 'Сезонный рост заболеваемости',
        'description' => 'В нескольких городах отмечен рост заболеваемости респираторными инфекциями, клиники усилили приём пациентов.',
    ],
    [
        'id' => 12,
        'title' => 'Засуха и риски для сельского хозяйства',
        'description' => 'Из-за продолжительной засухи фермеры прогнозируют снижение урожайности, обсуждаются меры поддержки аграриев.',
    ],
    [
        'id' => 13,
        'title' => 'Финал крупного спортивного турнира',
        'description' => 'В решающем матче сезона команда одержала победу в дополнительное время, установив новый рекорд по посещаемости.',
    ],
    [
        'id' => 14,
        'title' => 'Трансфер игрока и усиление состава',
        'description' => 'Клуб подписал контракт с новым нападающим, рассчитывая усилить атакующую линию перед серией дерби.',
    ],
    [
        'id' => 15,
        'title' => 'Новые правила для маркетплейсов',
        'description' => 'Регулятор предложил требования к маркировке товаров и прозрачности комиссий на торговых онлайн-платформах.',
    ],
    [
        'id' => 17,
        'title' => 'Сбои в поставках полупроводников',
        'description' => 'Производители электроники предупредили о задержках поставок чипов из-за ограничений экспорта и перегрузки заводов.',
    ],
    [
        'id' => 18,
        'title' => 'Открытие фестиваля современного искусства',
        'description' => 'В столице стартовал фестиваль современного искусства с выставками, перформансами и лекциями художников.',
    ],
    [
        'id' => 19,
        'title' => 'Крупная сделка на рынке недвижимости',
        'description' => 'Инвестфонд приобрёл портфель коммерческой недвижимости, планируя реконструкцию и повышение энергоэффективности.',
    ],
    [
        'id' => 20,
        'title' => 'Исследование океана и новые данные',
        'description' => 'Научная экспедиция собрала данные о течениях и температуре воды, уточнив прогнозы по изменению климата.',
    ],
];

Использование примера

Здесь всё просто - запускаем наш код и ждём результата.

require_once __DIR__ . '/../vendor/autoload.php';

use function Codewithkyrian\Transformers\Pipelines\pipeline;

final class SemanticEventSearch {...}

$events = [...];

$query = 'санкции против IT-компаний';

$search = new SemanticEventSearch(topN: 3);
$search->setModel('Xenova/paraphrase-multilingual-MiniLM-L12-v2');
$search->setEvents($events);
$search->setQuery($query);
$out = $search->run();
$search->render($out['query'], $out['results']);

Логика работы (по шагам)

Если вам не хочется разбираться в деталях реализации SemanticEventSearch, ниже – упрощённое пошаговое описание. Более подробный разбор кода выходит за рамки этой статьи.

Логика работы кода
  • Снаружи задаём:

    • список событий (setEvents)

    • модель (setModel)

    • запрос (setQuery)

    • topN через конструктор

  • Дальше — обычная логика в run():

    • Поднимаем embedder (pipeline('embeddings', model)) если ещё не поднят.

    • .transformers-cache:

      • при первом использовании модель и файлы токенизации/весов скачиваются и кладутся в .transformers-cache

      • дальше они берутся оттуда, чтобы не качать заново и работать быстрее

    • embeddings.events.json:

      • это наш локальный кэш эмбеддингов событий

      • пытаемся его прочитать и использовать только если model в кэше совпадает с текущей моделью

    • Если для каких-то событий эмбеддингов нет:

      • считаем эмбеддинги для description

      • сохраняем обратно в embeddings.events.json

    • Эмбеддим запрос (эмбеддинги нормализуются, чтобы косинусная близость была стабильной и сравнимой)

    • Считаем близость запроса к каждому событию (cosine similarity)

    • Сортируем, берём top‑N, возвращаем результаты

    • Рендер (вывод) делается снаружи через render(query, results)

Результат

На выходе мы получаем не совпадение слов, а совпадение по смыслу. Первый результат - наиболее подходящий по схожести с нашим запросом про "санкции против IT-компаний". И всё это - без сложной математики и без танцев с бубнами.

Query: санкции против IT-компаний

[0.4288] #4 Ограничения против ИТ-сектора
  Правительство объявило о новых ограничениях для компаний, работающих в сфере информационных технологий.

[0.3356] #15 Новые правила для маркетплейсов
  Регулятор предложил требования к маркировке товаров и прозрачности комиссий на торговых онлайн-платформах.

[0.2598] #8 Утечка данных в сфере онлайн-ритейла
  Интернет-магазин расследует утечку персональных данных клиентов после компрометации учётных записей сотрудников.

Где это можно применять в проде

В целом это уже похоже на продуктовый подход, а не на эксперимент. К тому же вы можете легко заметить, что такой подход:

  • слабо привязан к конкретной формулировке запроса

  • хорошо работает на коротких описаниях

  • легко комбинируется с обычными фильтрами (дата, регион, тип события)

То есть это не "AI ради AI", а вполне конкретная прикладная логика. Та самая, которую можно объяснить, отладить и поддерживать.

Ограничения и подводные камни

Важно не обманываться: это не серебряная пуля.

Модели весят немало. Производительность нужно учитывать. Некоторые задачи проще и надёжнее решаются без AI обычным SQL.

Но это уже нормальный инженерный разговор – про trade-off’ы, а не про магию или чёрный ящик.

Куда двигаться дальше

Как для меня, то TransformersPHP - это хороший пример того, что AI можно использовать напрямую в PHP-проектах без смены стека и без Python.

В своей книге "AI для PHP-разработчиков" (открытой и бесплатной) я как раз и разбираю подобные кейсы: где это имеет смысл, как выбирать модели и как не превратить проект в набор экспериментальных фич.

Ссылка на книгу "AI для PHP-разработчиков".

Кстати, все примеры можно скачать и запустить через готовую среду Docker.

Или же вы также можете запускать все примеры из книги напрямую.

Если тема откликается – буду рад обсуждению и фидбэку.
Особенно интересно, какие задачи вы уже решаете или хотели бы решать с помощью AI в PHP-проектах.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Был ли у вас опыт работы с TransformersPHP?
0%Да, использую регулярно0
25%Пробовал один или несколько раз1
0%Знаю о библиотеке, но не использовал0
75%Узнал о ней из этой статьи3
0%Не хочу об этом знать вообще0
Проголосовали 4 пользователя. Воздержавшихся нет.