Qdrantで日本語のキーワード検索（BM25）を実装する

この記事は [NTTコムウェア Advent Calendar 2024](https://qiita.com/advent-calendar/2024/nttcomware) 19日目 の記事です。

こんにちは、NTTコムウェアの 佐々木 哲平 です。
普段は LLM に関するプロダクト開発に携わっており、最近は RAG の精度向上や機能改善に取り組んでいます。

本記事では、これまでは実現が難しかった「Qdrant に BM25 を用いた日本語のキーワード検索」を導入する方法をご紹介します。

# 本記事の要旨
本記事で扱うのは「Qdrant で日本語文章の BM25 検索を実装する方法」です。

- Qdrant は [Qdrant/bm25](https://huggingface.co/Qdrant/bm25) + [qdrant/fastembed](https://github.com/qdrant/fastembed/) による BM25 検索機能を提供していたが、これまでは日本語に対応していなかった
- ただ、少し前に対応が入り、開発者側でトークン化すれば日本語にも対応できるようになった（[feat: Added a toggle to disable stemmer in bm25 #416](https://github.com/qdrant/fastembed/pull/416)）
- 実現方法は以下の通り
    - FastEmbed の stemmer 機能を無効にする
    - デフォルトの FastEmbed では Tokenizer が日本語に対応していないため、事前処理部分を開発者側で実装する
    - 後は Sparse Vector 同士で近傍探索するだけ
- 正式リリース版 ([v0.5.0](https://github.com/qdrant/fastembed/releases/tag/v0.5.0)) で対応されました！
  ~~未リリースの機能なので、直近で利用する場合は直接 whl からインストールする必要がある~~

# Qdrant におけるキーワード検索
[Qdrant](https://qdrant.tech/) は Rust 製のベクトルデータベースで、高負荷時でも高速・高信頼性で動作することを売りにしています。また、Docker / Kubernetes 上で動くため、環境を問わず動作するのも強みのひとつです。

一方で、[公式 QA](https://qdrant.tech/documentation/faq/qdrant-fundamentals/#does-qdrant-support-a-full-text-search-or-a-hybrid-search) にもある通り、主にベクトル検索を中心とした機能を提供することをメインミッションとしており、[Weaviate](https://weaviate.io/developers/weaviate/search/bm25#basic-bm25-search) や [Azure AI Search](https://learn.microsoft.com/ja-jp/azure/search/index-ranking-similarity) のような built-in の BM25 検索機能を提供していません。

このため、Qdrant で BM25 検索を実現するには、外部ツールを使うなどの工夫が必要です。

## キーワード検索の実現方法
Qdrant におけるキーワード検索の実装方式には、以下の４通りがあります。
1. [Match Value](https://qdrant.tech/documentation/concepts/filtering/#match) による文字列フィルタ
2. [Full-text index](https://qdrant.tech/documentation/concepts/indexing/#) による全文検索
3. [SPLADE モデルによる埋め込み](https://qdrant.tech/documentation/fastembed/fastembed-splade/) を使った類似度検索
4. [Qdrant/bm25](https://huggingface.co/Qdrant/bm25) + [qdrant/fastembed](https://github.com/qdrant/fastembed/) による埋め込みを使った類似度検索（★）

<br />

**1. [Match Value](https://qdrant.tech/documentation/concepts/filtering/#match) による文字列フィルタ**
Dense Vector で類似探索した後に、Payload の値と **完全一致** するデータを抽出する機能です。

公式ドキュメントでは、色など値が Enum 型であるようなケースで用いられることを想定しているようです。

キーワード検索が主目的というより、Dense Vector で検索する際の補助機能として用いるほうがイメージしやすいかと思います。

<br />

**2. [Full-text index](https://qdrant.tech/documentation/concepts/indexing/#) による全文検索**
テキストデータを事前にトークン化することにより、Payload の値で **部分一致** するデータを抽出できる機能です。[Scroll points](https://api.qdrant.tech/v-1-12-x/api-reference/points/scroll-points) と [Full Text Match](https://qdrant.tech/documentation/concepts/filtering/#full-text-match) を組み合わせて実装します。

こちらはベクトルを用意せずとも、キーワードだけで検索できますが、
- 日本語文章に対応するには、Qdrant をバイナリからビルドしなければいけない
- クエリに含まれているトークンを全て含んでいるデータのみがヒットする
- 全てのドキュメントに対して検索するには、全てのデータを Scroll する必要がある
- スコアは返却されない

ことに注意する必要があります。

<br />

**3. [SPLADE モデルによる埋め込み](https://qdrant.tech/documentation/fastembed/fastembed-splade/) を使った類似度検索**
SPLADE（Sparse Lexical and Expansion）モデルを使って Sparse Vector を生成することで、**類似探索を使ったキーワード検索** を実現します。

これまでの手法とは異なり、同義語的な語句を拾えたり、自然言語での検索ができたりと、一般的な検索エンジンのような使い方ができます。また、ベクトル間の類似度を測るため、スコアを算出することができるのも特徴です。

ただし、あくまで SPLADE モデルは自然言語処理モデルの一種であり、インデックス作成時や、クエリ文章の埋め込み時に、一定の計算コストが掛かってしまいます。
また、コンテキスト長という制約があり、Dense Embedding を行うモデルと扱える最大チャンク長で不整合を起こさないように注意する必要があります。

<br />

**4. [Qdrant/bm25](https://huggingface.co/Qdrant/bm25) + [qdrant/fastembed](https://github.com/qdrant/fastembed/) による埋め込みを使った類似度検索（★）**
qdrant/fastembed を使って Sparse Vector を生成することで、**BM25 を使ったキーワード検索** を実現します。

SPLADE モデルと同様に自然言語の検索ができ、スコアの算出もできます。
一方、SPLADE モデルとは対象的に、計算コストが低く、コンテキスト長のような入力テキストの長さに制限がないのも特徴です。

qdrant/fastembed は従来 CJK 言語（Chinese, Japanese, Korean）に対応していませんでした。が、"[feat: Added a toggle to disable stemmer in bm25 #416](https://github.com/qdrant/fastembed/pull/416)" の Pull Request により、開発者側で文章をトークン化することで、CJK 言語でも BM25 を導入できるようになりました。
<br />
<br />

今回はこの「**4. [Qdrant/bm25](https://huggingface.co/Qdrant/bm25) + [qdrant/fastembed](https://github.com/qdrant/fastembed/) による埋め込みを使った類似度検索（★）**」を中心に実装を進めていきます。

# Qdrant に BM25 検索を実装する
いよいよ実装に入っていきます。

今回は以下の開発環境で実施しました。

| ツール名 | バージョン |
|:-----------|------------:|
| Python       |  3.10.12 |
| Docker       |  27.3.1 |
| Ubuntu | 22.04.3 LTS |

## 事前準備
事前準備として、[FastEmbed](https://github.com/qdrant/fastembed) は v0.5.0 以降のバージョンをインストールする必要があります。また、[Qdrant](https://qdrant.tech/documentation/) を Docker で起動する必要があるため、[Docker](https://www.docker.com/ja-jp/) を事前にインストールしておきましょう。

### Python 依存パッケージのインストール
プログラムの前提条件となるパッケージをインストールします。

1. プロジェクトフォルダ内に移動します
   ```bash
   $ cd qdrant_bm25
   ```
1. `requirements.txt` を作成します
   ```text
   mecab-python3==1.0.10
   unidic-lite==1.0.8
   neologdn==0.5.3
   stopwordsiso==0.6.1
   datasets==3.2.0
   langchain_text_splitters==0.3.3
   qdrant_client==1.12.1
   fastembed==0.5.0
   ```
1. パッケージをインストールします
   ```bash
   $ pip install -r requirements.txt
   ```
1. 準備完了


### Qdrant の立ち上げ
Docker は既にセットアップ済みの前提で、ここでは Qdrant の立ち上げ方法を簡単に説明します。

1. プロジェクトフォルダ内に移動します
   ```bash
   $ cd qdrant_bm25
   ```
1. `docker-compose.yaml` を作成します
   ```yaml
   services:
     qdrant:
       image: qdrant/qdrant:v1.12.5
       container_name: qdrant
       ports:
         - "6333:6333"
       volumes:
         - qdrant_storage:/qdrant/storage

   volumes:
     qdrant_storage:
   ```
1. Qdrant を起動します
   ```bash
   $ sudo docker compose up -d
   ```
1. 準備完了

## 実装方法
かなり内容を省略していますが、抜粋版のソースコードを以下に示します。全体のソースコードは [teppeisasaki/qdrant-bm25](https://github.com/teppeisasaki/qdrant-bm25) をご覧ください。

重要な部分は、以下の２箇所となります。
- `SparseTextEmbedding(..., disable_stemmer=True)` としている部分
- MeCab を使った形態素解析を行っている部分（`_tokenize`, `embed_documents`, `embed_query`）

```python
class TextEmbedder:
    """テキストチャンクのスパース埋め込みを生成するクラス。

    Attributes:
        None
    """

    def __init__(self):
        """TextEmbedderを初期化します。"""
        self._bm25_model = SparseTextEmbedding(
            model_name="Qdrant/bm25", disable_stemmer=True
        )
        self._mecab_tagger = MeCab.Tagger()
        self._stopwords = stopwordsiso.stopwords("ja")
        
    def _tokenize(self, text: str) -> list[str]:
        """MeCabを使用してテキストをトークン化します。

        Args:
            text (str): トークン化する入力テキスト

        Returns:
            list[str]: トークンのリスト
        """
        lines = self._mecab_tagger.parse(text).splitlines()[:-1]
        nodes = [
            [line.split("\t")[0], line.split("\t")[4].split("-")[0]] for line in lines
        ]
        # 補助記号を削除
        nodes = self._remove_symbols(nodes)
        # ストップワードを削除
        nodes = self._remove_stopwords(nodes)
        return [node[0] for node in nodes]

    def embed_documents(self, chunk_texts: list[str]) -> list[SparseEmbedding]:
        """ドキュメントに対するチャンクの埋め込みを生成します。

        Args:
            chunk_texts (list[str]): 埋め込みを生成するテキストチャンクのリスト

        Returns:
            list[SparseEmbedding]: 埋め込みのリスト
        """
        filtered_chunks = []
        for chunk_text in chunk_texts:
            normalized_text = neologdn.normalize(text=chunk_text)
            tokens = self._tokenize(text=normalized_text)
            concat_tokens = " ".join(tokens)
            filtered_chunks.append(concat_tokens)
        return list(self._bm25_model.embed(documents=filtered_chunks, parallel=0))

    def embed_query(self, query_text: str) -> SparseEmbedding:
        """クエリに対するチャンクの埋め込みを生成します。

        Args:
            query_text (str): 埋め込みを生成するテキストチャンク

        Returns:
            SparseEmbedding: 埋め込み
        """
        normalized_text = neologdn.normalize(text=query_text)
        tokens = self._tokenize(text=normalized_text)
        tokenized_query = " ".join(tokens)
        return list(self._bm25_model.query_embed(query=tokenized_query))[0]

def main():
    # Step 1: データセットの読み込み
    loader = DatasetLoader(work_title="吾輩は猫である")
    texts = loader.load_texts()

    # Step 2: チャンクの作成
    chunker = TextChunker()
    chunk_texts = chunker.split_texts(texts=texts)

    # Step 3: Qdrant データベースの準備
    qdrant_manager = QdrantManager(collection_name="test_collection")
    qdrant_manager.init_collection()

    # Step 4: チャンクをデータベースに登録
    embedder = TextEmbedder()
    chunk_embeddings = embedder.embed_documents(chunk_texts=chunk_texts)
    qdrant_manager.insert_chunks(
        chunk_texts=chunk_texts, chunk_embeddings=chunk_embeddings
    )

    # Step 5: クエリの埋め込み
    query_text = "吾輩は猫である"
    query_embedding = embedder.embed_query(query_text=query_text)

    # Step 6: クエリで検索
    results = qdrant_manager.search(query_embedding=query_embedding)
    for point in results:
        print(f"id={point.id}\nscore={point.score}\ncontent={point.payload['text']}\n")


if __name__ == "__main__":
    main()
```

### `SparseTextEmbedding(..., disable_stemmer=True)` としている部分
FastEmbed で Sparse Vector を生成するには、SparseTextEmbedding というクラスを経由する必要があります。SparseTextEmbedding クラスの内部では [py-rust-stemmers](https://github.com/qdrant/py-rust-stemmers) パッケージを使って単語の語幹（stem）を取得しているのですが、残念ながら、このパッケージは日本語に対応していません。

このため、stemmer を無効化しなければ、英語前提での事前処理（stem, stopwords, ...）が行われてしまいます。結果、`disable_stemmer=True` を指定することが日本語文章を扱う上では必須となるのです。
```python
class TextEmbedder:
    """テキストチャンクのスパース埋め込みを生成するクラス。

    Attributes:
        None
    """

    def __init__(self):
        """TextEmbedderを初期化します。"""
        self._bm25_model = SparseTextEmbedding(
            model_name="Qdrant/bm25", disable_stemmer=True
        )
```


###  MeCab を使った形態素解析を行っている部分
前項では、FastEmbed が日本語文章を英語前提で事前処理するため、stemmer を無効化にしなければいけないという話をしました。

実はトークン化 (tokenization) においても同じことが起きており、デフォルトの Tokenizer では空白（スペース）を基準に単語を区別しているせいで、日本語では文章全体が１トークンとみなされてしまいます。
このため、文章の正規化、形態素解析、記号等の除去、トークン化という事前処理を開発者側で行う必要があります。

この辺りの処理を行っているのが、以下の部分の実装になります。

**処理の流れ**
1. 文章を [neologdn](https://github.com/ikegami-yukino/neologdn) で正規化する
1. [MeCab](https://github.com/SamuraiT/mecab-python3) で形態素解析する
1. 各ノードの文章から補助記号を削除する
1. 各ノードの文章からストップワード（[stopwords-iso](https://github.com/stopwords-iso/stopwords-iso)）を削除する
1. 各ノードを空白（半角スペース）で繋げて、ひとつの文章にする
1. 文章を FastEmbed に注入し、Sparse Vector を計算する

```python
def _tokenize(self, text: str) -> list[str]:
    """MeCabを使用してテキストをトークン化します。

    Args:
        text (str): トークン化する入力テキスト

    Returns:
        list[str]: トークンのリスト
    """
    # 2. MeCabで形態素解析する
    lines = self._mecab_tagger.parse(text).splitlines()[:-1]
    nodes = [
        [line.split("\t")[0], line.split("\t")[4].split("-")[0]] for line in lines
    ]
    # 3. 各ノードの文章から補助記号を削除する
    nodes = self._remove_symbols(nodes)
    # 4. 各ノードの文章からストップワードを削除する
    nodes = self._remove_stopwords(nodes)
    return [node[0] for node in nodes]

（中略）

def embed_documents(self, chunk_texts: list[str]) -> list[SparseEmbedding]:
    """ドキュメントに対するチャンクの埋め込みを生成します。

    Args:
        chunk_texts (list[str]): 埋め込みを生成するテキストチャンクのリスト

    Returns:
        list[SparseEmbedding]: 埋め込みのリスト
    """
    filtered_chunks = []
    for chunk_text in chunk_texts:
        # 1. 文章を正規化する
        normalized_text = neologdn.normalize(text=chunk_text)
        # 日本語文章をトークン化
        tokens = self._tokenize(text=normalized_text)
        # 5. 各ノードを空白（半角スペース）で繋げて、ひとつの文章にする
        concat_tokens = " ".join(tokens)
        filtered_chunks.append(concat_tokens)
    # 6. 文章を FastEmbed に注入し、Sparse Vector を計算する
    return list(self._bm25_model.embed(documents=filtered_chunks, parallel=0))

def embed_query(self, query_text: str) -> SparseEmbedding:
    """クエリに対するチャンクの埋め込みを生成します。

    Args:
        query_text (str): 埋め込みを生成するテキストチャンク

    Returns:
        SparseEmbedding: 埋め込み
    """
    normalized_text = neologdn.normalize(text=query_text)
    tokens = self._tokenize(text=normalized_text)
    tokenized_query = " ".join(tokens)
    # 検索クエリに対しては query_embed メソッドを使う
    return list(self._bm25_model.query_embed(query=tokenized_query))[0]
```

重要なところは MeCab で形態素解析をしている部分です。文章を品詞分解して単語レベルに分解することにより、英語をスペース区切りで単語レベルに分解するのと同じ効果を得ています。

また、助詞や記号などの情報はキーワード検索においては検索精度の低下をもたらす可能性があるため、ここでは `_remove_symbols` と `_remove_stopwords` で削除しています。その他にも、MeCab に文章を渡す前に正規化することで、文章のゆらぎを最小限にしています。

出来上がった単語の集合は、半角スペース（" "）で結合することで、英語と同じように後続処理が走るようにしています。

## 実際の動き（デモ）
夏目漱石「[吾輩は猫である](https://www.aozora.gr.jp/cards/000148/files/789_14547.html)」（青空文庫）に対し、複数のキーワードで検索した結果です。

- 検索キーワード「吾輩は猫である」
  
  ```bash
  $ python main.py

  id=2705
  score=9.876125
  content=吾輩は猫である。猫の癖にどうして主人の心中をかく精密に記述し得るかと疑うものがあるかも知れんが、このくらいな事は猫にとって何でもない。吾輩はこれで読心術を心得ている。いつ心得たなんて

  id=57
  score=9.799118
  content=、じっと辛棒しておった。彼は今吾輩の輪廓をかき上げて顔のあたりを色彩っている。吾輩は自白する。吾輩は猫として決して上乗の出来ではない。背といい毛並といい顔の造作といいあえて他の猫に勝るとは決して思っておらん。しかしいくら不器量の吾輩でも

  id=180
  score=9.551718
  content=両人が出て行ったあとで、吾輩はちょっと失敬して寒月君の食い切った蒲鉾の残りを頂戴した。吾輩もこの頃では普通一般の猫ではない。まず桃川如燕以後の猫か

  id=1894
  score=9.454283
  content=、吾輩が彼等に向って示す怒りの記号も何等の反応を呈出しない。考えて見ると無理のないところだ。吾輩は今まで彼等を猫として取り扱っていた。それが悪るい。猫ならこのくらいやればたしかに応えるのだが生憎相手は烏だ。烏の勘公とあって見れば致し方がない

  id=146
  score=9.380697
  content=、餅屋は餅屋、猫は猫で、猫の事ならやはり猫でなくては分らぬ。いくら人間が発達したってこればかりは駄目である。いわんや実際をいうと彼等が自ら信じているごとくえらくも何ともないのだからなおさらむずかしい。またいわんや同情に乏しい吾輩の主人のごときは
  ```
  完全一致である「吾輩は猫である」という文章はもちろんのこと、「吾輩」や、「猫」というキーワードが独立して配置されている文章も、上手くヒットしていることが分かります。
<br />


- 検索キーワード「鼠をとる猫」
  
  ```bash
  $ python main.py

  id=1452
  score=22.940628
  content=、寝ていても訳なく捕れる。昔しある人当時有名な禅師に向って、どうしたら悟れましょうと聞いたら、猫が鼠を覘うようにさしゃれと答えたそうだ。猫が鼠をとるようにとは

  id=1449
  score=22.556946
  content=、鴻雁も鳥屋に生擒らるれば雛鶏と俎を同じゅうす。庸人と相互する以上は下って庸猫と化せざるべからず。庸猫たらんとすれば鼠を捕らざるべからず。――吾輩はとうとう鼠をとる事に極めた。

  id=101
  score=21.719364
  content=。この時から吾輩は決して鼠をとるまいと決心した。しかし黒の子分になって鼠以外の御馳走を猟ってあるく事もしなかった。御馳走を食うよりも寝ていた方が気楽でいい。教師の家にいると猫も教師のような性質になると見える。要心しないと今に胃弱になるかも知れない。

  id=97
  score=17.580673
  content=。ちっと景気を付けてやろうと思って「しかし鼠なら君に睨まれては百年目だろう。君はあまり鼠を捕るのが名人で鼠ばかり食うものだからそんなに肥って色つやが善いのだろう」黒の御機嫌をとるためのこの質問は不思議にも反対の結果を呈出した。彼は喟然として大息していう

  id=632
  score=13.430288
  content=、鼠は取れそうもない、……どうです奥さんこの猫は鼠を捕りますかね」と吾輩ばかりでは不足だと見えて
  ```
  わざと検索キーワードが一度も作品のなかで出てこないものを選んだのですが、「鼠」「とる」「猫」というキーワードを独立に捉え、それらを含むデータがヒットしていることが分かります。


# 終わりに
本記事では、Qdrant で BM25 を利用した日本語のキーワード検索を実装する方法を紹介しました。Qdrant & BM25 検索を検討されている方は、ぜひ今回ご紹介したものをベースに導入を進めてみてください。

# ソースコード
本稿で扱ったソースコードは [teppeisasaki/qdrant-bm25](https://github.com/teppeisasaki/qdrant-bm25) にて公開しています。
<br />

記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。