Привет, Хабр!
Cегодня я хочу поделиться с вами опытом работы с библиотекой Tantivy — это полнотекстовый поисковый движок, написанный на Rust. Если вы когда‑либо задумывались о том, чтобы встроить поиск в свое приложение на Rust (вместо того чтобы поднимать отдельный ElasticSearch или Solr), то Tantivy неплохой такой кандидат. Библиотека вдохновлена Lucene (тем самым движком, на котором работают Solr и ElasticSearch) и дает схожие возможности: индексирование текста, быстрый поиск по ключевым словам, поддержку сложных запросов.
Что такое Tantivy и зачем он нужен
Tantivy — это полнотекстовый поисковый движок для Rust. Под полнотекстовым подразумевается, что библиотека умеет индексировать большое количество текста и затем эффективно находить документы, содержащие искомые слова или фразы.
Важно понимать нишу Tantivy: она ближе к Apache Lucene, чем к ElasticSearch. То есть Tantivy не предоставляет распределённости или кластеризации из коробки, не умеет сама по себе быть сервером по HTTP. Это именно низкоуровневая библиотека. Однако на ее базе строят более высокоуровневые системы. Пример — проект Quickwit, распределенный поисковый движок на Rust, использует Tantivy. Также существует tantivy‑cli, утилита командной строки, с помощью которой можно поиграться с возможностями Tantivy (например, проиндексировать Википедию и выполнять запросы). Но в этой статье мы сосредоточимся на использовании Tantivy в Rust‑коде.
Из коробки Tantivy поддерживает все основные фичи поискового движка: инвертированный индекс для быстрого поиска по словам, оценка релевантности документов по BM25 (на основе частоты слов и распространенности слов в корпусе), булевы запросы, фразовый поиск, поиск с опциональными и обязательными словами, диапазонные запросы по числовым полям, и многое другое. Поддерживаются текстовые поля с разбиением на токены (и стеммингом для ряда языков), а также поля других типов: числа, даты, булевы, гео‑адреса (IP), фасеты (иерархические теги) и произвольные JSON. Библиотека автоматически ведет статистику по терминам (сколько документов содержат каждое слово), что нужно для расчета рейтингов. В общем, по функциональности Tantivy очень близок к Lucene, но написан на современном Rust.
Создаем схему индекса
Прежде чем индексировать данные, Tantivy требует задать схему индекса. Схема описывает, какие поля будут у документов, и как каждый поле индексируется. В Tantivy схема строго типизирована и задается явно — добавить поле «на лету» потом нельзя, поэтому продумываем структуру заранее.
Допустим, мы делаем поисковый индекс для небольшого набора книг с полями: идентификатор, заголовок и основной текст. Идентификатор нам пригодится для удаления или обновления документов. Поле заголовка мы хотим индексировать (чтобы по нему искать) и хранить в индексе (чтобы выдавать в результатах поиска). Основной текст книги будем только индексировать (хранить целиком не будем, допустим он большой). Опишем эту схему с помощью Tantivy:
use tantivy::schema::*; // Импортируем типы для схемы
// 1. Создаем билдера схемы
let mut schema_builder = Schema::builder();
// 2. Добавляем поле "id" – текстовое, не разбиваемое на токены (STRING), и индексируемое, и хранимое
let id_field = schema_builder.add_text_field("id", STRING | STORED);
// 3. Добавляем поле "title" – текстовое с токенизацией (TEXT), индексируемое и хранимое
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
// 4. Добавляем поле "body" – большой текст, токенизируем (TEXT), индексируем, но можно не хранить
let body_field = schema_builder.add_text_field("body", TEXT);
// 5. Строим объект Schema
let schema = schema_builder.build();
Использовали несколько предопределенных констант‑опций: STRING, TEXT, STORED. Они задают, как поле будет обрабатываться:
TEXT, поле будет токенизироваться (разбиваться на слова, приводиться к нижнему регистру и так далее) и индексироваться, то есть слова из текста попадут в инвертированный индекс. Также для таких полей по умолчанию сохраняется информация о позиции слов (это нужно для фразового поиска) и частоте слов.
STRING,поле рассматривается как единое цельное значение (без разбиения на слова). По сути,
STRINGэто текстовое поле, но без токенизации. Например, для идентификаторов или тегов, где нужно точное совпадение, а не поиск по отдельным словам.STORED, отмечает поле для хранения. Tantivy может сохранять оригинальное значение поля для каждого документа (в специальном хранилище документов). Хранимые поля можно потом получить из результатов поиска, чтобы, например, показать заголовок или ссылку. Если поле не помечено как STORED, вы не сможете достать его текст из индекса, только искать по нему.
Комбинируя эти флаги, мы настроили: id как STRING|STORED (не разбиваем, но храним и индексируем), title как TEXT|STORED (разбиваем на токены, храним и индексируем), body как TEXT (индексируем с токенизацией, но не храним целиком). После добавления полей мы получили финальный Schema. Теперь на основе этой схемы можно создавать индекс.
Tantivy поддерживает и числовые поля u64, i64, f64, булевы, даты, facet (иерархические метки) и др. Для чисел есть специальные оптимизации, так называемые fast fields: колоночные структуры, позволяющие очень быстро фильтровать по диапазону или сортировать результаты. Но в данной статье мы в примерах обойдемся текстовыми полями. Если нужно добавить, скажем, целочисленное поле, есть методы add_u64_field, add_i64_field и тому подобное, где можно указать опции (indexed, stored, fast). Концепция та же: нужно явно задать, будет ли поле индексироваться для поиска, храниться для отдачи клиенту, участвовать ли в сортировке как fast‑поле и так далее
Индексирование документов
Схема есть, создаем индекс. Индекс в Tantivy состоит из сегментов, хранящихся в указанном хранилище. Чаще всего это директория на диске (локальный индекс), но возможно хранение и в памяти. Создадим новый пустой индекс в директории и проиндексируем несколько документов:
use tantivy::{Index, doc}; // doc! макрос для удобного создания документов
// Пусть index_path – это путь к каталогу для индекса (например, "./tantivy-index")
let index = Index::create_in_dir(index_path, schema.clone())?;
// Создаем IndexWriter с буфером в 100 мегабайт
let mut index_writer = index.writer(100_000_000)?;
// Подготовим набор документов для индексации
let documents = vec![
("doc1", "The Old Man and the Sea", "He was an old man who fished alone in a skiff..."),
("doc2", "For Whom the Bell Tolls", "He lay on the brown, pine-needled floor of the forest..."),
("doc3", "Мастер и Маргарита", "В час жаркого заката в Москве, на Патриарших прудах..."),
];
for (id, title, body) in documents {
// Добавляем документ в индекс
index_writer.add_document(doc!(
id_field => id,
title_field => title,
body_field => body
))?;
}
// Завершаем индексирование, сохраняем сегмент на диск
index_writer.commit()?;
Сначала мы создали индекс в указанной директории Index::create_in_dir. Индексу передается наша схема, чтобы он знал структуру документов. Затем мы получили index.writer(...), это IndexWriter, через который выполняется всё добавление документов. В writer(100_000_000) указали размер буфера в байтах (100 MB). IndexWriter в Tantivy работает асинхронно и параллельно: он будет накапливать документы в памяти (в нескольких потоках), и примерно при достижении этого порога начнет их сбрасывать на диск в виде нового сегмента. Больший буфер — быстрее индексирование, но и памяти нужно больше.
Далее мы подготовили список документов (для примера я добавил два английских и один русский текст, Tantivy поддерживает Unicode и может индексировать тексты на любом языке, но о нюансах токенизации русского чуть позже). В цикле проходим по документам и вызываем index_writer.add_document(...). Здесь используется удобный макрос doc! для создания документа: внутри него мы перечисляем поля и значения. Tantivy автоматически каждому полю сопоставляет значение нужного типа (строки тут конвертируются в нужный вид). После добавления всех документов обязательно вызываем index_writer.commit(). Без commit документы не попадут в поисковый индекс окончательно. commit выполняет следующие действия:
завершает обработку всех добавленных документов (в том числе дожидается, пока потоковые операции индексирования завершатся).
сбрасывает накопленные в RAM данные на диск, формируя новый сегмент (набор файлов).
обновляет метаданные индекса, помечая, что появился новый сегмент (и новые документы).
после commit новые документы становятся доступны для поиска (но об этом чуть далее, нужен еще шаг с обновлением читателя).
Tantivy не умеет автоматически коммитить по времени или по размеру (если буфер заполнится, он начнет сбрасывать в сегмент, но чтобы эти данные стали видны при поиске, все равно нужен commit). Поэтому в типичном случае вы сами решаете, когда коммитить: например, после индексации батча документов или по таймеру. Частые коммиты делают больше мелких сегментов, слишком редкие держат много данных в памяти и дольше блокируют поискового читателя от обновлений. Нужно находить баланс под свою задачу.
После выполнения этого кода на диске в index_path вы найдете несколько файлов, это и есть сегмент с нашими тремя документами, плюс файл метаданных. Tantivy использует собственный формат хранения (близкий к Lucene): документы разбиваются на сегменты, внутри сегмента хранятся файлы инвертированного индекса, словарь терминов, хранилище документов, вспомогательные структуры (нормы, fast‑поля и пр.). Мы еще вернемся к структуре сегмента в разделе про внутренности.
Насчет локализации и токенизации. По дефолту Tantivy применяет стандартный токенизатор: он режет текст по пробелам и пунктуации, приводит к нижнему регистру и отбрасывает слишком длинные токены. Также по умолчанию подключен стеммер для английского (и нескольких европейских языков) через библиотеку rust-stemmers. Для русского языка стеммера, кажется, нет, и слова индексируются как есть (но в нижнем регистре). Если вы работаете с языками, отличными от английского, возможно, придется подключить свои токенизаторы или отключить стемминг. В Tantivy можно регистрировать кастомные токенизаторы через index.tokenizers(), например, использовать lemmatize_ru для русского или другие библиотеки. В нашем примере я просто добавил русский текст как есть, Tantivy его проиндексирует (разобьет на слова), и искать по русским словам можно будет. Однако морфология русского не учтена (слово «заката» и «закат» будут разными токенами).
После индексации у нас есть сохраненный индекс. Давайте теперь выполним поиск по нему.
Поиск документов
Для поиска Tantivy предоставляет объект IndexReader и Searcher. Общая схема такая:
Открываем
IndexReaderу нашего индекса. Он открывает необходимые файлы сегментов и готовится отдавать из них данные.Получаем из reader объект
Searcherэто обертка, которая знает про все сегменты и умеет по ним искать.Строим запрос. Для простых случаев удобно пользоваться
QueryParser, парсером строковых запросов, похожим на запросы Lucene. Он умеет понимать логические операторы, кавычки для фраз, префиксные и диапазонные запросы и пр.Передаем запрос
searcher‑у и выбираем коллектор для результатов. Чаще всего используется коллектор TopDocs, он выберет топ‑N самых релевантных документов.Получаем список результатов (документы с указанием их оценки релевантности), и можем извлечь сами документы (точнее, те поля, которые мы пометили как STORED).
Покажем это в коде, продолжая наш пример:
use tantivy::{ReloadPolicy, TopDocs};
// Открываем индекс (если он не в памяти, можно повторно открыть по тому же path)
let index = Index::open_in_dir(index_path)?;
// Получаем reader с политикой автоматической подгрузки новых коммитов
let reader = index.reader_builder().reload_policy(ReloadPolicy::OnCommit).try_into()?;
// Альтернативно: let reader = index.reader()?; // использует политику по умолчанию
let searcher = reader.searcher();
// Инициализируем парсер запросов по полям title и body
let mut query_parser = QueryParser::for_index(&index, vec![title_field, body_field]);
// Дополнительно: настроим парсер, чтобы по умолчанию требовать все слова (AND)
query_parser.set_conjunction_by_default();
// Составляем запрос: например, ищем документы, где есть слова "old" И "sea" (оба)
let query = query_parser.parse_query("old AND sea")?;
// Выполняем поиск: просим top-10 результатов
let top_docs = searcher.search(&query, &TopDocs::with_limit(10))?;
// Обходим результаты и выводим их
for (score, doc_address) in top_docs {
let retrieved = searcher.doc(doc_address)?;
let title = retrieved.get_first(title_field).unwrap().text().unwrap();
println!("Документ: {} (релевантность {:.3})", title, score);
}
Сначала мы открыли индекс с диска через Index::open_in_dir. Затем получили reader. Здесь показан способ с reader_builder(), мы указали ReloadPolicy::OnCommit. Это значит, что reader будет автоматически замечать новые коммиты в этом индексе и подхватывать их (с небольшим задержкой). Проще говоря, если где‑то в другом месте кода наш index_writer.commit() добавит новые документы, reader вскоре начнет их видеть без дополнительного кода. Альтернативно, можно было вызвать просто index.reader() — тогда будет использована политика по умолчанию (кажется, OnCommitWithDelay, то есть тоже автоматическая). В старых версиях Tantivy нужно было вручную дергать reader.reload()? после каждого коммита, но сейчас это можно автоматизировать. Я выбрал явное указание политики для наглядности.
Получив searcher, настраиваем QueryParser. Мы передали ему ссылку на индекс и список полей, по которым нужно искать по умолчанию (так называемые default_fields). В нашем случае логично искать и по заголовку, и по основному тексту, поэтому передаем вектор из title_field и body_field. Можно указать только одно поле, тогда без явного префикса запрос будет искать только в нем.
Я также вызвал set_conjunction_by_default(). Дело в том, что по умолчанию QueryParser трактует последовательность слов как слово1 OR слово2 ... (то есть хотя бы одно из слов должно быть). Вы можете настроить как вам нужно. В нашем примере запрос "old AND sea" явно требует оба слова, но если бы пользователь ввел просто "old sea" без оператора, то с включенным set_conjunction_by_default это бы трактовалось как old AND sea.
Метод parse_query разбирает строку запроса в объект Query. QueryParser умеет довольно многое:
Просто слова через пробел (с учетом правила выше про AND/OR).
Операторы AND, OR, а также унарные
+(обязательное слово) и-(исключаемое слово). Например, запрос"rust +guide -beta"означает, что документ должен содержать слово «guide», может содержать «rust», и не должен содержать «beta».Кавычки для фраз:
"Old man"будет искаться как точная фраза (слова рядом в указанном порядке) в полях с позиционными данными. Наше поле body как TEXT хранит позиции, так что фразовый поиск по нему возможен.Префиксный поиск: если поставить в конце слова, QueryParser поймет как префикс. Пример: запрос
"title:Rust"найдет документы, где в поле title есть слова, начинающиеся на „rust“ (rust, rusty, rustacean...). Надо учитывать, что префиксные запросы могут возвращать очень много вариантов, это потенциально дорого.“»Диапазоны для числовых или строковых полей: например, если бы было поле
year(у нас его нет) типа u64, запросyear:[1950 TO 2020]нашел бы документы с годом в заданном интервале. Для строк можно делать лексикографические диапазоны, но это редкая необходимость.Множества: синтаксис
field: IN [value1 value2 ...]позволяет искать точное совпадение поля против набора значений (эффективнее, чем писать несколькоOR).Полнотекстовый по нескольким полям: как я описал ранее,
title:foo body:fooгенерируется автоматически, если указать default_fields. Можно и явно указывать префиксы в запросе, например:title:sea body:sea— но обычно достаточно указать default_fields один раз при настройке парсера.Фuzzy (неточные) запросы: Tantivy умеет делать нечеткий поиск (по расстоянию Левенштейна) для отдельных терминов, но их нужно специально включить. Есть метод
query_parser.set_field_fuzzy(field, distance), после чего, кажется, синтаксисword~начнет работать для заданного поля. В статье не будем углубляться, но имейте в виду, что «фаззи» поиск по аналогии с Lucene тоже присутствует.Буствание: можно повышать вклад поля или терма с помощью
^. Например,Rust^2.0 AND guideповысит вес терма «Rust» в 2 раза. Также можно задать boost на уровне поля (чтобы совпадения в заголовке были важнее, чем в теле) через методset_field_boost(field, factor).
В общем, язык запросов близок к классическому Lucene Query. Наш запрос "old AND sea" довольно простой, он ищет документы, где встречаются оба слова «old» и «sea» в любом порядке и в любом из указанных полей (title или body).
Далее мы выполняем поиск: searcher.search(&query, &TopDocs::with_limit(10)). В данном случае мы используем готовый коллектор TopDocs, который вернет нам топ-10 результатов, отсортированных по убыванию оценки. Можно указать другой коллектор, например, Count для просто подсчета количества совпавших документов, или свои кастомные. Tantivy позволяет писать собственные коллекторы, если нужно нестандартное поведение при сборе результатов (например, агрегировать по каким‑то полям). Но самым распространенным случаем остается топ‑N документов.
top_docs в результате — это вектор кортежей (score, doc_address). score — это f32 оценка релевантности (чем выше, тем документ лучше подходит под запрос). Tantivy, начиная с каких‑то версий, использует BM25 — популярную функцию ранжирования, пришедшую на смену классическому TF/IDF. По сути, score учитывает частоту слов в документе (term frequency) и обратную частоту в корпусе (IDF), плюс длину документа. Но в детали формулы уходить не будем; достаточно знать, что результаты более‑менее разумно сортируются по релевантности. DocAddress — это адрес документа, состоящий из идентификатора сегмента и локального номера документа в сегменте. Нам не нужно вручную разбирать этот адрес, мы передаем его в searcher.doc(doc_address) чтобы получить документ обратно.
searcher.doc(...) возвращает документ (тип TantivyDocument). Этот документ будет содержать только хранимые поля! То есть в нашем случае мы сможем достать id и title, потому что мы их пометили STORED, а body не сможем (да оно нам и не нужно для выдачи результатов, обычно).
Если вы запустите этот код на трех документах и поисковом запросе "old AND sea", должен найтись наш документ «The Old Man and the Sea» (Хемингуэй), с каким‑то положительным score, и два других документа не должны появиться (во втором нет слова «sea», в третьем нет ни «old» ни «sea» вообще).
Объект IndexReader можно клонировать (на самом деле у него внутри Arc). Обычно делают так: создают один reader, затем дергают reader.searcher() в каждом потоке, где нужен поиск (Search API в Tantivy потокобезопасный). IndexReader автоматически следит за обновлениями индекса (в зависимости от политики). Если вы сделали новый коммит документов, то либо вызываете reader.reload()?, либо, как мы сделали, настроив OnCommit, ждете пару секунд и новый сегмент подхватится. После этого новые документы будут находиться при поиске.
Обновление и удаление документов
Жизнь поиска это не только добавление, но и удаление или обновление документов. Tantivy, как и Lucene, придерживается иммутабельной модели индекса: существующие сегменты после коммита не изменяются (они только читаются). Поэтому обновление документа реализуется как «удалить старую версию + добавить новую».
Практически это выглядит так: когда вы хотите удалить документы с определенным свойством, нужно вызвать метод index_writer.delete_term(term). Term в Tantivy — это пара (поле, значение). Например, мы можем удалить документ по его id. Именно поэтому полезно иметь уникальное поле id в схеме. Предположим, мы хотим удалить документ «doc2» (For Whom the Bell Tolls) и обновить документ «doc1» новым содержимым. Код будет примерно такой:
use tantivy::Term;
// Удаляем документ с id == "doc2"
index_writer.delete_term(Term::from_field_text(id_field, "doc2"))?;
// Обновляем документ "doc1": удаляем старый и добавляем новый с тем же id
index_writer.delete_term(Term::from_field_text(id_field, "doc1"))?;
index_writer.add_document(doc!(
id_field => "doc1",
title_field => "The Old Man and the Sea (Updated Edition)",
body_field => "Some new content of the book..."
))?;
// Фиксируем изменения
index_writer.commit()?;
Здесь мы дважды вызвали delete_term, по одному на каждое удаляемое значение id. Затем добавили обновленный документ. Только после коммита эти удаления/добавления применятся. Внутри Tantivy при коммите произойдет следующее: будет создан новый сегмент, куда вошел добавленный новый документ. Удаленные документы логически останутся в старых сегментах, но помечены в битовом векторе как удаленные и не будут участвовать в поиске. Таким образом, со временем индекс будет состоять из нескольких сегментов: старые сегменты с «дырками» от удаленных доков и новые сегменты с добавлениями.
Возникает вопрос: не раздуется ли индекс, если постоянно обновлять данные? Для этого существует процесс слияния сегментов, иначе merge. Tantivy в фоновом режиме может объединять сегменты, удаляя помеченные как удаленные документы физически и создавая более крупные сегменты. Стратегию слияния можно настраивать через IndexWriter::set_merge_policy, например, по умолчанию используется неплохая эвристика, схожая с Lucene, которая сливает мелкие сегменты в больший, когда их накапливается много.
Вы обычно можете не вмешиваться, Tantivy сам будет вызывать слияния при коммите в отдельном потоке. Если нужно, можно вручную инициировать оптимизацию (слить всё в один сегмент), но это редкая операция (и тяжелая по I/O).
Пока вы не сделали commit, ваши добавления/удаления «висят» в памяти. Когда сделали commit, они записаны на диск и стали видимы поиску (для всех новых поисковых запросов с новым или обновленным searcher). Если приложение упадет до commit, документов не будет, индекс останется как был. Если упадет во время commit, Tantivy либо откатит не полностью записанный сегмент при следующем открытии, либо (скорее) применит журнал операций, но деталей тут я, честно, не тестировал. В любом случае, после успешного commit вам гарантируется целостность индекса.
Дополнительные возможности Tantivy
Упомяну кратко еще несколько полезных возможностей:
Агрегации и фасеты. Можно подсчитать количество документов по условию, посчитать минимальное/максимальное/сумму по числовому полю, построить гистограмму, или сделать faceted search — разбиение результатов по категориям. Для этого в библиотеке есть модуль
aggregation. Вы можете, например, добавив в схему поле‑facet (иерархический путь, например"genre:/books/fiction"), быстро получать количество результатов по жанрам. Агрегации используют те самые fast fields и специальные структуры, так что работают очень быстро.Продвинутая настройка анализа текста. Если нужно подключить кастомный токенизатор или изменить алгоритм разбивки, Tantivy это позволяет. Можно регистрировать новые токенизаторы (есть интерфейс
Tokenizertrait), подключать фильтры (стеммеры, синонимы, стоп‑слова). Например, для китайского или японского текста понадобятся совсем другие алгоритмы токенизации — их можно интегрировать. По умолчанию, повторюсь, Tantivy идет по простому пути: разделитель — пробел/пунктуация, язык — английский стеммер (если указан Language).Поиск по префиксу и wildcard. Tantivy поддерживает префиксные запросы (слово*). Wildcard с
?и*внутри слова вроде не поддерживается парсером (это дорого реализовать напрямую), но префиксы — пожалуйста. Также, можно делать поиск по маске (например, регулярные выражения) через специальные Query (RegExpQuery), но их надо формировать вручную, QueryParser по‑моему не парсит regex.Кастомные запросы. Архитектура Tantivy позволяет реализовывать свои типы запросов, если вдруг не хватает стандартных. Можно реализовать трейт
Query— например, гео‑поиск по координатам (сейчас готового вроде нет), или какой‑нибудь ML‑ранжирование. Это уже конечно совсем для энтузиастов.Связанные проекты. Помимо Quickwit, о котором я упоминал, есть проекты, расширяющие Tantivy. Например, Tantivy‑Py — привязка Python (если вдруг нужен поиск из Python, но на ядре Rust). Есть библиотека Summa, это надстройка над Tantivy, дающая сетевой API (grpc) и некоторые доп. возможности типа индексации из Kafka, пользовательские скрипты ранжирования.
Заключение
Подводя итог, Tantivy — мощная и удобная библиотека для организации полнотекстового поиска в ваших проектах на Rust. Она предоставляет низкоуровневый контроль (вы сами решаете, когда коммитить, какие поля хранить, как именно строить запросы), но при этом избавляет от необходимости реализовывать сложнейшие структуры данных поиска вручную.
Если вы планируете внедрять поиск в своем проекте на Rust, рекомендую попробовать Tantivy в деле. Документация у него достаточно подробная, плюс можно подсмотреть идеи в исходниках tantivy‑cli или Quickwit. Спасибо за внимание, и удачного дня!
Если близка идея собирать такие вещи на Rust, присмотритесь к курсу «Rust Developer. Professional» от OTUS: best practices языка, безопасная работа с памятью, конкурентность и async, проектирование высокопроизводительных систем и разбор ключевых библиотек экосистемы. Практика — тестирование, профилирование, работа с внешними crate’ами — чтобы уверенно строить отказоустойчивые сервисы на Rust.
На странице курса можно пройти вступительный тест для проверки своих знаний, а также записаться на бесплатные уроки, которые проведут преподаватели курса в рамках набора.