Это статья - пример небольшого личного опыта, где я пытался решить одну чисто техническую задачу для одного из моих текущих проектов. Задача в конце-концов была решена, насколько правильно - не знаю, но надеюсь многим будет интересен и полезен мой опыт. Итак, небольшая драма в 5-ти актах.

Акт I. Экспозиция (жили-были)

Итак, недавно в одном из проектов над которым я работаю и где ядро написано на PHP возникла одна тривиальная некая задача. Если не вдаваться в детали самого проекта (вам будет неинтересно), то суть её можно описать следующим: на вход подаётся текст, а на выход нужно выдать NER.

Для тех, кто не знает - NER (Named Entity Recognition) - это задача из области NLP (Natural Language Processing) - на Хабре можно найти пару довольно подробных статей на этот счёт (например: тут, тут и ещё много). Её суть в том, чтобы находить в тексте всякие сущности (имена людей, компании, города, даты и т. д.) и определять их тип.

Простой пример:

Apple открыла новый офис в N-ске в 2023 году.

NER-модель разметит это примерно так:

  • Apple → ORG (организация)

  • N-ске → LOC (место)

  • 2023 году → DATE (дата)

То есть NER помогает превратить обычный текст в структурированные данные, с которыми уже можно работать в коде: строить аналитические отчёты, автоматизировать поиск информации или даже ловить фишинг в письмах, в общем - кому что.

Ну и, конечно, хочется, чтобы "Apple" определялось как компания, а не фрукт, и "N-ске" - как город, а не что-то ещё.

Акт II. Развитие действия (тяжёлые отношения у PHP и NER)

Мой PHP-бэкэнд - отличное место для обработки форм, SQL-запросов и довольно сложной бизнес логики. Но когда я попытался понять, что в PHP есть сегодня для NLP, то возникло ощущение, будто пришёл на рок-концерт с блокфлейтой.

В Python для этого всё готово: spaCy, HuggingFace Transformers, Torch и т.д. и т.п.
А в PHP… ну, вариантов немного, и каждый со своими проблемами.

И тут я задумался: "А как это вообще сделать в PHP?"

Акт III. Кульминация (герои мечутся по сцене в поисках роли)

В результате недолгого исследования начала вырисовываться картина.
Ниже варианты, которые я нашёл для PHP.

1. Вызов внешних API

Самый очевидный путь. Берёшь готовый API - OpenAI, HuggingFace Inference API, Rasa, watson-nlp - и дергаешь его из PHP.

Плюсы: просто, быстро, почти без настройки и почти бесплатно на старте.
Минусы: нужен интернет, надо платить за токены, скорость иногда подводит. А ещё душит жаба платить за каждый токен, если у тебя поток текста на десятки тысяч строк в день (хотя, конечно можно выбрать модели подешевле).

2. Python рядом

Делаешь микросервис на Python (например, вместе с spaCy). Да, именно так. Ставишь себе микросервис, который гоняет spaCy или какую-нибудь модель, и PHP просто бьёт туда запросами. По сути, превращаешь PHP в "тонкого клиента", а всю магию перекладываешь на соседний контейнер.

Плюсы: мощно, гибко, можно ставить любые модели.
Минусы: приходится поддерживать два стека - Composer и pip, потенциально конфликт версий, плюс лишний DevOps.

3. PHP-библиотеки

Есть энтузиасты, которые попытались втащить использование NLP моделей в PHP.

Плюсы: не нужен второй язык.
Минусы: проекты часто заброшены, документация слабая, модели не всегда самые новые, ограниченная поддержка языков (часто только английский или парочка других европейских).

Пример с mitie-php:

$model = new Mitie\NER('ner_model.dat');

$doc = $model->doc('Nat works at GitHub in San Francisco');

$doc->entities();

Вывод будет что-то вроде:

[
    ['text' => 'Nat', 'tag' => 'PERSON', 'score' => 0.31123712, 'offset' => 0],
    ['text' => 'GitHub', 'tag' => 'ORGANIZATION', 'score' => 0.56601151, 'offset' => 13],
    ['text' => 'San Francisco', 'tag' => 'LOCATION', 'score' => 1.38905243, 'offset' => 23]
]

4. ONNX-модели

ONNX - это формат, в котором можно запускать модели без "тяжёлого" Python-стека.
Есть расширения и для PHP через C++-библиотеки.

Плюсы: работает быстрее, чем тянуть целый Python, нет нужды в Torch.
Минусы: мало примеров, надо руками возиться с конвертацией модели и сборкой расширений.

Пример с transformers-php:

require 'vendor/autoload.php';

use Codewithkyrian\Transformers\Transformers;

$pipeline = Transformers::pipeline('token-classification', 'Xenova/bert-base-NER');

// Perform NER on a sentence
$result = $pipeline->run("Apple opened a new office in N-sk.");

print_r($result);

Вывод будет что-то вроде:

Array
(
    [0] => Array
        (
            [entity] => ORG
            [word] => Apple
        )
    [1] => Array
        (
            [entity] => LOC
            [word] => N-sk
        )
)

5. Собственный велосипед.

  1. Теоретически можно написать свой regex-based NER.
    Если у вас ограниченный домен - например, нужно только города и компании - можно сделать словари и паттерны.

    Работает на удивление хорошо, но при первом же "Мета" вместо Facebook ты вспоминаешь, что живёшь в 2025, а не в 2005.

  2. Можно попытать напрямую вызывать Python прямо из PHP
    Не микросервис, не API — а реально запустить скрипт Python из PHP-процесса. С помощью exec() или shell_exec() можно дернуть команду:

    $input = "Apple opened a new office in N-sk.";
    $escaped = escapeshellarg($input);
    $result = shell_exec("python3 ner.py $escaped");
    $entities = json_decode($result, true);
    var_dump($entities);

    А в ner.py какой-нибудь простой код на spaCy:

    import sys, json, spacy
    nlp = spacy.load("en_core_web_sm")
    doc = nlp(sys.argv[1])
    entities = [{"text": e.text, "label": e.label_} for e in doc.ents]
    print(json.dumps(entities))

    Это не суперэффективно (каждый вызов поднимает интерпретатор Python), но для небольших задач - может быть вполне сносным решением. Особенно если не хочется городить инфраструктуру ради пары запросов в час.

  3. Ну и, наконец, можно использовать библиотеку RubixML, чтобы создать и обучить свою собственную модель. Если есть время и желание, чтобы собрать свой датасет (желательно побольше), разметить токены и построить пайплайн признаков - то у этого "академического" подхода тоже есть право на жизнь.

Акт IV. Развязка (конфликт разрешён, последствия действий героев начинают проявляться)

Поигравшись с разными опциями я остановился на варианте "Python рядом".
В результате у меня бежит отдельный контейнер со spaCy, куда я обращаюсь из контейнера с PHP-FPM.

Ниже приведена примерная структура проекта:

app/
docker/
  spacy/
	- app.py
	- Dockerfile
	- requirements.txt
docker-compose.yml

Пример app.py

from fastapi import FastAPI, Request
import spacy
from transformers import pipeline
import torch
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

app = FastAPI()

# Select device: prefer CUDA, then MPS, else CPU
if torch.cuda.is_available():
    device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
    device = "mps"
else:
    device = "cpu"
logger.info(f"Using device: {device}")

# Ask spaCy to use GPU if available and supported (requires cupy/cuda extras)
try:
    if device == "cuda":
        spacy.prefer_gpu()
        logger.info("spaCy: prefer_gpu() called")
except Exception as e:
    logger.warning(f"spaCy GPU preference failed or unavailable: {e}")

# Loading spaCy models for EN and RU
models_spacy = {
    'en': spacy.load('en_core_web_md'),
    'ru': spacy.load('ru_core_news_md')
}

def clean_entity_text(text: str) -> str:
    """Clean entity text by removing any unwanted characters.
    Args:
        text: The text to clean
    Returns:
        Cleaned text, or empty string if text should be filtered out
    """
    # Return empty string for specific values
    if text in ("#", "0 &&!", "https://www", "//"):
        return ""

    # Clean up and return
    return text.strip("#➡ ").strip()

@app.post("/annotate")
async def annotate(request: Request):
    data = await request.json()
    text = data.get('text', '')
    lang = data.get('lang', 'en')

    if lang in models_spacy:
        nlp = models_spacy[lang]
        doc = nlp(text)
        annotations = [
            {"text": clean_entity_text(ent.text), "label": ent.label_}
            for ent in doc.ents
        ]
    else:
        annotations = []

    return {"annotations": annotations}

Пример Dockerfile

ARG BASE_IMAGE=python:3.10-slim
FROM ${BASE_IMAGE}

# Control torch install for CPU vs CUDA base
ARG INSTALL_TORCH=true
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu

WORKDIR /app

# Copy requirements first for better caching
COPY requirements.txt .
ENV PIP_NO_CACHE_DIR=1
RUN pip install --no-cache-dir -r requirements.txt --upgrade \
    && if [ "$INSTALL_TORCH" = "true" ]; then \
         pip install --no-cache-dir --index-url ${TORCH_INDEX_URL} torch; \
       fi

# Download spaCy models for English and Russian
RUN python -m spacy download en_core_web_md
RUN python -m spacy download ru_core_news_md

# Copy application code
COPY app.py .

EXPOSE 8001

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]

Пример requirements.txt

fastapi
uvicorn[standard]
spacy
transformers

Пример с docker-compose.local.yml

app:  
  build:  
    context: .  
    dockerfile: docker/app/Dockerfile  
  depends_on:  
    - db
    - redis  
  volumes:  
    - ./app:/var/www  
  ports:  
    - "9000:9000"  
  networks:  
    - my-network  
  env_file:  
    - ./app/.env
      
      
spacy:  
  build:  
    context: ./docker/spacy  
    dockerfile: Dockerfile  
  restart: always  
  volumes:  
    - ./docker/spacy:/app  
  ports:  
    - "8001:8001"  
  networks:  
    - my-network

Пример вызова из PHP

private const SPACY_URL = 'http://spacy:8001';

/**  
 * Annotate text * * @param string $text  
 * @param string $lang  
 * @return mixed  
 */  
public static function annotateText(string $text, string $lang = 'en'): mixed  
{  
    $data = json_encode(['text' => $text, 'lang' => $lang]);  
  
    $ch = curl_init(self::SPACY_URL . '/annotate');  
  
    if ($ch === false) {  
        echo 'Failed to initialize curl';  
        return null;  
    }  
  
    curl_setopt_array($ch, [  
        CURLOPT_RETURNTRANSFER => true,  
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],  
        CURLOPT_POSTFIELDS => $data,  
    ]);  
  
    $response = curl_exec($ch);  
  
    if (curl_errno($ch)) {  
        echo 'Curl error: ' . curl_error($ch);  
    }  
  
    curl_close($ch);  
  
    if (is_string($response)) {  
        return json_decode($response, true);  
    }  
  
    return null;  
}

Акт V: Финал (история счастливо завершается)

Сравнение по производительности

Тест: обработка 1000 предложений (~20k токенов).
Условия: средний сервер (8 vCPU, 16 GB RAM).
Оценка - ориентировочная, основана на общих замерах из документации и личном опыте.

Вариант

Среднее время на 1000 предложений

Задержка (latency) на один запрос

Затраты CPU/RAM

Масштабируемость

Внешние API (OpenAI)

40–90 сек (зависит от сети и тарифа)

300–800 мс (иногда скачет до секунд)

CPU/RAM на клиенте почти нет

Масштаб по токенам и тарифам провайдера

Python-сервис (spaCy)

8–12 сек

30–50 мс

Высокая загрузка CPU, RAM ~2–4 GB на модель

Горизонтально (несколько контейнеров)

PHP-библиотеки (mitie-php)

25–40 сек

80–120 мс

CPU средне, RAM до 1 GB

Ограничено - редко оптимизировано под многопоточность

ONNX через PHP-расширение

6–10 сек

20–40 мс

RAM ~1–2 GB, CPU умеренно

Хорошо масштабируется, но нужна ручная сборка

Regex + словари

<1 сек

<1 мс

Незначительно

Бесконечно, но только в узком домене

Что видно из таблицы

  • Самый быстрый (при грамотной настройке) - ONNX, но дорог в интеграции.

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

  • Самый непредсказуемый - внешние API (скорость зависит от сети и тарифа).

  • Regex бьёт всех по скорости, но совершенно бесполезен для сложных сценариев.

Итого, после экспериментов с API, PHP-библиотеками, ONNX и regex я остановился на варианте с отдельным Python-сервисом (spaCy). Для продакшена это оказалось самым устойчивым решением: оно масштабируемо, понятно в поддержке и позволяет обновлять модели независимо от PHP-бэкенда.

Почему именно Python-сервис

  1. Гибкость: позволяет легко менять модели и языки без переписывания PHP-кода (сегодня spaCy, а завтра что угодно - хоть Paraphrase).

  2. Изоляция: NLP-стек вынесен в отдельный контейнер, PHP остаётся "тонким клиентом".

  3. Масштабирование: сервис можно запустить в нескольких экземплярах за балансировщиком.

  4. Обновления: для обновления модели достаточно собрать новый Docker-образ - PHP-часть не трогаем.

  5. Прозрачность: логи, мониторинг, метрики можно настроить отдельно, не перегружая PHP-приложение.

Итог

Если вам нужно встроить NER (или вообще NLP) в проект на PHP, самый надёжный и предсказуемый путь сегодня - соседний Python-сервис в Docker. Да, это требует чуть больше DevOps-усилий, но зато вы получаете реальную мощь Python-экосистемы и не зависите от состояния случайных PHP-библиотек.

Вот такой опыт. Если вы тоже мучились с NER на PHP - расскажите, что выбрали. Может, кто-то уже придумал элегантное решение, о котором мы все мечтаем.

---

*Meta Platforms Inc. (Facebook, Instagram) — признана экстремистской организацией, ее деятельность запрещена на территории России.

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


  1. granv1
    17.09.2025 22:11

    автор открыл для себя и применил фундаментальный принцип конвейертзации и unix style подхода! Ура!


    1. FanatPHP
      17.09.2025 22:11

      Мне всё-таки кажется, что это слишком широкое толкование конвейеризации через контейнеризацию. Этак любой прокси-сервер будет unix-style. Из красивых слов тут скорее подошла бы микросервисная архитектура.


  1. olegl84
    17.09.2025 22:11

    постой запрос к chatgpt дает RubixML для php


    1. samako Автор
      17.09.2025 22:11

      RubixML это замечательная библиотека, но в ней нет готового решения для NER. Хотя её можно использовать, чтобы создать и обучить свою модель, если есть время и желание, то есть: собрать датасет, разметить токены и построить пайплайн признаков. У меня на это, увы - не было времени.

      Впрочем, спасибо за напоминание - добавлю про этот вариант тоже.


  1. FanatPHP
    17.09.2025 22:11

    Немного обидно за пхп. В Питоне и FastAPI, и красивый логгер, а в пыхе echo 'Curl error: ' кишками наружу. И сам курл без единого враппера, голенький как в первый день творения знакомства с языком.