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();
    // 每一个field应用对应的分词器
    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); // Not stored
    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);
	//... 其他的一些field
    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 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语言然后对对应的语言field进行搜索应该会更好一点,但是跑起来就没改了。


#[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)?;
            // Map from 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个非常详细的搜索example,而且每个都有很详细的解释。

您可能感兴趣的文章

发现更多精彩内容

评论