Tantivy 如何對不同語言應用不同的分詞器?

本站的搜尋是使用 tantivytantivy-jieba 構建的。Tantivy 是一個以 Rust 編寫的高效能全文搜尋引擎函式庫,靈感來自 Apache Lucene。它支援 BM25 排名、自然語言查詢、片語搜尋、分面檢索以及多種欄位類型(包含文字、數值、日期、IP 和 JSON),並提供多語言分詞支援(含中日韓)。它具有極快的索引與查詢速度、毫秒級啟動時間,以及記憶體映射(mmap)支援。

自從加入多語言翻譯後,搜尋內容常常夾雜許多其他語言。最近終於將不同語言的搜尋分開處理。主要解決方案是:當前語言的搜尋僅返回對應語言的文章結果,且不同語言使用不同的分詞器,例如中文使用 tantivy-jieba,日文使用 lindera,英文等則使用預設分詞器。如此一來,便解決了多語言混雜及分詞器不匹配所導致的搜尋效果不佳問題。

原本想用 qdrant 來進行語意搜尋,但 embedding 是在本地執行的,若再轉發到本地回傳結果會太慢,初始化時間也不確定要多久,成功率也難以保證。不過或許可以在微信公眾號上加上這個功能,這兩天看看能不能寫完。

手寫的,降低一點 AI 率,看得懂就好。最近準備把之前那些全部用 AI 寫的文章刪掉,看看什麼時候必應索引能恢復。

一、建構索引

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();
	// 索引的 schema 設定
    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);

    // 為不同語言設定 tokenizer
    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 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)
}

二、索引搜尋

這部分如果先 match 語言,再針對對應語言的欄位進行搜尋應該會更好,但因為跑起來了就沒改。

#[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("Search index not found in 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();
	// 搜尋篩選 Occur::Must 表示必須出現,需滿足 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,
        ],
    );
	// 使用者輸入的搜尋詞
    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()))
}

三、後記

Tantivy 的搜尋效果還是挺好的,雖然還不能做語意搜尋,但速度和效果都不錯。也有不少向量資料庫的全文搜尋是基於 Tantivy 索引實現的。

關於 Tantivy 更多、更詳細的使用方法可參考:tantivy 官方 example,裡面有 20 個非常詳盡的搜尋範例,每個都有詳細說明。

您可能感興趣的文章

發現更多精彩內容

評論