Tantivyはどのように異なる言語にそれぞれ異なるトークナイザーを適用するのか?
当サイトの検索機能はtantivy
とtantivy-jieba
を使用して構築されています。TantivyはRustで書かれた高性能な全文検索エンジンライブラリであり、Apache Luceneから着想を得ています。BM25スコアリング、自然言語クエリ、フレーズ検索、ファセット検索、テキスト・数値・日付・IP・JSONなど複数のフィールドタイプをサポートしており、中国語・日本語・韓国語を含む多言語のトークナイズも可能です。非常に高速なインデックス作成と検索速度、ミリ秒単位での起動時間、メモリマップ(mmap)のサポートを備えています。
多言語翻訳を導入して以降、検索対象のコンテンツには多くの異なる言語が混在するようになりました。最近ようやく、各言語ごとの検索を分離することができました。主な解決策として、現在の言語での検索ではその言語に対応する記事のみを返すようにし、それぞれの言語に適したトークナイザーを使用しています。たとえば、中国語にはtantivy-jieba
、日本語にはlindera
、英語など他の言語にはデフォルトのトークナイザーを使っています。これにより、多言語が混在していることや、トークナイザーの不一致による検索精度の低下という問題を解決しました。
当初はqdrantを使って意味検索を行うつもりでしたが、embedding処理をローカルで行っているため、再度ローカルに結果を返すのは遅すぎます。初期化にどれくらい時間がかかるかも不明で、成功率も保証できません。ただし、これは微信公众号(WeChat公式アカウント)に実装するのはありかもしれません。この数日中に完成できるか見てみます。
手書きで書いているので、AI使用率を下げています。読める程度であれば十分です。最近、以前AIで書いたすべての記事を削除する予定で、その後Bingのインデックスがいつ回復するか様子を見ようと思っています。
一、インデックスの構築
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();
// インデックスのスキーマ設定
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);
// 異なる言語ごとにトークナイザーを設定
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 tokenizer = LinderaTokenizer::from_segmenter(segmenter);
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個の非常に詳しい検索例があり、それぞれ丁寧な解説がついています。
コメント