- BrainTools - https://www.braintools.ru -
Как компьютер превращает текст в числа и почему TF–IDF десятилетиями оставался основой поисковых систем. Разбираем Bag of Words, TF–IDF и поиск похожих документов на чистом PHP.
Это шестая часть проекта.
Часть 5: От массивов к GPU: как PHP-экосистема приходит к настоящему ML [1]
Часть 4: Практическое использование TransformersPHP [2]
Часть 3: Практика без Python и data science [3]
Часть 2: Собираем простейшую RAG-систему на PHP с Neuron AI за вечер [4]
Часть 1: Как я пытался подружить PHP с NER – драма в 5 актах [5]
Когда мы говорим, что нейросети “понимают текст”, легко забыть одну важную вещь: компьютер изначально вообще не умеет понимать слова.
Для машины текст – это просто последовательность символов. Чтобы алгоритмы могли работать с языком, текст нужно превратить в числа.
Именно здесь появляются Bag of Words и TF–IDF – два фундаментальных подхода, с которых исторически начиналось NLP и поиск по тексту.
Несмотря на возраст, эти методы до сих пор используются:
в поисковых системах;
в FAQ и helpdesk;
в корпоративных поисковиках;
в рекомендациях документов;
в классификации текстов.
И главное – они помогают понять, как вообще текст становится математикой [6]
Исторически эти подходы появились в разные годы и развивались постепенно.
Bag of Words [7] начал формироваться ещё в 1950-х годах как простой способ представления текста через набор слов. Активно развиваться этот подход стал в 1960-х вместе с работами Жерара Салтона и появлением vector space model.
TF–IDF [8] появился позже – в начале 1970-х. Идею IDF предложила Карен Спэрк Джонс в 1972 году, а затем TF–IDF стал популярным благодаря исследованиям Жерара Салтона в области информационного поиска.
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]
Каждое число показывает, сколько раз слово встретилось в документе.
Формально Bag of Words можно описать так:
Пусть словарь:
Тогда документ представляется как вектор:
где:
– количество вхождений слова
– размер словаря.
На этом этапе документы уже становятся объектами линейной алгебры.
Это обычный вектор в (для чистого Bag of Words – формально в
, но можно рассматривать как вектор в
.
И уже на этом этапе мы можем:
сравнивать документы
обучать классификаторы
искать похожие тексты
Но есть одна проблема.
У подхода есть серьёзный недостаток, который называется проблемой частот.
Все слова считаются одинаково важными.
Например: слово “кот” слово “и”.
Слово “и” будет встречаться почти в каждом документе. Его частота большая, но смысловая ценность почти нулевая.
Bag of Words не различает:
важные слова
служебные слова
редкие, но информативные термины
Именно поэтому на сцене появился TF–IDF.
TF–IDF расшифровывается как: Term Frequency – Inverse Document Frequency
Идея очень простая:
слово важно, если оно часто встречается в документе
но оно теряет ценность, если встречается почти во всех документах
TF – “насколько часто слово встречается в данном документе”
IDF – “насколько слово редкое в корпусе”
Итоговый вес – их произведение.
Самая простая формула TF:
Но чаще используют нормализацию:
где:
– количество слова
– длина документа
Интерпретация проста:
0 → слова нет
чем больше значение, тем важнее слово в рамках данного документа
IDF показывает, насколько слово редкое.
а насколько это слово уникально для всего корпуса?
Для этого используется IDF:
где:
– натуральный логарифм (его же и используем далее)
– количество документов
– число документов, содержащих слово
Иногда ещё добавляют сглаживание:
Как это интерпретировать:
редкое слово → высокий IDF
частое слово → низкий IDF
Например: “SMTP” может встречаться редко, в тоже время “как” – почти везде.
Следовательно:
“SMTP” будет иметь высокий вес
“как” – почти нулевой
Допустим, что у нас есть:
всего 3 документа
слово “кот” встречается в двух документах
Тогда:
А слово “собака” встречается только один раз:
Даже если в документе они встречаются по одному разу, “собака” будет весить значительно больше.
Теперь объединяем TF и IDF:
Таким образом:
частое слово внутри документа → вес растёт;
частое слово во всём корпусе → вес падает.
Как и Bag of Words, TF–IDF – это вектор.
Отличие только в том, что вместо целых чисел мы получаем вещественные веса.
Этот вектор:
обычно хранится в разреженном виде (только ненулевые значения)
высокоразмерный
хорошо отражает смысл документа на базовом уровне
TF–IDF часто используют вместе с косинусным сходством (cosine similarity [9]).
Почему? Потому что:
длины документов разные
важна не сумма весов, а направление вектора
Косинусное сходство измеряет угол между векторами, а не расстояние между точками.
TF–IDF долгое время был основой поисковых систем, и даже сегодня похожие идеи используются внутри Elasticsearch, Lucene, корпоративных поисковиков и систем рекомендаций.
Причина проста: TF–IDF хорошо работает в задачах, где тексты относительно короткие, важна терминология и нужны быстрые, понятные вычисления. Модель легко интерпретировать, а результаты – объяснить.
При этом важно понимать границы этих моделей. Они не учитывают порядок слов, не понимают контекст и не знают семантики. Для них выражения вроде river bank и bank account могут выглядеть почти одинаково (или для русского языка: заплетённая коса и нашла коса на камень).
Но несмотря на простоту, такие подходы до сих пор остаются полезными. Они быстрые, хорошо работают на небольших данных и часто используются как сильный baseline перед более сложными ML-моделями.
Bag of Words и TF–IDF – это фундамент NLP.
Если вы понимаете, как текст превращается в вектор, почему слова получают разные веса и как редкость влияет на значимость термина, то embeddings [10], attention и transformer-модели [11] становятся гораздо понятнее.
Потому что современные модели делают концептуально то же самое – представляют текст в виде чисел и ищут зависимости между ними, – только значительно сложнее и умнее.
Именно поэтому мы начали объяснения с мешка слов.
В этой статье мы сознательно не будем использовать готовые библиотеки и реализуем всё на чистом PHP – исключительно в образовательных целях, чтобы лучше понять, как работают Bag of Words и TF–IDF “под капотом”.
Рассмотрим простой пример. Допустим, у нас есть база знаний:
$documents = [
1 => 'Как сбросить пароль пользователя',
2 => 'Ошибка подключения к базе данных',
3 => 'Настройка SMTP для отправки почты',
4 => 'Восстановление доступа к аккаунту пользователя',
];
Пользователь вводит запрос:
не могу восстановить пароль пользователя
Задача системы – найти наиболее похожие документы.
Pipeline будет выглядеть так:
Документы
↓
Токенизация
↓
TF–IDF векторы
↓
Вектор запроса
↓
Cosine Similarity
↓
Сортировка результатов
$documents = [
1 => 'Как сбросить пароль пользователя',
2 => 'Ошибка подключения к базе данных',
3 => 'Настройка SMTP для отправки почты',
4 => 'Восстановление доступа к аккаунту пользователя',
];
$query = 'не могу восстановить пароль пользователя';
Для простоты здесь используется очень примитивная токенизация – мы просто разбиваем строку по пробелам. В production-системах обычно дополнительно:
удаляют пунктуацию
нормализуют пробелы
убирают stop-words
приводят слова к нормальной форме
function tokenize(string $text): array {
$text = mb_strtolower($text);
return explode(' ', $text);
}
Преобразуем документы:
$tokenizedDocs = array_map('tokenize', $documents);
$queryTokens = tokenize($query);
При помощи этой функции мы рассчитаем нормализованную частоту встречаемости термина в одном документе.
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;
}
Строим векторы документов:
$idf = inverseDocumentFrequency($tokenizedDocs);
$documentVectors = [];
foreach ($tokenizedDocs as $id => $tokens) {
$tf = termFrequency($tokens);
$documentVectors[$id] = tfidf($tf, $idf);
}
$queryTf = termFrequency($queryTokens);
$queryVector = tfidf($queryTf, $idf);
Теперь запрос пользователя представлен точно так же, как и документы.
Это очень важный момент.
После TF–IDF документы и запрос представлены в одном взвешенном векторном пространстве терминов.
Теперь нужно измерить близость между векторами.
Используем cosine similarity:
Интуитивно:
чем ближе cosine similarity к 1 → тем ближе направления векторов
чем ближе значение к 0 → тем менее похожи документы
Реализация cosine similarity
(см. ниже в полном примере кода).
$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
)
Чтобы самостоятельно протестировать этот код,
воспользуйтесь онлайн-демонстрацией [12] для его запуска.
Система считает наиболее похожими:
“Как сбросить пароль пользователя”
“Восстановление доступа к аккаунту пользователя”
И это уже выглядит вполне разумно.
Интересно, что:
SMTP не имеет ничего общего с запросом
ошибка [13] базы данных тоже нерелевантна
документ про восстановление доступа получил ненулевое сходство в основном благодаря совпадению слова “пользователя”
При этом система всё ещё не понимает, что:
“восстановить” и “восстановление” связаны
“пароль” и “доступ” могут быть близкими по смыслу
Без стемминга (stemming) [14] или лемматизации (lemmatization) [15] такие слова считаются разными токенами.
И хотя система: не понимает семантику текста, не знает синонимов, не учитывает контекст и не не использует нейросети – она просто работает со статистикой слов.
Таким образом, хотя мы и убедились на довольно простом примере, что система работает, у неё есть ограничения. Она не понимает смысл текста по-настоящему: не знает синонимов, плохо работает с разными формами слов и не учитывает контекст. По сути, поиск строится в основном на совпадении терминов.
Например, для текущей реализации слова:
“восстановить”
“восстановление”
считаются разными токенами.
То же самое касается:
“доступ”
“пароль”
Система просто не знает, что эти слова могут быть связаны по смыслу.
Чтобы решить это, обычно добавляют:
stemming [14]
lemmatization [15]
word embeddings [10]
transformer‑модели [11]
Но фундамент остаётся тем же: текст всё равно превращается в вектор. И этот кейс показывает очень важную идею всей области NLP.
Даже простая статистика слов уже позволяет строить полезные поисковые системы.
Без нейросетей. Без GPU/TPU. Без LLM.
Только: слова, веса, векторы и немного линейной алгебры.
Именно с таких систем исторически начинался поиск по тексту – и именно они до сих пор лежат внутри многих production-систем как быстрый и надёжный базовый уровень.
Если вам интересна тема AI в PHP, можно глубже погрузиться в неё в моей бесплатной книге: “AI для PHP-разработчиков: интуитивно и на практике [16]“.
А чтобы лучше понять, как всё работает, – попробуйте интерактивные онлайн-примеры [17] и поэкспериментируйте с кодом самостоятельно.
Автор: samako
Источник [18]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/30210
URLs in this post:
[1] От массивов к GPU: как PHP-экосистема приходит к настоящему ML: https://habr.com/ru/articles/1019250/
[2] Практическое использование TransformersPHP: https://habr.com/ru/articles/993966/
[3] Практика без Python и data science: https://habr.com/ru/articles/984042/
[4] Собираем простейшую RAG-систему на PHP с Neuron AI за вечер: https://habr.com/ru/articles/966792/
[5] Как я пытался подружить PHP с NER – драма в 5 актах: https://habr.com/ru/articles/948014/
[6] математикой: http://www.braintools.ru/article/7620
[7] Bag of Words: https://ru.wikipedia.org/wiki/%D0%9C%D0%B5%D1%88%D0%BE%D0%BA_%D1%81%D0%BB%D0%BE%D0%B2
[8] TF–IDF: https://ru.wikipedia.org/wiki/TF-IDF
[9] cosine similarity: https://en.wikipedia.org/wiki/Cosine_similarity
[10] embeddings: https://ru.wikipedia.org/wiki/%D0%92%D0%B5%D0%BA%D1%82%D0%BE%D1%80%D0%BD%D0%BE%D0%B5_%D0%BF%D1%80%D0%B5%D0%B4%D1%81%D1%82%D0%B0%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5_%D1%81%D0%BB%D0%BE%D0%B2
[11] transformer-модели: https://ru.wikipedia.org/wiki/%D0%A2%D1%80%D0%B0%D0%BD%D1%81%D1%84%D0%BE%D1%80%D0%BC%D0%B5%D1%80_(%D0%BC%D0%BE%D0%B4%D0%B5%D0%BB%D1%8C_%D0%BC%D0%B0%D1%88%D0%B8%D0%BD%D0%BD%D0%BE%D0%B3%D0%BE_%D0%BE%D0%B1%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D1%8F)
[12] онлайн-демонстрацией: https://aiwithphp.org/books/ai-for-php-developers/examples/part-5/bag-of-words-and-tf-idf
[13] ошибка: http://www.braintools.ru/article/4192
[14] стемминга (stemming): https://ru.wikipedia.org/wiki/%D0%A1%D1%82%D0%B5%D0%BC%D0%BC%D0%B8%D0%BD%D0%B3
[15] лемматизации (lemmatization): https://ru.wikipedia.org/wiki/%D0%9B%D0%B5%D0%BC%D0%BC%D0%B0%D1%82%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F
[16] AI для PHP-разработчиков: интуитивно и на практике: https://apphp.gitbook.io/ai-for-php-developers/
[17] интерактивные онлайн-примеры: https://aiwithphp.org/books/ai-for-php-developers/examples/
[18] Источник: https://habr.com/ru/articles/1034874/?utm_campaign=1034874&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.