Это статья - пример небольшого личного опыта, где я пытался решить одну чисто техническую задачу для одного из моих текущих проектов. Задача в конце-концов была решена, насколько правильно - не знаю, но надеюсь многим будет интересен и полезен мой опыт. Итак, небольшая драма в 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) — признана экстремистской организацией, ее деятельность запрещена на территории России.
Комментарии (0)
olegl84
17.09.2025 22:11постой запрос к chatgpt дает RubixML для php
samako Автор
17.09.2025 22:11RubixML это замечательная библиотека, но в ней нет готового решения для NER. Хотя её можно использовать, чтобы создать и обучить свою модель, если есть время и желание, то есть: собрать датасет, разметить токены и построить пайплайн признаков. У меня на это, увы - не было времени.
Впрочем, спасибо за напоминание - добавлю про этот вариант тоже.
FanatPHP
17.09.2025 22:11Немного обидно за пхп. В Питоне и FastAPI, и красивый логгер, а в пыхе echo 'Curl error: ' кишками наружу. И сам курл без единого враппера, голенький как в первый день
творениязнакомства с языком.
granv1
автор открыл для себя и применил фундаментальный принцип конвейертзации и unix style подхода! Ура!
FanatPHP
Мне всё-таки кажется, что это слишком широкое толкование конвейеризации через контейнеризацию. Этак любой прокси-сервер будет unix-style. Из красивых слов тут скорее подошла бы микросервисная архитектура.