|
Professor Seleznov
|
Как компьютер превращает текст в числа и почему 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 - Мешок слов: векторы Немного математики Формально Bag of Words можно описать так: Пусть словарь:

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

где:

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


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

(для чистого Bag of Words – формально в

, но можно рассматривать как вектор в

. И уже на этом этапе мы можем:
- сравнивать документы
- обучать классификаторы
- искать похожие тексты
Но есть одна проблема. Главная проблема Bag of Words У подхода есть серьёзный недостаток, который называется проблемой частот. Все слова считаются одинаково важными. Например: слово "кот" слово "и". Слово "и" будет встречаться почти в каждом документе. Его частота большая, но смысловая ценность почти нулевая. Bag of Words не различает:
- важные слова
- служебные слова
- редкие, но информативные термины
Именно поэтому на сцене появился TF–IDF. TF–IDF: идея в одной фразе TF–IDF расшифровывается как: Term Frequency – Inverse Document Frequency Идея очень простая:
- слово важно, если оно часто встречается в документе
- но оно теряет ценность, если встречается почти во всех документах
TF – "насколько часто слово встречается в данном документе" IDF – "насколько слово редкое в корпусе" Итоговый вес – их произведение. TF (Term Frequency) – насколько слово важно внутри документа Самая простая формула TF:

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

где:

– количество слова

– длина документа
Интерпретация проста:
- 0 → слова нет
- чем больше значение, тем важнее слово в рамках данного документа
IDF (Inverse Document Frequency) – насколько слово редкое IDF показывает, насколько слово редкое.
а насколько это слово уникально для всего корпуса?
Для этого используется IDF:

где:

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

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

– число документов, содержащих слово
Иногда ещё добавляют сглаживание:

Как это интерпретировать:
- редкое слово → высокий IDF
- частое слово → низкий IDF
Например: "SMTP" может встречаться редко, в тоже время "как" – почти везде. Следовательно:
- "SMTP" будет иметь высокий вес
- "как" – почти нулевой
Пример вычисления Допустим, что у нас есть:
- всего 3 документа
- слово "кот" встречается в двух документах
Тогда:

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

Даже если в документе они встречаются по одному разу, "собака" будет весить значительно больше. Финальная формула TF–IDF Теперь объединяем TF и IDF:

Таким образом:
- частое слово внутри документа → вес растёт;
- частое слово во всём корпусе → вес падает.

Тепловая карта, отображающая значения TF-IDF Вектор TF–IDF Как и Bag of Words, TF–IDF – это вектор. Отличие только в том, что вместо целых чисел мы получаем вещественные веса.

Этот вектор:
- обычно хранится в разреженном виде (только ненулевые значения)
- высокоразмерный
- хорошо отражает смысл документа на базовом уровне
Сравнение документов 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) документов Шаг 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:

Интуитивно:
- чем ближе 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 )
Интерпретация результатов Система считает наиболее похожими:
- "Как сбросить пароль пользователя"
- "Восстановление доступа к аккаунту пользователя"
И это уже выглядит вполне разумно. Интересно, что:
- SMTP не имеет ничего общего с запросом
- ошибка базы данных тоже нерелевантна
- документ про восстановление доступа получил ненулевое сходство в основном благодаря совпадению слова "пользователя"
При этом система всё ещё не понимает, что:
- "восстановить" и "восстановление" связаны
- "пароль" и "доступ" могут быть близкими по смыслу
Без стемминга (stemming) или лемматизации (lemmatization) такие слова считаются разными токенами. И хотя система: не понимает семантику текста, не знает синонимов, не учитывает контекст и не не использует нейросети – она просто работает со статистикой слов. Подведение итогов Итак, хоть мы и убедились на довольно простом примере, что система работает, у неё есть ограничения. Она не понимает смысл текста по-настоящему: не знает синонимов, плохо работает с разными формами слов и не учитывает контекст. По сути, поиск строится в основном на совпадении терминов. Например, для текущей реализации слова:
- "восстановить"
- "восстановление"
считаются разными токенами. То же самое касается:
Система не знает, что эти слова могут быть связаны по смыслу. Чтобы решить это, обычно добавляют:
Но фундамент остаётся тем же: текст всё равно превращается в вектор. И этот кейс показывает очень важную идею всей области NLP. Даже простая статистика слов уже позволяет строить полезные поисковые системы. Без нейросетей. Без GPU/TPU. Без LLM. Только: слова, веса, векторы и немного линейной алгебры. Именно с таких систем исторически начинался поиск по тексту – и именно они до сих пор лежат внутри многих production-систем как быстрый и надёжный базовый уровень. Если вам интересна эта тема, можно глубже погрузиться в неё в моей бесплатной книге:
https://apphp.gitbook.io/ai-for-php-developers/ А также посмотреть и поиграться с онлайн-примерами:
https://aiwithphp.org/books/ai-for-php-developers/examples/-Источник
|