Cómo aplica Tantivy diferentes tokenizadores para distintos idiomas
La búsqueda de este sitio utiliza tantivy
y tantivy-jieba
. Tantivy es una biblioteca de motor de búsqueda de texto completo de alto rendimiento escrita en Rust, inspirada en Apache Lucene. Soporta puntuación BM25, consultas en lenguaje natural, búsquedas por frases, recuperación facetada y múltiples tipos de campos (incluyendo texto, numéricos, fecha, IP y JSON), además de ofrecer soporte multilingüe para el análisis léxico (incluyendo chino, japonés y coreano). Cuenta con velocidades extremadamente rápidas de indexación y consulta, tiempos de arranque en milisegundos y soporte para mapeo de memoria (mmap).
Desde que agregué traducción multilingüe, el contenido buscado incluye muchos textos en otros idiomas. Recientemente logré finalmente separar las búsquedas por idioma. La solución principal consiste en que la búsqueda en un idioma determinado solo devuelva resultados de artículos en ese mismo idioma, utilizando diferentes tokenizadores según el idioma: por ejemplo, se usa tantivy-jieba
para chino, lindera
para japonés y el tokenizador predeterminado para inglés u otros idiomas. Así se resuelve el problema del mal rendimiento de búsqueda causado por la mezcla de varios idiomas y la falta de coincidencia entre los textos y sus tokenizadores.
Originalmente pensaba usar qdrant para búsquedas semánticas, pero como los embeddings se realizan localmente, reenviarlos y obtener resultados locales sería demasiado lento, sin mencionar el tiempo de inicialización ni la incertidumbre sobre su tasa de éxito. Aunque probablemente pueda implementarlo más adelante en una cuenta oficial de WeChat; veré si puedo terminarlo estos días.
Escrito manualmente, para reducir la tasa de contenido generado por IA; con que se entienda, está bien. Recientemente planeo eliminar todos los artículos previamente escritos con ayuda de IA y ver cuándo Bing recupera su indexación.
1. Construcción del índice
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 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 = tus artículos.
for article in all_articles {
let mut doc = TantivyDocument::new();
doc.add_text(lang_field, &article.lang);
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. Búsqueda en el índice
Sería mejor hacer coincidir primero el idioma y luego buscar en los campos correspondientes, pero como ya estaba funcionando, no lo cambié.
#[server]
pub async fn search_handler(query: SearchQuery) -> Result<String, ServerFnError> {
let index = SEARCH_INDEX_CACHE.get("primary_index").ok_or_else(|| {
ServerFnErrorErr::ServerError("Índice de búsqueda no encontrado en caché.".to_string())
})?;
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();
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)?;
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()))
}
3. Epílogo
El rendimiento de búsqueda de tantivy es bastante bueno. Aunque aún no soporta búsqueda semántica, tanto la velocidad como los resultados son excelentes. Muchas bases de datos vectoriales utilizan índices de tantivy para sus funciones de búsqueda de texto completo.
Para obtener más información detallada sobre el uso de tantivy, véase: ejemplo oficial de tantivy, que contiene 20 ejemplos muy detallados de búsqueda, cada uno con explicaciones completas.
Comentario