Как Tantivy применяет разные токенизаторы для разных языков?

Поиск на этом сайте построен с использованием tantivy и tantivy-jieba. Tantivy — это высокопроизводительная библиотека полнотекстового поиска, написанная на Rust, вдохновлённая Apache Lucene. Она поддерживает ранжирование по BM25, запросы на естественном языке, поиск фраз, фасетный поиск и различные типы полей (включая текстовые, числовые, даты, IP-адреса и JSON), а также предоставляет многоязычную поддержку токенизации (включая китайский, японский и корейский). Библиотека отличается высокой скоростью индексирования и поиска, запуском за миллисекунды и поддержкой отображения в память (mmap).

После добавления поддержки нескольких языков содержимое поиска стало смешиваться с текстами на других языках. Недавно мне удалось, наконец, разделить поиск по языкам. Основная задача была решена: при поиске на текущем языке возвращаются только статьи на соответствующем языке, при этом для каждого языка используется свой токенизатор — например, для китайского применяется tantivy-jieba, для японского — lindera, а для английского и других языков используется токенизатор по умолчанию. Таким образом была решена проблема неэффективного поиска из-за смешивания языков и несоответствия токенизаторов.

Изначально я планировал использовать qdrant для семантического поиска, но поскольку embedding выполняется локально, пересылка данных обратно сделает результаты слишком медленными, да и время инициализации непредсказуемо, не говоря уже о неопределённой вероятности успеха. Однако, возможно, я добавлю эту функцию в официальный аккаунт WeChat, если успею реализовать в ближайшие дни.

Написано вручную, чтобы снизить долю контента, созданного ИИ; главное — понятно. В ближайшее время планирую удалить все статьи, ранее написанные с помощью ИИ, и проверить, восстановится ли индексация в Bing.

1. Построение индекса

pub async fn build_search_index() -> anyhow::Result<Index> {
	// Настройка токенизаторов для каждого языка
    let en_text_options = TextOptions::default()
        .set_indexing_options(
            TextFieldIndexing::default()
                .set_tokenizer("en")
                .set_index_option(IndexRecordOption::WithFreqsAndPositions),
        )
        .set_stored();
    let zh_text_options = TextOptions::default()
        .set_indexing_options(
            TextFieldIndexing::default()
                .set_tokenizer("jieba")
                .set_index_option(IndexRecordOption::WithFreqsAndPositions),
        )
        .set_stored();
    let ja_text_options = TextOptions::default()
        .set_indexing_options(
            TextFieldIndexing::default()
                .set_tokenizer("lindera")
                .set_index_option(IndexRecordOption::WithFreqsAndPositions),
        )
        .set_stored();
	// Настройка схемы индекса
    let mut schema_builder = Schema::builder();
    // Применение соответствующего токенизатора к каждому полю
    let title_en_field = schema_builder.add_text_field("title_en", en_text_options.clone());
    let content_en_field = schema_builder.add_text_field("content_en", en_text_options); // Не сохраняется
    let title_zh_field = schema_builder.add_text_field("title_zh", zh_text_options.clone());
    let content_zh_field = schema_builder.add_text_field("content_zh", zh_text_options);
    let title_ja_field = schema_builder.add_text_field("title_ja", ja_text_options.clone());
    let content_ja_field = schema_builder.add_text_field("content_ja", ja_text_options);
	//... другие поля
    let schema = schema_builder.build();

    // Создание индекса в памяти
    let index = Index::create_in_ram(schema);

    // Регистрация токенизаторов для разных языков
    let en_analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
        .filter(LowerCaser)
        .filter(Stemmer::new(tantivy::tokenizer::Language::English))
        .build();
    index.tokenizers().register("en", en_analyzer);
    // Японский
    let dictionary = load_embedded_dictionary(lindera::dictionary::DictionaryKind::IPADIC)?;
    let segmenter = Segmenter::new(Mode::Normal, dictionary, None);
    //let tokenizer = LinderaTokenizer::from_segmenter(segmenter);

    let lindera_analyzer = TextAnalyzer::from(LinderaTokenizer::from_segmenter(segmenter));
    index.tokenizers().register("lindera", lindera_analyzer);
	// Китайский
    let jieba_analyzer = TextAnalyzer::builder(JiebaTokenizer {})
        .filter(RemoveLongFilter::limit(40))
        .build();
    index.tokenizers().register("jieba", jieba_analyzer);
    // Запись индекса (число — ограничение по памяти)
    let mut index_writer = index.writer(50_000_000)?;

    let all_articles = ваши статьи.

    for article in all_articles {
        let mut doc = TantivyDocument::new();
        doc.add_text(lang_field, &article.lang);

        // Здесь применяются токенизаторы; упрощённый и традиционный китайский будут отфильтрованы через lang_field.
        match article.lang.as_str() {
            "zh-CN" | "zh-TW" => {
                doc.add_text(title_zh_field, &article.title);
                doc.add_text(content_zh_field, &article.md);
            }
            "ja" => {
                doc.add_text(title_ja_field, &article.title);
                doc.add_text(content_ja_field, &article.md);
            }
            _ => {
                doc.add_text(title_en_field, &article.title);
                doc.add_text(content_en_field, &article.md);
            }
        }

        index_writer.add_document(doc)?;
    }

    index_writer.commit()?;
    index_writer.wait_merging_threads()?;

    Ok(index)
}

2. Поиск по индексу

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

#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
    // Я храню индекс в памяти через moka — статей немного.
    let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
        ServerFnErrorErr::ServerError("Индекс поиска не найден в кэше.".to_string())
    })?;
	// Получение полей
    let schema = index.schema();
    let title_en_f = schema.get_field("title_en").unwrap();
    let content_en_f = schema.get_field("content_en").unwrap();
    let title_zh_f = schema.get_field("title_zh").unwrap();
    let content_zh_f = schema.get_field("content_zh").unwrap();
    let title_ja_f = schema.get_field("title_ja").unwrap();
    let content_ja_f = schema.get_field("content_ja").unwrap();
    let canonical_f = schema.get_field("canonical").unwrap();
    let lang_f = schema.get_field("lang").unwrap();

    let reader = index.reader()?;
    let searcher = reader.searcher();
	// Фильтрация по Occur::Must — условие обязательно должно выполняться, должны удовлетворяться все запросы в Vec
    let mut queries: Vec<(Occur, Box<dyn tantivy::query::Query>)> = Vec::new();

    let query_parser = QueryParser::for_index(
        &index,
        vec![
            title_en_f,
            content_en_f,
            title_zh_f,
            content_zh_f,
            title_ja_f,
            content_ja_f,
        ],
    );
	// Запрос пользователя
    let user_query = query_parser.parse_query(&query.q)?;
    queries.push((Occur::Must, user_query));
	// Фильтрация по языку
    if let Some(lang_code) = &query.lang {
        let lang_term = Term::from_field_text(lang_f, lang_code);
        let lang_query = Box::new(TermQuery::new(lang_term, IndexRecordOption::Basic));
        queries.push((Occur::Must, lang_query));
    }
	// Другие фильтры
	...

    let final_query = BooleanQuery::new(queries);

    let hits: Vec<Hit> = match query.sort {
        SortStrategy::Relevance => {
            let top_docs = TopDocs::with_limit(query.limit);
            let search_results: Vec<(Score, DocAddress)> =
                searcher.search(&final_query, &top_docs)?;
            // Преобразование из Vec<(Score, DocAddress)>
            search_results
                .into_iter()
                .filter_map(|(score, doc_address)| {
                    let doc = searcher.doc::<TantivyDocument>(doc_address).ok()?;
                    let title = doc
                        .get_first(title_en_f)
                        .or_else(|| doc.get_first(title_zh_f))
                        .or_else(|| doc.get_first(title_ja_f))
                        .and_then(|v| v.as_str())
                        .unwrap_or("")
                        .to_string();

                    let formatted_lastmod =
                        match DateTime::parse_from_rfc3339(doc.get_first(lastmod_str_f)?.as_str()?)
                        {
                            Ok(dt) => {
                                let china_dt = dt.with_timezone(&Shanghai);
                                china_dt.format("%Y-%m-%d").to_string()
                            }
                            Err(_) => doc.get_first(lastmod_str_f)?.as_str()?.to_string(),
                        };
                    Some(Hit {
                        title,
                        canonical: doc.get_first(canonical_f)?.as_str()?.to_string(),
                        lastmod: formatted_lastmod,
                        score,
                    })
                })
                .collect()
        }
	// Другие варианты сортировки... У меня основная сортировка — по времени
    }; // Не уверен, правильно ли выровнены скобки

    serde_json::to_string(&hits).map_err(|e| ServerFnError::ServerError(e.to_string()))
}

3. Заключение

Результаты поиска с помощью tantivy довольно хороши: хотя семантический поиск пока недоступен, скорость и качество работы очень высокие. Многие векторные базы данных используют индекс tantivy для полнотекстового поиска.

Более подробную информацию об использовании tantivy можно найти здесь: официальные примеры tantivy, где представлены 20 подробных примеров поиска с исчерпывающими объяснениями.

Статьи, которые могут вас заинтересовать

Найдите больше интересного контента

Комментарий