AI для PHP-разработчиков. Часть 6: Bag of Words и TF–IDF – как компьютер превращает текст в математику

от автора

Как компьютер превращает текст в числа и почему TF–IDF десятилетиями оставался основой поисковых систем. Разбираем Bag of Words, TF–IDF и поиск похожих документов на чистом PHP.

Это шестая часть проекта.

Часть 5: От массивов к GPU: как PHP-экосистема приходит к настоящему ML
Часть 4: Практическое использование TransformersPHP
Часть 3: Практика без Python и data science
Часть 2: Собираем простейшую RAG-систему на PHP с Neuron AI за вечер
Часть 1: Как я пытался подружить PHP с NER – драма в 5 актах

Когда мы говорим, что нейросети «понимают текст», легко забыть одну важную вещь: компьютер изначально вообще не умеет понимать слова.

Для машины текст – это просто последовательность символов. Чтобы алгоритмы могли работать с языком, текст нужно превратить в числа.

Именно здесь появляются Bag of Words и TF–IDF – два фундаментальных подхода, с которых исторически начиналось NLP и поиск по тексту.

Несмотря на возраст, эти методы до сих пор используются:

  • в поисковых системах;

  • в FAQ и helpdesk;

  • в корпоративных поисковиках;

  • в рекомендациях документов;

  • в классификации текстов.

И главное – они помогают понять, как вообще текст становится математикой


Историческая справка

Исторически эти подходы появились в разные годы и развивались постепенно.

Bag of Words начал формироваться ещё в 1950-х годах как простой способ представления текста через набор слов. Активно развиваться этот подход стал в 1960-х вместе с работами Жерара Салтона и появлением vector space model. ​

TF–IDF появился позже — в начале 1970-х. Идею IDF предложила Карен Спэрк Джонс в 1972 году, а затем TF–IDF стал популярным благодаря исследованиям Жерара Салтона в области информационного поиска.

Bag of Words: «мешок слов»

BOW — Bag of Words (мешок слов) – это способ представить текст без учёта порядка слов. Нас интересует только то, какие слова встретились и сколько раз.

Представим два предложения:

  • ​»Кот ест рыбу»

  • «Рыбу ест кот»

Для человека они почти одинаковы. Для Bag of Words – абсолютно одинаковы.

Мы как бы высыпаем слова из текста в мешок, перемешиваем, забывая об их порядке и считаем количество каждого слова.

Как строится словарь

Первый шаг – построить словарь. Это просто список всех уникальных слов во всех документах.

Пусть у нас есть три документа:

D1: кот ест рыбуD2: кот любит рыбуD3: собака ест мясо

Сначала строится словарь всех уникальных слов:

[кот, ест, рыбу, любит, собака, мясо]

После этого каждому слову назначается индекс:

кот → 0ест → 1рыбу → 2любит → 3собака → 4мясо → 5

Превращаем текст в вектор

Теперь каждый документ можно представить как числовой вектор длины |V|, где |V| – размер словаря.

Для документа: кот ест рыбу получаем:

[1, 1, 1, 0, 0, 0]

Для: кот любит рыбу:

[1, 0, 1, 1, 0, 0]

А для: собака ест мясо:

[0, 1, 0, 0, 1, 1]

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

BOW - Мешок слов: векторы

BOW — Мешок слов: векторы

Немного математики

Формально Bag of Words можно описать так:

Пусть словарь:

V = {w_1, w_2, \dots, w_n}

Тогда документ представляется как вектор:

x(d) = (c_1, c_2, \dots, c_n)

где:

  • cᵢ– количество вхождений слова wᵢ

  • n – размер словаря.

На этом этапе документы уже становятся объектами линейной алгебры.

Это обычный вектор в  \mathbb{R}^n (для чистого Bag of Words – формально в \mathbb{N}^n, но можно рассматривать как вектор в  \mathbb{R}^n.​

И уже на этом этапе мы можем:

  • ​сравнивать документы

  • обучать классификаторы

  • искать похожие тексты

Но есть одна проблема.

Главная проблема Bag of Words

У подхода есть серьёзный недостаток, который называется проблемой частот.

Все слова считаются одинаково важными.

Например: слово «кот» слово «и».

Слово «и» будет встречаться почти в каждом документе. Его частота большая, но смысловая ценность почти нулевая.​

Bag of Words не различает:

  • важные слова

  • служебные слова

  • редкие, но информативные термины

Именно поэтому на сцене появился TF–IDF.

TF–IDF: идея в одной фразе

TF–IDF расшифровывается как: Term Frequency – Inverse Document Frequency

​Идея очень простая:

  • ​слово важно, если оно часто встречается в документе

  • но оно теряет ценность, если встречается почти во всех документах

TF – «насколько часто слово встречается в данном документе»

​IDF – «насколько слово редкое в корпусе»

Итоговый вес – их произведение.

TF (Term Frequency) – насколько слово важно внутри документа

Самая простая формула TF:

\mathrm{TF}(w,d)=count(w,d)

Но чаще используют нормализацию:

\mathrm{TF}(w,d)=\frac{count(w,d)}{|d|}

где:

  • count(w,d) – количество слова

  • |d| – длина документа

Интерпретация проста:

  • 0 → слова нет

  • чем больше значение, тем важнее слово в рамках данного документа

IDF (Inverse Document Frequency) – насколько слово редкое

IDF показывает, насколько слово редкое.

а насколько это слово уникально для всего корпуса?

Для этого используется IDF:

\mathrm{IDF}(w)=\ln\left(\frac{N}{df(w)}\right)

где:

  • ln – натуральный логарифм (его же и используем далее)

  • N – количество документов

  • df(w) – число документов, содержащих слово

Иногда ещё добавляют сглаживание:

\mathrm{IDF}(w) = \ln\left(\frac{N + 1}{df(w) + 1}\right) + 1

Как это интерпретировать:

  • редкое слово → высокий IDF

  • частое слово → низкий IDF

Например: «SMTP» может встречаться редко, в тоже время «как» – почти везде.

Следовательно:

  • «SMTP» будет иметь высокий вес

  • «как» – почти нулевой

Пример вычисления

Допустим, что у нас есть:

  • всего 3 документа

  • слово «кот» встречается в двух документах

Тогда:

\mathrm{IDF}(\text{кот})=\ln\left(\frac{3}{2}\right)\approx0.405

А слово «собака» встречается только один раз:

\mathrm{IDF}(\text{собака})=\ln\left(\frac{3}{1}\right)\approx1.099

Даже если в документе они встречаются по одному разу, «собака» будет весить значительно больше.

Финальная формула TF–IDF

Теперь объединяем TF и IDF:

\mathrm{TF\text{-}IDF}(w,d)=\mathrm{TF}(w,d)\times\mathrm{IDF}(w)

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

  • частое слово внутри документа → вес растёт;

  • частое слово во всём корпусе → вес падает.

Тепловая карта, отображающая значения TF-IDF

Тепловая карта, отображающая значения TF-IDF

​Вектор TF–IDF

Как и Bag of Words, TF–IDF – это вектор.

Отличие только в том, что вместо целых чисел мы получаем вещественные веса.

x(d) = (\mathrm{tfidf}_1, \mathrm{tfidf}_2, \dots, \mathrm{tfidf}_n)

Этот вектор:

  • обычно хранится в разреженном виде (только ненулевые значения)

  • высокоразмерный

  • хорошо отражает смысл документа на базовом уровне

Сравнение документов

TF–IDF часто используют вместе с косинусным сходством (cosine similarity).

Почему? Потому что:

  • длины документов разные

  • важна не сумма весов, а направление вектора

​Косинусное сходство измеряет угол между векторами, а не расстояние между точками.

Косинусное сходство документов

Косинусное сходство документов

Почему TF–IDF стал стандартом поиска

TF–IDF долгое время был основой поисковых систем, и даже сегодня похожие идеи используются внутри Elasticsearch, Lucene, корпоративных поисковиков и систем рекомендаций.

Причина проста: TF–IDF хорошо работает в задачах, где тексты относительно короткие, важна терминология и нужны быстрые, понятные вычисления. Модель легко интерпретировать, а результаты – объяснить.

Ограничения Bag of Words и TF–IDF

​При этом важно понимать границы этих моделей. Они не учитывают порядок слов, не понимают контекст и не знают семантики. Для них выражения вроде river bank и bank account могут выглядеть почти одинаково (или для русского языка: заплетённая коса и нашла коса на камень).

​Но несмотря на простоту, такие подходы до сих пор остаются полезными. Они быстрые, хорошо работают на небольших данных и часто используются как сильный baseline перед более сложными ML-моделями.

Почему это всё ещё важно

Bag of Words и TF–IDF – это фундамент NLP.

Если вы понимаете, как текст превращается в вектор, почему слова получают разные веса и как редкость влияет на значимость термина, то embeddings, attention и transformer-модели становятся гораздо понятнее.

Потому что современные модели делают концептуально то же самое – представляют текст в виде чисел и ищут зависимости между ними, – только значительно сложнее и умнее.

Именно поэтому мы начали объяснения с мешка слов.

Простой пример TF–IDF на PHP (без библиотек)

Поиск похожих документов на PHP

В этой статье мы сознательно не будем использовать готовые библиотеки и реализуем всё на чистом PHP – исключительно в образовательных целях, чтобы лучше понять, как работают Bag of Words и TF–IDF «под капотом».

Рассмотрим простой пример. Допустим, у нас есть база знаний:

$documents = [    1 => 'Как сбросить пароль пользователя',    2 => 'Ошибка подключения к базе данных',    3 => 'Настройка SMTP для отправки почты',    4 => 'Восстановление доступа к аккаунту пользователя',];

Пользователь вводит запрос:

не могу восстановить пароль пользователя

Задача системы – найти наиболее похожие документы.

Архитектура поиска

Pipeline будет выглядеть так:

Документы   ↓Токенизация   ↓TF–IDF векторы   ↓Вектор запроса   ↓Cosine Similarity   ↓Сортировка результатов
Конвейер поиска (pipeline) документов

Конвейер поиска (pipeline) документов

Шаг 1. Подготавливаем документы

$documents = [    1 => 'Как сбросить пароль пользователя',    2 => 'Ошибка подключения к базе данных',    3 => 'Настройка SMTP для отправки почты',    4 => 'Восстановление доступа к аккаунту пользователя',];$query = 'не могу восстановить пароль пользователя'; 

Шаг 2. Токенизация

Для простоты здесь используется очень примитивная токенизация – мы просто разбиваем строку по пробелам. В production-системах обычно дополнительно:

  • удаляют пунктуацию

  • нормализуют пробелы

  • убирают stop-words

  • приводят слова к нормальной форме

function tokenize(string $text): array {    $text = mb_strtolower($text);    return explode(' ', $text);}

Преобразуем документы:

$tokenizedDocs = array_map('tokenize', $documents);$queryTokens = tokenize($query);

Шаг 3. TF (Term Frequency)

При помощи этой функции мы рассчитаем нормализованную частоту встречаемости термина в одном документе.

function termFrequency(array $tokens): array {    $tf = [];    $count = count($tokens);    foreach ($tokens as $token) {        $tf[$token] = ($tf[$token] ?? 0) + 1;    }    foreach ($tf as $word => $value) {        $tf[$word] = $value / $count;    }    return $tf;}

Шаг 4. IDF (Inverse Document Frequency)

Теперь считаем, насколько слово редкое во всём корпусе. Вычисляем обратную частоту встречаемости термина во всём корпусе документов.

function inverseDocumentFrequency(array $documents): array {    $df = [];    $N = count($documents);    foreach ($documents as $doc) {        foreach (array_unique($doc) as $word) {            $df[$word] = ($df[$word] ?? 0) + 1;        }    }    $idf = [];    foreach ($df as $word => $freq) {        $idf[$word] = log($N / $freq);        // Такой вариант формулы использует smoothing и помогает избежать         // ситуаций, когда очень частые слова получают вес ровно 0        // $idf[$word] = log(($N + 1) / ($freq + 1)) + 1;    }    return $idf;}

Шаг 5. TF–IDF вектор

Создаём TF-IDF вектор для одного документа/запроса.

function tfidf(array $tf, array $idf): array {    $vector = [];    foreach ($tf as $word => $value) {        $vector[$word] = $value * ($idf[$word] ?? 0);    }    return $vector;}

Строим векторы документов:

$idf = inverseDocumentFrequency($tokenizedDocs);$documentVectors = [];foreach ($tokenizedDocs as $id => $tokens) {    $tf = termFrequency($tokens);    $documentVectors[$id] = tfidf($tf, $idf);}

Шаг 6. Вектор запроса

$queryTf = termFrequency($queryTokens);$queryVector = tfidf($queryTf, $idf);

Теперь запрос пользователя представлен точно так же, как и документы.

Это очень важный момент.

После TF–IDF документы и запрос представлены в одном взвешенном векторном пространстве терминов.

Шаг 7. Cosine Similarity

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

Используем cosine similarity:

\mathrm{cosine\_sim}(A, B) = \frac{A \cdot B}{|A||B|}

Интуитивно:

  • чем ближе cosine similarity к 1 → тем ближе направления векторов

  • чем ближе значение к 0 → тем менее похожи документы

Реализация cosine similarity

(см. ниже в полном примере кода).

Шаг 8. Поиск похожих документов

$results = [];foreach ($documentVectors as $id => $vector) {    $results[$id] = cosineSimilarity(        $queryVector,        $vector    );}arsort($results);print_r($results);

Полный пример кода на чистом PHP

Скрытый текст
// Исходные документы для поиска сходства.$documents = [    1 => 'Как сбросить пароль пользователя',    2 => 'Ошибка подключения к базе данных',    3 => 'Настройка SMTP для отправки почты',    4 => 'Восстановление доступа к аккаунту пользователя',];// Converts text to lowercase and splits by spaces.function tokenize(string $text): array {    $text = mb_strtolower($text);    return explode(' ', $text);}// Вычисляет нормализованную частоту встречаемости терминов в одном документе.function termFrequency(array $tokens): array {    $tf = [];    $count = count($tokens);    foreach ($tokens as $token) {        $tf[$token] = ($tf[$token] ?? 0) + 1;    }    foreach ($tf as $word => $value) {        $tf[$word] = $value / $count;    }    return $tf;}// Вычисляет обратную частоту встречаемости документа по всем документам.function inverseDocumentFrequency(array $documents): array {    $df = [];    $N = count($documents);    foreach ($documents as $doc) {        foreach (array_unique($doc) as $word) {            $df[$word] = ($df[$word] ?? 0) + 1;        }    }    $idf = [];    foreach ($df as $word => $freq) {        $idf[$word] = log($N / $freq);        // Такой вариант формулы использует smoothing и помогает избежать ситуаций,         // когда очень частые слова получают вес ровно 0        // $idf[$word] = log(($N + 1) / ($freq + 1)) + 1;    }    return $idf;}// Создает TF-IDF вектор для одного документа/запроса.function tfidf(array $tf, array $idf): array {    $vector = [];    foreach ($tf as $word => $value) {        $vector[$word] = $value * ($idf[$word] ?? 0);    }    return $vector;}// Измеряет сходство между двумя разреженными векторами.function cosineSimilarity(array $a, array $b): float {    $dot = 0;    $normA = 0;    $normB = 0;    $words = array_unique(array_merge(        array_keys($a),        array_keys($b)    ));    foreach ($words as $word) {        $va = $a[$word] ?? 0;        $vb = $b[$word] ?? 0;        $dot += $va * $vb;        $normA += $va * $va;        $normB += $vb * $vb;    }    if ($normA == 0 || $normB == 0) {        return 0;    }    return $dot / (sqrt($normA) * sqrt($normB));}// Предварительно вычислить токенизированные документы, // IDF-коды и векторы TF-IDF для документов.$tokenizedDocs = array_map('tokenize', $documents);$idf = inverseDocumentFrequency($tokenizedDocs);$documentVectors = [];foreach ($tokenizedDocs as $id => $tokens) {    $tf = termFrequency($tokens);    $documentVectors[$id] = tfidf($tf, $idf);}$query = 'не могу восстановить пароль пользователя';$queryTokens = tokenize($query);$queryTf = termFrequency($queryTokens);$queryVector = tfidf($queryTf, $idf);$results = [];foreach ($documentVectors as $id => $vector) {    $results[$id] = cosineSimilarity(        $queryVector,        $vector    );}arsort($results);echo 'Results:' . "\n";foreach ($results as $id => $score) {    echo 'Document ' . $id . ': ' . round($score, 2) . ' (' . $documents[$id] . ')' . "\n";}echo "\n" . "\n";echo 'Document vectors:' . "\n";foreach ($documentVectors as $id => $vector) {    echo 'Document ' . $id . ': ' . "\n";    print_r($vector);    echo "\n";}echo "\n";echo 'IDF:' . "\n";print_r($idf);

Результат

Пример вывода:

Array (    [1] => 0.62017367294604    [4] => 0.11952286093344    [2] => 0    [3] => 0)

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

Интерпретация результатов

Система считает наиболее похожими:

  1. «Как сбросить пароль пользователя»

  2. «Восстановление доступа к аккаунту пользователя»

И это уже выглядит вполне разумно.

Интересно, что:

  • SMTP не имеет ничего общего с запросом

  • ошибка базы данных тоже нерелевантна

  • документ про восстановление доступа получил ненулевое сходство в основном благодаря совпадению слова «пользователя»

При этом система всё ещё не понимает, что:

  • «восстановить» и «восстановление» связаны

  • «пароль» и «доступ» могут быть близкими по смыслу

Без стемминга (stemming) или лемматизации (lemmatization) такие слова считаются разными токенами.

И хотя система: не понимает семантику текста, не знает синонимов, не учитывает контекст и не не использует нейросети – она просто работает со статистикой слов.

Подведение итогов

Таким образом, хотя мы и убедились на довольно простом примере, что система работает, у неё есть ограничения. Она не понимает смысл текста по-настоящему: не знает синонимов, плохо работает с разными формами слов и не учитывает контекст. По сути, поиск строится в основном на совпадении терминов.

Например, для текущей реализации слова:

  • «восстановить»

  • «восстановление»

считаются разными токенами.

То же самое касается:

  • «доступ»

  • «пароль»

Система просто не знает, что эти слова могут быть связаны по смыслу.

Чтобы решить это, обычно добавляют:

Но фундамент остаётся тем же: текст всё равно превращается в вектор. И этот кейс показывает очень важную идею всей области NLP.

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

Без нейросетей. Без GPU/TPU. Без LLM.

Только: слова, веса, векторы и немного линейной алгебры.

Именно с таких систем исторически начинался поиск по тексту – и именно они до сих пор лежат внутри многих production-систем как быстрый и надёжный базовый уровень.

Если вам интересна тема AI в PHP, можно глубже погрузиться в неё в моей бесплатной книге: «AI для PHP-разработчиков: интуитивно и на практике«.

А чтобы лучше понять, как всё работает, – попробуйте интерактивные онлайн-примеры и поэкспериментируйте с кодом самостоятельно.

ссылка на оригинал статьи https://habr.com/ru/articles/1034874/