Cómo aplica Tantivy diferentes tokenizadores para distintos idiomas

La búsqueda de este sitio utiliza tantivy y tantivy-jieba. Tantivy es una biblioteca de motor de búsqueda de texto completo de alto rendimiento escrita en Rust, inspirada en Apache Lucene. Soporta puntuación BM25, consultas en lenguaje natural, búsquedas por frases, recuperación facetada y múltiples tipos de campos (incluyendo texto, numéricos, fecha, IP y JSON), además de ofrecer soporte multilingüe para el análisis léxico (incluyendo chino, japonés y coreano). Cuenta con velocidades extremadamente rápidas de indexación y consulta, tiempos de arranque en milisegundos y soporte para mapeo de memoria (mmap).

Desde que agregué traducción multilingüe, el contenido buscado incluye muchos textos en otros idiomas. Recientemente logré finalmente separar las búsquedas por idioma. La solución principal consiste en que la búsqueda en un idioma determinado solo devuelva resultados de artículos en ese mismo idioma, utilizando diferentes tokenizadores según el idioma: por ejemplo, se usa tantivy-jieba para chino, lindera para japonés y el tokenizador predeterminado para inglés u otros idiomas. Así se resuelve el problema del mal rendimiento de búsqueda causado por la mezcla de varios idiomas y la falta de coincidencia entre los textos y sus tokenizadores.

Originalmente pensaba usar qdrant para búsquedas semánticas, pero como los embeddings se realizan localmente, reenviarlos y obtener resultados locales sería demasiado lento, sin mencionar el tiempo de inicialización ni la incertidumbre sobre su tasa de éxito. Aunque probablemente pueda implementarlo más adelante en una cuenta oficial de WeChat; veré si puedo terminarlo estos días.

Escrito manualmente, para reducir la tasa de contenido generado por IA; con que se entienda, está bien. Recientemente planeo eliminar todos los artículos previamente escritos con ayuda de IA y ver cuándo Bing recupera su indexación.

1. Construcción del índice

pub async fn build_search_index() -> anyhow::Result<Index> {
	// Configurar tokenizadores separados 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();
	// Configuración del esquema del índice
    let mut schema_builder = Schema::builder();
    // Aplicar el tokenizador correspondiente 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); // No almacenado
    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);
	//... Otros campos
    let schema = schema_builder.build();

    // Crear el índice en memoria
    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);
	// Chino
    let jieba_analyzer = TextAnalyzer::builder(JiebaTokenizer {})
        .filter(RemoveLongFilter::limit(40))
        .build();
    index.tokenizers().register("jieba", jieba_analyzer);
    // Escribir en el índice (el número indica límite de memoria)
    let mut index_writer = index.writer(50_000_000)?;

    let all_articles = tus artículos.

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

        // Aquí se aplica el tokenizador; chino simplificado y tradicional serán filtrados mediante 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. Búsqueda en el índice

Sería mejor hacer coincidir primero el idioma y luego buscar en los campos correspondientes, pero como ya estaba funcionando, no lo cambié.

#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
    // Uso moka para mantenerlo en memoria, después de todo son pocos artículos.
    let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
        ServerFnErrorErr::ServerError("Índice de búsqueda no encontrado en caché.".to_string())
    })?;
	// Obtener campos
    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 búsqueda: Occur::Must significa que debe aparecer, cumpliendo todos los requisitos en 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 del usuario
    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));
    }
	// Otros 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)?;
            // Convertir 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()
        }
	// Otros criterios de ordenamiento... aquí principalmente ordeno por fecha
    }; // No sé si los paréntesis están bien alineados

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

El rendimiento de búsqueda de tantivy es bastante bueno. Aunque aún no soporta búsqueda semántica, tanto la velocidad como los resultados son excelentes. Muchas bases de datos vectoriales utilizan índices de tantivy para sus funciones de búsqueda de texto completo.

Para obtener más información detallada sobre el uso de tantivy, véase: ejemplo oficial de tantivy, que contiene 20 ejemplos muy detallados de búsqueda, cada uno con explicaciones completas.

Artículos que podrían interesarte

Descubre más contenido interesante

Comentario