Это статья — пример небольшого личного опыта, где я пытался решить одну чисто техническую задачу для одного из моих текущих проектов. Задача в конце‑концов была решена, насколько правильно — не знаю, но надеюсь многим будет интересен и полезен мой опыт. Итак, небольшая драма в 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. Собственный велосипед
Теоретически можно написать свой regex-based NER.
Если у вас ограниченный домен — например, нужно только города и компании — можно сделать словари и паттерны.
Работает на удивление хорошо, но при первом же «Мета» вместо Facebook ты вспоминаешь, что живёшь в 2025, а не в 2005.Можно попытать напрямую вызывать 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), но для небольших задач — может быть вполне сносным решением. Особенно если не хочется городить инфраструктуру ради пары запросов в час.
Ну и, наконец, можно использовать библиотеку 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-сервис
Гибкость: позволяет легко менять модели и языки без переписывания PHP‑кода (сегодня spaCy, а завтра что угодно — хоть Paraphrase).
Изоляция: NLP‑стек вынесен в отдельный контейнер, PHP остаётся «тонким клиентом».
Масштабирование: сервис можно запустить в нескольких экземплярах за балансировщиком.
Обновления: для обновления модели достаточно собрать новый Docker‑образ — PHP‑часть не трогаем.
Прозрачность: логи, мониторинг, метрики можно настроить отдельно, не перегружая PHP‑приложение.
Итог
Если вам нужно встроить NER (или вообще NLP) в проект на PHP, самый надёжный и предсказуемый путь сегодня — соседний Python‑сервис в Docker. Да, это требует чуть больше DevOps‑усилий, но зато вы получаете реальную мощь Python‑экосистемы и не зависите от состояния случайных PHP‑библиотек.
Вот такой опыт. Если вы тоже мучились с NER на PHP — расскажите, что выбрали. Может, кто‑то уже придумал элегантное решение, о котором мы все мечтаем.
*Meta Platforms Inc. (Facebook, Instagram) — признана экстремистской организацией, ее деятельность запрещена на территории России.
Читать далее:
Часть 2: Собираем простейшую RAG-систему на PHP с фреймворком Neuron AI за вечер
