Como o Tantivy aplica tokenizadores diferentes para idiomas distintos?

A busca deste site foi construída usando tantivy e tantivy-jieba. O Tantivy é uma biblioteca de alto desempenho para busca全文 escrita em Rust, inspirada no Apache Lucene. Ele suporta pontuação BM25, consultas em linguagem natural, busca por frases, recuperação facetada e diversos tipos de campos (incluindo texto, números, datas, IP e JSON), além de oferecer suporte a tokenização multilíngue (incluindo chinês, japonês e coreano). Possui velocidade muito alta tanto na indexação quanto nas consultas, tempo de inicialização em milissegundos e suporte a memory mapping (mmap).

Desde que adicionei tradução multilíngue, o conteúdo buscado passou a conter muitos textos em outros idiomas. Recentemente, finalmente consegui separar as buscas por idioma. A principal solução foi garantir que a busca em um determinado idioma retorne apenas artigos nesse mesmo idioma, utilizando tokenizadores diferentes conforme o idioma: por exemplo, tantivy-jieba para chinês, lindera para japonês e o tokenizador padrão para inglês e outros idiomas. Isso resolveu os problemas de baixa qualidade nas buscas causados pela mistura de idiomas e pelo uso inadequado dos tokenizadores.

Inicialmente pensei em usar o qdrant para busca semântica, mas como o embedding é feito localmente, encaminhar novamente para obter resultados locais seria muito lento — nem sei quanto tempo levaria para inicializar, e a taxa de sucesso também é incerta. Talvez eu possa implementar isso no公众号 do WeChat; vou ver se consigo terminar nos próximos dias.

Escrito manualmente, para reduzir a taxa de detecção como conteúdo gerado por IA; desde que dê para entender, está bom. Estou planejando excluir todos os artigos anteriores escritos com IA e ver quando o Bing volta a indexar normalmente.

1. Construção do índice

pub async fn build_search_index() -> anyhow::Result<Index> {
	// Configurar tokenizadores separadamente para cada idioma
    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();
	// Definição do schema do índice
    let mut schema_builder = Schema::builder();
    // Aplicar o tokenizador correspondente a cada campo
    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); // Não armazenado
    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);
	//... Outros campos
    let schema = schema_builder.build();

    // Criar o índice na memória
    let index = Index::create_in_ram(schema);

    // Registrar tokenizadores para diferentes idiomas
    let en_analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
        .filter(LowerCaser)
        .filter(Stemmer::new(tantivy::tokenizer::Language::English))
        .build();
    index.tokenizers().register("en", en_analyzer);
    // Japonês
    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);
	// Chinês
    let jieba_analyzer = TextAnalyzer::builder(JiebaTokenizer {})
        .filter(RemoveLongFilter::limit(40))
        .build();
    index.tokenizers().register("jieba", jieba_analyzer);
    // Escrever no índice (o número indica limite de memória)
    let mut index_writer = index.writer(50_000_000)?;

    let all_articles = seus_artigos;

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

        // Aqui aplica-se o tokenizador; chinês simplificado e tradicional serão filtrados via 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. Busca no índice

Seria melhor verificar primeiro o idioma com match e depois buscar apenas nos campos correspondentes, mas como já estava funcionando, não mudei.


#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
    // Estou usando moka para manter na memória, afinal são poucos artigos.
    let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
        ServerFnErrorErr::ServerError("Índice de busca não encontrado no cache.".to_string())
    })?;
	// get_field
    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();
	// Filtro de busca: Occur::Must significa que deve ocorrer, ou seja, satisfazer todos os requisitos em queries: 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,
        ],
    );
	// Consulta do usuário
    let user_query = query_parser.parse_query(&query.q)?;
    queries.push((Occur::Must, user_query));
	// Filtrar por idioma
    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));
    }
	// Outros filtros
	...

    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)?;
            // Converter de 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()
        }
	// Outros critérios de ordenação... aqui priorizo ordenação por data
    }; // Não sei se os parênteses estão alinhados corretamente

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

3. Considerações finais

O desempenho da busca com Tantivy é bastante bom. Embora ainda não suporte busca semântica, sua velocidade e eficácia são excelentes. Muitos bancos de dados vetoriais usam índices Tantivy para busca全文.

Para mais informações detalhadas sobre o uso do Tantivy, consulte: exemplo oficial do Tantivy, que contém 20 exemplos de busca muito detalhados, cada um com explicações completas.

Artigos que você pode estar interessado

Descubra mais conteúdos interessantes

Comentário