Wie wendet Tantivy unterschiedliche Tokenizer für verschiedene Sprachen an?

Die Suche auf dieser Seite wurde mit tantivy und tantivy-jieba realisiert. Tantivy ist eine Hochleistungs-Bibliothek für Volltextsuche, geschrieben in Rust, inspiriert von Apache Lucene. Sie unterstützt BM25-Bewertung, natürlichsprachliche Abfragen, Phrasensuche, Facettensuche sowie verschiedene Feldtypen (einschließlich Text, Zahlen, Datum, IP und JSON) und bietet mehrsprachige Tokenisierung (inklusive Chinesisch, Japanisch, Koreanisch). Sie zeichnet sich durch extrem schnelle Indexierungs- und Suchgeschwindigkeit, Millisekunden-schnellen Start und Unterstützung für Memory-Mapping (mmap) aus.

Seit der Hinzufügung der Mehrsprachübersetzung enthielt die Suchfunktion viele Inhalte in verschiedenen Sprachen. Inzwischen habe ich die Suchfunktionen endlich nach Sprachen getrennt. Das Hauptziel war, dass bei einer Suche in einer bestimmten Sprache nur Artikel dieser Sprache zurückgegeben werden und dass unterschiedliche Sprachen jeweils einen passenden Tokenizer verwenden – beispielsweise tantivy-jieba für Chinesisch, lindera für Japanisch und den Standard-Tokenisierer für Englisch usw. Auf diese Weise konnte das Problem der gemischten Sprachen und der ungeeigneten Tokenizer gelöst werden, was zuvor die Suchergebnisse stark beeinträchtigt hatte.

Ursprünglich wollte ich Qdrant für semantische Suche verwenden, aber da die Embeddings lokal berechnet werden, wäre die Weiterleitung an einen lokalen Dienst zu langsam gewesen – selbst die Initialisierungsdauer wäre ungewiss, ebenso wie die Erfolgsrate. Möglicherweise werde ich diese Funktion stattdessen in einem WeChat-Offiziellen-Konto integrieren; mal sehen, ob ich es in den nächsten Tagen schaffe, fertigzustellen.

Dieser Text wurde manuell verfasst, um die KI-Rate zu senken – Hauptsache, er ist verständlich. Ich plane, alle bisherigen Artikel, die mit KI generiert wurden, bald zu löschen, und warte ab, wann Bing seine Indizierung wiederherstellt.

1. Index erstellen

pub async fn build_search_index() -> anyhow::Result<Index> {
	// Für jede Sprache separat einen Tokenizer festlegen
    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();
	// Schema des Index definieren
    let mut schema_builder = Schema::builder();
    // Jedes Feld mit dem entsprechenden Tokenizer versehen
    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); // Nicht gespeichert
    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);
	//... Weitere Felder
    let schema = schema_builder.build();

    // Index im Arbeitsspeicher erstellen
    let index = Index::create_in_ram(schema);

    // Für verschiedene Sprachen jeweils einen Tokenizer registrieren
    let en_analyzer = TextAnalyzer::builder(SimpleTokenizer::default())
        .filter(LowerCaser)
        .filter(Stemmer::new(tantivy::tokenizer::Language::English))
        .build();
    index.tokenizers().register("en", en_analyzer);
    // Japanisch
    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);
	// Chinesisch
    let jieba_analyzer = TextAnalyzer::builder(JiebaTokenizer {})
        .filter(RemoveLongFilter::limit(40))
        .build();
    index.tokenizers().register("jieba", jieba_analyzer);
    // Index schreiben (die Zahl begrenzt den maximalen Speicherverbrauch)
    let mut index_writer = index.writer(50_000_000)?;

    let all_articles = Ihre Artikel.

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

        // Hier wird der Tokenizer angewandt; Vereinfachtes und traditionelles Chinesisch werden über das lang_field gefiltert.
        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. Suche im Index

Theoretisch wäre es besser, zunächst die Sprache zu matchen und dann gezielt in den entsprechenden sprachspezifischen Feldern zu suchen. Aber da die aktuelle Version bereits läuft, habe ich es nicht mehr geändert.


#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
    // Ich verwende Moka, um den Index im Speicher zu halten – bei dieser geringen Artikelanzahl kein Problem.
    let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
        ServerFnErrorErr::ServerError("Suchindex nicht im Cache gefunden.".to_string())
    })?;
	// Felder abrufen
    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();
	// Bei Occur::Must muss die Bedingung erfüllt sein – alle Anforderungen in queries: Vec müssen zutreffen
    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,
        ],
    );
	// Nutzersuche
    let user_query = query_parser.parse_query(&query.q)?;
    queries.push((Occur::Must, user_query));
	// Sprachfilter
    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));
    }
	// Weitere Filter
	...

    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)?;
            // Umwandlung von 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()
        }
	// Weitere Sortierungen... hier sortiere ich hauptsächlich nach Zeit
    }; // Keine Ahnung, ob die Klammern richtig ausgerichtet sind

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

3. Nachwort

Die Suchqualität von Tantivy ist insgesamt sehr gut. Obwohl keine semantische Suche möglich ist, überzeugen Geschwindigkeit und Ergebnisqualität. Viele Vektordatenbanken verwenden Tantivy-Indizes für ihre Volltextsuche.

Weitere und detailliertere Informationen zur Verwendung von Tantivy finden Sie unter: Tantivy-Offizielles Beispiel, wo 20 sehr ausführliche Suchbeispiele mit jeweils detaillierten Erklärungen bereitgestellt werden.

Artikel, die Sie interessieren könnten

Entdecken Sie weitere spannende Inhalte

Kommentar