前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >为了一碟醋,我包了两顿饺子

为了一碟醋,我包了两顿饺子

作者头像
tyrchen
发布2021-11-10 17:18:13
1.5K1
发布2021-11-10 17:18:13
举报
文章被收录于专栏:程序人生程序人生

上周五赶上公司的 mental health day,连着周末休了三天。

于是我有了三天时间赶我的极客时间「Rust 第一课」专栏的稿子。我想着三天怎么也能交出两篇稿子,结果就周五忙活一天,熬出一篇。

难道周六周日我去打酱油了么?还是穿着非主流跟小宝小贝混糖吃去了?非也非也,周六一大早,我就坐在电脑旁,准备新的稿子:「实操项目:使用 pyo3/neon 开发 Python3/nodejs 模块」。

本来这个题目写起来毫无压力,不过因为专栏一开始我临时加了个 get hands dirty 系列,自己一上头就已经实操了 pyo3 和 neon,所以这篇写起来会毫无新意。

于是我就想:有什么东西足够复杂,足够有用,又是 python 社区或者 nodejs 社区缺乏的?

思来想去,我选定了内嵌式的搜索引擎。目前开源和非开源世界里,搜索引擎服务器已经遍地开花,有开源界口碑良好的 elasticsearch,有创业圈行业标杆的 agolia,当然,Rust 自己也有 meilisearch,sonic 和 quickwit。这些搜索引擎服务器可以通过任何语言撰写的客户端访问。不过,如果你就想在自己的系统里嵌入一个无服务器的,本地运行,本地索引的,却又能处理海量数据的搜索引擎,python 和 nodejs 还真没有什么太好的选择,尤其是 python,至少人家 nodejs 还有 flexsearch 拿得出手,恕我孤陋寡闻,python 就真没什么好选择。基于 lucene 的 pylucene 没毛病,但运行个 python 的搜索引擎还要在 python 里起个 java VM,总让人如鲠在喉。这就是我为什么选择用内嵌搜索引擎为例,谈如何让 Rust 为 python 和 nodejs 提供支持。

Rust 下,我们有 tantivy 这个表现相当不错的内嵌式搜索引擎,它也是 quickwit 的底层库(quickwit 是 tantivy 的维护者)。你可以看 https://tantivy-search.github.io/bench/,这里对比了 tantivy,lucene,pisa,bleve 和 rucene 这几个库的性能。基本上 tantivy 和 C++ 撰写的 pisa 性能旗鼓相当,但功能更全面一些。

不过,tantivy 有自己的 tantivy-py,我再做一个类似的意义不大。我翻了翻 tantivy-py 的代码,发现它基本上就是 Rust 库的封装,而 tantivy 自身因为是定位底层实现,所以 API 并不那么友好。所以,tantivy-py 难说是给搜索引擎小白提供的傻瓜版本。所以,我可以做一个定位不太一样的 python 搜索引擎库。我希望的是,它的 API 是这样使用的感觉:

代码语言:javascript
复制
In [1]: from xunmi import *
# 从配置里直接加载(或者创建)索引
In [2]: indexer = Indexer("./fixtures/config.yml")
# 获取修改索引的句柄
In [3]: updater = indexer.get_updater()

In [4]: f = open("./fixtures/wiki_00.xml")
# 获取要索引的数据
In [5]: data = f.read()

In [6]: f.close()
# 提供一些简单的规则格式化数据(比如数据类型,字段重命名,类型转换)
# 支持 xml / json / yml 等数据,数据需要与索引匹配,否则需要用
# mapping 和 conversion 规则转换
In [7]: input_config = InputConfig("xml", [("$value", "content")], [("id", ("string", "number"))])
# 更新索引
In [8]: updater.update(data, input_config)
# 搜索更新后的索引,使用 "title", "content" 字段搜索,找 offset 0 处的 5 个结果返回
In [9]: result = indexer.search("历史", ["title", "content"], 5, 0)
# 返回结果包含 score 和索引中存储的数据(这里 content 只索引,没有存储)
In [10]: result
Out[10]: 
[(13.932347297668457,
  '{"id":[22],"title":["历史"],"url":["https://zh.wikipedia.org/wiki?curid=22"]}'),
 (11.62932014465332,
  '{"id":[399],"title":["非洲历史"],"url":["https://zh.wikipedia.org/wiki?curid=399"]}'),
 (11.526201248168945,
  '{"id":[2239],"title":["美国历史"],"url":["https://zh.wikipedia.org/wiki?curid=2239"]}'),
 (11.521516799926758,
  '{"id":[374],"title":["亚洲历史"],"url":["https://zh.wikipedia.org/wiki?curid=374"]}'),
 (11.342496871948242,
  '{"id":[182],"title":["中国历史"],"url":["https://zh.wikipedia.org/wiki?curid=182"]}')]

其中,索引的配置文件长这个样子:

代码语言:javascript
复制
---
path: /tmp/searcher_index # 索引路径
schema: # 索引的 schema,对于文本,使用 CANG_JIE 做中文分词
  - name: id
    type: u64
    options:
      indexed: true
      fast: single
      stored: true
  - name: url
    type: text
    options:
      indexing: ~
      stored: true
  - name: title
    type: text
    options:
      indexing:
        record: position
        tokenizer: CANG_JIE
      stored: true
  - name: content
    type: text
    options:
      indexing:
        record: position
        tokenizer: CANG_JIE
      stored: false
text_lang:
  chinese: true # 如果是 true,自动做繁体到简体的转换
writer_memory: 100000000

所以,在写文章之前,我需要先写一个使用 pyo3 把 Rust 代码封装成 Python FFI,供 Python 使用。有了这个代码,才好写文章。而写这个代码之前,我需要先写一个 Rust 库把 tantivy 封装一下,提供友好的 API。

于是有了第一顿饺子:xunmi(寻觅)。

我写了一个简单的 tanvity 封装,放在 github 的 tyrchen/xunmi 下,使用方法:

代码语言:javascript
复制
use std::str::FromStr;
use xunmi::*;

fn main() {
    // you can load a human-readable configuration (e.g. yaml)
    let config = IndexConfig::from_str(include_str!("../fixtures/config.yml")).unwrap();

    // then open or create the index based on the configuration
    let indexer = Indexer::open_or_create(config).unwrap();

    // then you can get the updater for adding / updating index
    let mut updater = indexer.get_updater().unwrap();

    // data to index could comes from json / yaml / xml, as long as they're compatible with schema
    let content = include_str!("../fixtures/wiki_00.xml");

    // you could even provide mapping for renaming fields and converting data types
    // e.g. index schema has id as u64, content as text, but xml has id as string
    // and it doesn't have a content field, instead the $value of the doc is the content.
    // so we can use mapping / conversion to normalize these.
    let config = InputConfig::new(
        InputType::Xml,
        vec![("$value".into(), "content".into())],
        vec![("id".into(), (ValueType::String, ValueType::Number))],
    );

    // you could use add() or update() to add data into the search index
    // if you add, it will insert new docs; if you update, and if the doc
    // contains an "id" field, updater will first delete the term matching
    // id (so id shall be unique), then insert new docs.
    // all data added/deleted will be committed.
    updater.update(content, &config).unwrap();

    // by default the indexer will be auto reloaded upon every commit,
    // but that has delays in tens of milliseconds, so for this example,
    // we shall reload immediately.
    indexer.reload().unwrap();

    println!("total: {}", indexer.num_docs());

    // you could provide a query and fields you want to search
    let result = indexer.search("历史", &["title", "content"], 5, 0).unwrap();
    for (score, doc) in result.iter() {
        println!("score: {}, doc: {:?}", score, doc);
    }
}

可以看到,它几乎和 Python 的示例代码一致。

在写 xunmi 的过程中,我发现,中文的繁体到简体的转换工具,不太理想。我先找到一个下载量还不错,又把自己标记为 1.0 的 character_converter 库,发现转换一篇维基百科的文章,慢得肉眼可见。后来又发现了貌似很牛逼的,用 C++ 写的 opencc,以及它的封装 opencc-rust,可惜 opencc-rust 做的不好,编译时需要系统先安装好 opencc 才能用,我在 github action 里跑的时候,即便 "apt install opencc" 还是会编译错误,故而我萌生了自己写一个的念头。我想,不就是繁体字到简体字的一个映射么?也就是一两百行代码的事情:我编译期生成一个映射表,运行时把字符串一个字符一个字符转换不就行了么?

于是我又开始折腾第二顿饺子:fast2s(tyrchen/fast2s)。

在做的过程中,我突然想到一直觉得很牛逼但不知道用在哪里的 fst 库。fst 是一个用有限自动机做有序 set / map 的查询的库,效率比 HashMap 差一些,但非常非常节省内存。当然,繁体字到简体字的转换也就两千个汉字,内存节省收益不大,但我就是觉得找到了 fst 的一个应用场景,技痒想试试。做 fast2s 需要繁体字到简体字的转换表,在找转换表时,我又发现了 simplet2s-rs,于是就把它的转换表拿来用。很快写出来的第一版和几个已有的库比较:

代码语言:javascript
复制
| tests | fast2s | simplet2s-rs | opencc-rust | character_conver |
| ----- | ------ | ------------ | ----------- | ---------------- |
| zht   | 446us  | 616us        | 5.08ms      | 1.23s            |
| zhc   | 491us  | 798us        | 6.08ms      | 2.87s            |
| en    | 68us   | 2.82ms       | 12.24ms     | 26.11s           |

Test result (mutate existing string):

| tests | fast2s | simplet2s-rs | opencc-rust | character_conver |
| ----- | ------ | ------------ | ----------- | ---------------- |
| zht   | 438us  | N/A          | N/A         | N/A              |
| zhc   | 503us  | N/A          | N/A         | N/A              |
| en    | 34us   | N/A          | N/A         | N/A              |

发现优胜的是 fast2s 和 simplet2s-rs。

按理说用 fst 做出来的 fast2s 要比用 HashMap 的 simplet2s 慢,可是结果让我吃了一惊。看了一下 simplet2s-ts 的代码才发现,我还有一些特殊情况没有处理。于是我把 simplet2s 对应的特殊情况的处理表改动了一下,用字符数组取代字符串,这样可以避免在访问哈希表时额外的指针跳转(如果你看我 Rust 专栏哈希表那一讲,可以明白这两者的区别):

代码语言:javascript
复制
// fast2s 的代码,key 和 value 都使用了字符/字符数组
// thanks https://github.com/bosondata/simplet2s-rs/blob/master/src/lib.rs#L8 for this special logic
// Traditional Chinese -> Not convert case
static ref T2S_EXCLUDE: HashMap<char, HashSet<Word>> = {
    hashmap!{
        '兒' => hashset!{['兒','寬']},
        '覆' => hashset!{['答', '覆'], ['批','覆'], ['回','覆']},
        '夥' => hashset!{['甚','夥']},
        '藉' => hashset!{['慰','藉'], ['狼','藉']},
        '瞭' => hashset!{['瞭','望']},
        '麽' => hashset!{['幺','麽']},
        '幺' => hashset!{['幺','麽']},
        '於' => hashset!{['樊','於']}
    }
};

// simplet2s 的代码,key 和 value 都使用了字符串
// Traditional Chinese -> Not convert case
static ref T2S_EXCLUDE: HashMap<&'static str, HashSet<&'static str>> = {
    hashmap!{
        "兒" => hashset!{"兒寬"},
        "覆" => hashset!{"答覆", "批覆", "回覆"},
        "夥" => hashset!{"甚夥"},
        "藉" => hashset!{"慰藉", "狼藉"},
        "瞭" => hashset!{"瞭望"},
        "麽" => hashset!{"幺麽"},
        "幺" => hashset!{"幺麽"},
        "於" => hashset!{"樊於"}
    }
};

处理好特殊情况后,fast2s 和 simplet2s-rs 的结果差不多,但因为我的 fast2s 用了一些特殊的优化,所以在使用 fst 的情况下,依旧性能和 simplet2s 旗鼓相当:

代码语言:javascript
复制
| tests | fast2s | simplet2s-rs | opencc-rust | character_conver |
| ----- | ------ | ------------ | ----------- | ---------------- |
| zht   | 596us  | 579us        | 4.93ms      | 1.23s            |
| zhc   | 643us  | 750us        | 5.89ms      | 2.87s            |
| en    | 59us   | 2.68ms       | 11.46ms     | 26.11s           |

Test result (mutate existing string):

| tests | fast2s | simplet2s-rs | opencc-rust | character_conver |
| ----- | ------ | ------------ | ----------- | ---------------- |
| zht   | 524us  | N/A          | N/A         | N/A              |
| zhc   | 609us  | N/A          | N/A         | N/A              |
| en    | 48us   | N/A          | N/A         | N/A              |

在 fast2s 里,我不光提供了直接的转换,还提供了对已有字符串的修改,而不是生成新的字符串的功能。这个能力对大容量的字符串或者文件(文件可以 mmap)的繁简转换很有意义,因为它能节省内存的分配和消耗。

第二顿饺子 fast2s 包好,基本上周六的时间就悉数花去。

然后周日我又掉转头继续包第一顿饺子 xunmi。待 xunmi 折腾好,我处理完要撰写的文章所需要的 xunmi-py,已经是周日深夜 12 点。我为了 xunmi-py 的 96 行代码,付出了近 700 行(300 + 377)代码的代价:

代码语言:javascript
复制
fast2s
❯ tokei .
-------------------------------------------------------------------------------
 Language            Files        Lines         Code     Comments       Blanks
-------------------------------------------------------------------------------
 Markdown                3           56           56            0            0
 Rust                    5          365          300           13           52
 Plain Text              8         6428         6428            0            0
 TOML                    3          259           99          142           18
-------------------------------------------------------------------------------
 Total                  19         7108         6883          155           70
-------------------------------------------------------------------------------

xunmi
❯ tokei .
-------------------------------------------------------------------------------
 Language            Files        Lines         Code     Comments       Blanks
-------------------------------------------------------------------------------
 Markdown                2           62           62            0            0
 Rust                    5          458          377           19           62
 TOML                    2          239           84          142           13
 XML                     1        63054        59462            0         3592
 YAML                    3          348          344            0            4
-------------------------------------------------------------------------------
 Total                  13        64161        60329          161         3671
-------------------------------------------------------------------------------

geek-time-rust-resources/31/xunmi-py
❯ tokei .
-------------------------------------------------------------------------------
 Language            Files        Lines         Code     Comments       Blanks
-------------------------------------------------------------------------------
 Makefile                1           24           18            0            6
 Python                  1            1            1            0            0
 Rust                    2          112           96            0           16
 TOML                    1           19           14            0            5
 XML                     1        63054        59462            0         3592
 YAML                    2          321          318            0            3
-------------------------------------------------------------------------------
 Total                   9        63531        59909            0         3622
-------------------------------------------------------------------------------

虽然包饺子花了比我预期要长得多的时间,但在这个过程中,我学到了一些奇妙的东西。

比如我一直苦恼如何把多个数据源(json / yaml / xml / ...)的数据,在不用定义 Rust struct 的情况下(如果可以定义 struct,那么就可以直接用 serde 转换),整合成一套方案。为此我甚至一开始走错了方向,试图自动检测文本类型,然后将它们统一转换成 JSON(这个检测和转换的代码还是有些挑战的)。

后来发现,使用 serde,我可以把 serde_xml_rs 提供的转换能力,让 xml 文本转换成一个 serde_json 下的 Value 结构。就好比把猪大肠安在牛肚子里,竟然不排异:

代码语言:javascript
复制
let data: serde_json::Value = serde_xml_rs::from_str(&input);

神奇吧?

于是多个数据源统一处理就可以简化成下面这样子,简单到让人不敢相信自己的眼睛:

代码语言:javascript
复制
pub type JsonObject = serde_json::Map<String, JsonValue>;
pub struct JsonObjects(Vec<JsonObject>);

impl JsonObjects {
    pub fn new(input: &str, config: &InputConfig, t2s: bool) -> Result<Self> {
        let input = match t2s {
            true => Cow::Owned(fast2s::convert(input)),
            false => Cow::Borrowed(input),
        };
        let err_fn =
            || DocParsingError::NotJson(format!("Failed to parse: {:?}...", &input[0..20]));
        let result: std::result::Result<Vec<JsonObject>, _> = match config.input_type {
            InputType::Json => serde_json::from_str(&input).map_err(|_| err_fn()),
            InputType::Yaml => serde_yaml::from_str(&input).map_err(|_| err_fn()),
            InputType::Xml => serde_xml_rs::from_str(&input).map_err(|_| err_fn()),
        };

        let data = match result {
            Ok(v) => v,
            Err(_) => {
                let obj: JsonObject = match config.input_type {
                    InputType::Json => serde_json::from_str(&input).map_err(|_| err_fn())?,
                    InputType::Yaml => serde_yaml::from_str(&input).map_err(|_| err_fn())?,
                    InputType::Xml => serde_xml_rs::from_str(&input).map_err(|_| err_fn())?,
                };
                vec![obj]
            }
        };

        Ok(Self(data))
    }
}

好了,饺子的事,我们就先说到这儿。醋,下周就能尝到 :)

禅定时刻

你问我为啥都 9102 年又两年了,还要支持似乎已经过时的 xml?hmm...因为很多数据源都还是 xml,比如 wikipedia 的 dump。

对于 xunmi 来说,目前的处理方式还不够好,在往索引里添加文档时,应该用 channel 把处理流程分成几个阶段,这样,索引的添加就不会影响到查询,python 使用者整体的体验会更好:

有空我继续把这顿饺子继续整得薄皮大馅。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-11-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序人生 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 禅定时刻
相关产品与服务
Elasticsearch Service
腾讯云 Elasticsearch Service(ES)是云端全托管海量数据检索分析服务,拥有高性能自研内核,集成X-Pack。ES 支持通过自治索引、存算分离、集群巡检等特性轻松管理集群,也支持免运维、自动弹性、按需使用的 Serverless 模式。使用 ES 您可以高效构建信息检索、日志分析、运维监控等服务,它独特的向量检索还可助您构建基于语义、图像的AI深度应用。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档