Как компьютер превращает текст в числа и почему 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-разработчиков: интуитивно и на практике".

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

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


  1. SerafimArts
    14.05.2026 10:09

    Тоже на коленке делал в своё время TF-IDF (кликабельно) и прикручивал его на laravel.su для генерации ключевых слов (html meta keywords) для страниц документации (это когда ещё старый дизайн был). Работает великолепно, всем советую впихнуть к себе такую штуку для подобных задач.

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

    1. Для токенизации лучше всего использовать IntlRuleBasedBreakIterator: https://www.php.net/manual/en/class.intlrulebasedbreakiterator.php - мне показалось это как минимум и самым эффективным, так и самым грамотным способом (в разных локалях/языках “пробельные” символы могут быть различными, intl всё это учитывает).

    2. Плюс не забывать про stop words: Нужно из результата выкидывать всякие предлоги, местоимения и прочее. Они вообще никакой роли не играют (кроме названий/заголовков, там они могут быть важны).

    3. Нельзя забывать ещё и о нормализации юникода, весь текст стоит приводить явно к какому-нибудь NFC, иначе потом можно удивиться почему одно и тоже вроде бы слово по разному считается.

    4. В качестве супер-костыля для “стемминга” можно использовать doctrine/inflector. Да, это вообще про другое, но оно может вполне сгладить лишнюю избыточность на первом этапе.

    Опять же, это делалось всё для опенсорса в качестве эксперимента, “фор фан”, и просто чтобы “хоть как-то работало”, поэтому приведённые выше ремарки могут быть оторваны от реальности и грамотные люди могут меня поправить ниже в комментариях.


    1. samako Автор
      14.05.2026 10:09

      Приведённые примеры с статье были только для понимания концепции, для нормальной работы я бы рекомендовал использовать TfIdfTransformer от RubixML: https://rubixml.github.io/ML/2.0/transformers/tf-idf-transformer.html

      Но меня лично радует тот факт, что есть люди, которые пытаются разобраться в теме самостоятельно. Скажите, вы поддерживаете как-то этот репозиторий? Я бы хотел добавить его в https://github.com/apphp/awesome-php-ml

      И ещё - а каким образом конкретно вы использовали его для генерации ключевых слов?


      1. SerafimArts
        14.05.2026 10:09

        Скажите, вы поддерживаете как-то этот репозиторий?

        Поддерживаю в формате “пока ишью не прилетит, тогда поправлю”. На PHP 8.5 он вроде заводится вполне. Из проблем - только зависимость на voku/stop-words, которая в каком-то полузаброшенном состоянии и выплёвывет deprecation notice. Плюс “политически ангажированный” пакет (автор), что может быть не совсем безопасным. Но альтернатив чтоб “стоп-слова” поддерживалось кем-то я не находил.

        И ещё - а каким образом конкретно вы использовали его для генерации ключевых слов?

        Есть массив из документов (страниц документации) по фреймворку. Самое “редкое” слово между разными документами, но самое “частое” на конкретно этой странице и является в большинстве случаев ключевым словом и темой страницы документации.

        Например, на странице “установки” чаще всего упоминается именно “установка”, всякие “windows”, “docker” и прочие. В отличие от других страниц, где про установку наоборот ничего нет.

        Или на странице роутинга чаще всего говорится как раз про “роутинг”, “миддлвари” и прочее. На остальных страницах документации таких упоминаний нет (или в разы меньше), как следствие и ключевые слова для страницы будут именно такими.


        1. samako Автор
          14.05.2026 10:09

          Есть массив из документов (страниц документации) по фреймворку. Самое “редкое” слово между разными документами, но самое “частое” на конкретно этой странице и является в большинстве случаев ключевым словом и темой страницы документации.

          Спасибо, как-то я прошёл мимо такой возможности использовать TF-IDF, хотя это действительно абсолютно естественным образов вытекает из свойств этой модели.