Tantivy 如何對不同語言應用不同的分詞器?
本站的搜尋是使用 tantivy
與 tantivy-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 個非常詳盡的搜尋範例,每個都有詳細說明。
評論