Rustで学術論文からテキストを抽出する #17 応用編 - arXiv論文収集システム構築⑤ キーワード抽出

:::note
## Summary
- テキストから任意のキーワードを抽出するクレートを実装した
    - 確実性とメンテナンスの観点からホワイトリスト方式の原始的な抽出方法を採用した

### Crates & Repositories
| crate | GitHub |
|:-----:|:------:|
|[rsrpp](https://crates.io/crates/rsrpp)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[rsrpp-cli](https://crates.io/crates/rsrpp-cli)|[rsrpp](https://github.com/akitenkrad/rsrpp)
|[arxiv-tools](https://crates.io/crates/arxiv-tools)|[rs-arxiv-tools](https://github.com/akitenkrad/rs-arxiv-tools)|
|[ss-tools](https://crates.io/crates/ss-tools)|[rs-ss-tools](https://github.com/akitenkrad/rs-ss-tools)|
|[keyword-tools](https://crates.io/crates/keyword-tools)|[keywords](https://github.com/akitenkrad/keywords)|

:::

## 前回までのあらすじ

[前回：Rustで学術論文からテキストを抽出する #16](https://qiita.com/akitenkrad/items/7057383260f1c560f18d)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/7dad9c17-c8ff-67f5-7d33-738e224869c6.png)

arXivとSemanticScholarから論文のメタ情報を取得できるようになりました．
ただし，これらのAPIではキーワードを抽出することができません．
そこで今回はキーワード抽出を実装します．

## 最適なキーワード抽出の難しさ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933655/cb819d26-ef7c-0c97-fcf0-719c5a3dc3cf.png)

自然言語処理において，キーワードを抽出に関連する技術がいくつかあります．
単語の出現頻度によるTF-IDFやトピックモデルなども応用が可能です．
近年では，LLMを用いたキーワード抽出が主流かもしれません．

...が，色々試してみた結果，どれも今回のシステムには採用できないと結論しました．
最大の理由は，抽出するキーワードの微細なコントロールができないことです．
例えばLLMは確率モデルなので，モデルがある論文のテキストを受け取ったときにどんなキーワードを抽出してくるか予測が困難で，かつ再現性がありません．
キーワードに表記ゆれが多いことも課題です．LLMとLarge Language Modelは同じキーワードにまとめたいですが，モデルがこれらをどのように扱うのか予測ができませんし，結果が毎回異なることもあり得ます．
システムを長期的に運用するに当たっては，過去の論文との比較などを考慮して，できるだけ抽出の再現性を確保しておきたいです．
また，LLMや機械学習モデルではキーワードが増えるに従って定期的な再学習が必要になる点もボトルネックでした．

色々考えた結果，今回は一番原始的な方法で実装することにしました．
つまり，事前に欲しいキーワードのリストを作っておいて，そのリストに引っかかったキーワードを抽出してくるというものです．
当然，未知のキーワードには対応できませんが，それはLLMや他の機械学習モデルでも似たようなものかなと．
それよりは，キーワードリストによるホワイトリスト形式でキーワードを抽出することで，抽出の正確さがダントツになります．

ということで，論文を集めつつ，キーワードリストを育てながらシステムに組み込むことにしました．

## 実装方針

今回は，対象の言語が英語なので，キーワードの抽出処理自体は正規表現でなんとかなりそうです．
欲しい要件としては以下のようなものです．

- キーワードの表記ゆれに対応する
- 処理に再現性がある
- キーワードの出現頻度をカウントできる
- キーワードの重要度のようなものを考慮したい
- キーワードリストの編集が簡単

表記揺れと重要度については，キーワードリストの形式で対応することにします．
今回は以下のような形式のJSONファイルをキーワードリストとして保持し，新しいキーワードが出てきたら随時編集することにします．

```json
[
    {
        "category": "NaturalLanguageProcessing",
        "language": "English",
        "word": "LLM",
        "alias": "Large Language Model",
        "score": 10
    },
    ...
]
```

`word`がキーワードになります．
`alias`でキーワードの表記ゆれに対応します．LLMの場合，他に`Lage Language Model`も同じ`alias`を持つので，出現したキーワードを集計するときに`alias`を用いることによって表記ゆれを吸収できます．
`score`はキーワードの重要度を設定することにしており，これは自分にとってどれくらい重要かという基準に従って勘で設定します．

`category`はあると便利かなと思って入れておきました．

また，今回は使いませんが，日本語の文字列に対してもキーワード抽出できるように`language`を追加しています．
日本語の場合は，テキストの分ち書きが必要になるので，形態素解析ライブラリを追加することになります．

## keyword-tools

ホワイトリスト形式なので，キーワードのリストをそのまま構造体で保持するような形で実装します．
今回は正規表現で対応することにしたので，キーワードを格納したオブジェクトから，該当するキーワードをマッチングするための正規表現を取得できるようにしました．

正規表現が見づらいのはお許しを...
キーワードが出現するパターンを盛り込んでいったらこのようになってしまいました．
例えば，
- 前後の単語に挟まれて出現: `r"\sWORD\s"` (← 一番シンプルなパターン)
- 文末に出現: `r"\sWORD\."` (← スペースではなくドットで終了)
- ハイフネーションになっている: `r"\-WORD"` (← 新しい領域だと造語が湯水の如く出てくるので結構見かけます)

などなど．
これらを考慮した結果が，下記の正規表現になっています．
(複数形までは良くても，`ing`や`ed`などはやり過ぎだったかもしれませんが)


```rust
#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)]
pub struct Keyword {
    pub word: String,
    pub alias: String,
    pub score: isize,
    pub language: Language,
    pub category: Category,
}

impl Keyword {
    pub fn get_keyword_ptn(&self) -> String {
        let kwd = self
            .word
            .to_lowercase()
            .replace("-", r"(\-|\s)*")
            .replace(" ", r"(\s|\-)*");
        let ptn = format!(
            r#"(^|\s|\(|'|"|\-)+(?i){}(s|ing|al|d|ed|\-[^\s]+)*($|\s|\)|\(|\.|,|:|;|\)|'|")+"#,
            kwd
        );
        return ptn;
    }
}
```

なお，`Category`と`Language`は`enum`で実装しています．

```rust
#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)]
pub enum Language {
    English,
    Japanese,
}

#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)]
pub enum Category {
    MachineLearning,
    NaturalLanguageProcessing,
    Security,
    Organization,
    ComputerVision,
    Item,
    Topic,
    Task,
    Other,
}
```

あとは，テキストとキーワードのリストを受け取って，キーワード抽出する関数を実装するだけです．

```rust
pub fn extract_keywords(text: &str, keywords: Vec<Keyword>, lang: Language) -> Vec<Keyword> {
    let mut extracted_keywords: Vec<Keyword> = Vec::new();

    keywords.iter().for_each(|keyword| {
        let re_str = keyword.get_keyword_ptn();
        let re = Regex::new(&re_str).unwrap();
        if re.is_match(text) {
            extracted_keywords.push(keyword.clone());
        }
    });

    return extracted_keywords;
}
```

プログラムには日本語の部分もあるのですが，今回は英語部分しか使わないので抜粋しています．正規表現に当たったら，`extracted_keywords: Vec<Keyword>`にキーワードを格納しているだけです．

今回の実装で最も大変なところは，キーワードのリストをどのように充実させていくかということです．
これはもう，論文を収集しながら，気になる単語を片端から登録していくしかないかなと思っています．
ちなみにリポジトリには現時点ですでに1300程度のキーワードを登録しています．
論文を読むほど増えます．

このキーワードはユーザに完全に過学習したリストになっているので，このクレートを利用される方むけには，独自のキーワードリストを使用できるように実装しています．

詳しくは[こちら](https://crates.io/crates/keyword-tools)まで．

## 次回

さて，次回は楽しいたのしい生成AIパートを実装します．
まずは，OpenAIのAPIを簡単に使えるようにラッパーライブラリを実装し，その後欲しい情報を抽出できるようにプロンプトエンジニアリングを行います．

[次回：Rustで学術論文からテキストを抽出する #18](https://qiita.com/akitenkrad/items/7ad6c55f7392ca80660e)