Comment Tantivy applique-t-il des analyseurs lexicaux différents selon les langues ?

La recherche sur ce site est construite avec tantivy et tantivy-jieba. Tantivy est une bibliothèque de moteur de recherche plein texte haute performance écrite en Rust, inspirée d'Apache Lucene. Elle prend en charge le scoring BM25, les requêtes en langage naturel, la recherche par phrases, la recherche par facettes et divers types de champs (texte, nombres, dates, IP, JSON), ainsi qu’un support multilingue pour l’analyse lexicale, y compris chinois, japonais et coréen. Elle se distingue par une vitesse d’indexation et de recherche extrêmement élevée, un temps de démarrage en millisecondes et le support du memory mapping (mmap).

Depuis l’ajout de la traduction multilingue, les contenus recherchés incluaient beaucoup de textes dans d'autres langues. Récemment, j’ai enfin séparé les recherches selon les langues. Le problème principal résolu consiste à faire en sorte que la recherche dans une langue donnée renvoie uniquement les articles correspondants à cette langue, et que chaque langue utilise son propre analyseur lexical : par exemple, tantivy-jieba pour le chinois, lindera pour le japonais, et l’analyseur par défaut pour l’anglais, etc. Cela règle efficacement les problèmes de mauvaise qualité de recherche causés par le mélange des langues et l’inadéquation des analyseurs lexicaux.

J’avais initialement prévu d’utiliser qdrant pour la recherche sémantique, mais comme les embeddings sont générés localement, transmettre les données puis récupérer les résultats en local serait trop lent, sans compter que le temps d’initialisation est imprévisible et que le taux de réussite n’est pas garanti. Toutefois, cela pourrait être envisageable pour un compte officiel WeChat. Je verrai ces prochains jours si je peux finaliser cela.

Rédigé manuellement, pour réduire le taux d’écriture par IA ; tant que c’est compréhensible, ça me va. Récemment, je prévois de supprimer tous les anciens articles rédigés entièrement par IA, et de voir quand l’indexation Bing pourra se rétablir.

1. Construction de l’index

pub async fn build_search_index() -> anyhow::Result<Index> {
	// Définir des analyseurs lexicaux distincts pour chaque langue
    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();
	// Configuration du schéma de l’index
    let mut schema_builder = Schema::builder();
    // Appliquer l’analyseur lexical approprié à chaque champ
    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); // Non stocké
    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);
	//... autres champs
    let schema = schema_builder.build();

    // Créer l’index en mémoire
    let index = Index::create_in_ram(schema);

    // Enregistrer les analyseurs lexicaux spécifiques aux langues
    let en_analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
        .filter(LowerCaser)
        .filter(Stemmer::new(tantivy::tokenizer::Language::English))
        .build();
    index.tokenizers().register("en", en_analyzer);
    // Japonais
    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);
	// Chinois
    let jieba_analyzer = TextAnalyzer::builder(JiebaTokenizer {})
        .filter(RemoveLongFilter::limit(40))
        .build();
    index.tokenizers().register("jieba", jieba_analyzer);
    // Écrire dans l’index (le nombre indique la limite de mémoire)
    let mut index_writer = index.writer(50_000_000)?;

    let all_articles = vos articles.

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

        // Appliquer ici l’analyseur lexical ; les variantes chinoises simplifiées et traditionnelles seront filtrées 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. Recherche dans l’index

Idéalement, il faudrait d’abord identifier la langue, puis effectuer la recherche sur les champs correspondants. Mais comme cela fonctionnait déjà, je n’ai pas modifié.


#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
    // J’utilise moka pour garder l’index en mémoire, vu que le volume d’articles est limité.
    let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
        ServerFnErrorErr::ServerError("Index de recherche non trouvé dans le cache.".to_string())
    })?;
	// obtenir les champs
    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();
	// Filtre de recherche : Occur::Must signifie que le critère doit être respecté, toutes les conditions dans queries: Vec doivent être satisfaites
    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,
        ],
    );
	// Requête utilisateur
    let user_query = query_parser.parse_query(&query.q)?;
    queries.push((Occur::Must, user_query));
	// Filtrer par langue
    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));
    }
	// Autres filtres
	...

    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)?;
            // Conversion depuis 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()
        }
	// Autres tris... Ici, principalement tri chronologique
    }; // Pas sûr que les parenthèses soient bien alignées

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

3. Conclusion

Les performances de recherche de Tantivy sont très bonnes. Bien qu’il ne prenne pas encore en charge la recherche sémantique, sa vitesse et sa précision sont excellentes. De nombreuses bases de données vectorielles utilisent également l’index Tantivy pour leurs fonctionnalités de recherche plein texte.

Pour plus d’informations détaillées sur l’utilisation de Tantivy, consultez : exemple officiel de tantivy, qui propose 20 exemples très complets, chacun accompagné d’explications détaillées.

Articles qui pourraient vous intéresser

Découvrez plus de contenu passionnant

Commentaire