Text Embedding用モデルプロバイダーを用意したかった(text-embeddings-inference・ollama編)

Text Embeddingをおこなう際に重要となるのは、何をどのような基準でベクトル化するかである。 英文であれば、1単語ごとにスペースで区切ってあることがほとんどであるので、単語を基準にするのが手っ取り早い。 一方の日本語文では、単語の区切りが明確になっておらず、単語単位で取り出そうとすると形態素解析が必須となる。 これを文字単位で切り出してベクトル化すると、文字が一部分一致しているだけのまるで嚙み合わない結果が返ってくるようになってしまう。

セルフホスティングできるText Embeddingのモデルプロバイダーとしては、 huggingface/text-embeddings-inference、 モデルとしてはhttps://huggingface.co/cl-nagoya/ruri-largeなどがある。 しかしながら、このtext-embeddings-inference日本語用のEmbeddingモデル(cl-nagoya/ruri-large)を 用いてAPIサーバーを建てようとすると、tokenizer.jsonがないよ~と次のようなエラーが出力されてしまう。

$ docker run --rm \
    --gpus all \
    -p 8083:80 \
    -v $PWD/data:/data \
    --pull always \
    ghcr.io/huggingface/text-embeddings-inference:89-1.6 \
    --model-id cl-nagoya/ruri-large
...
...
HTTP status client error (404 Not Found) for url (https://huggingface.co/cl-nagoya/ruri-large/resolve/main/tokenizer.json)

tokenizer_config.jsonを覗いてみると、word_tokenzer_typemecabになっていたり、 mecabの辞書にunidic_liteが用いられていたり、 sudachiっぽい記述があったりと、外部のtokenizerが用いられていることが分かる。

  "clean_up_tokenization_spaces": true,
  "cls_token": "[CLS]",
  "do_lower_case": false,
  "do_subword_tokenize": true,
  "do_word_tokenize": true,
  "jumanpp_kwargs": null,
  "mask_token": "[MASK]",
  "mecab_kwargs": {
    "mecab_dic": "unidic_lite"
  },
  "model_max_length": 512,
  "never_split": null,
  "pad_token": "[PAD]",
  "sep_token": "[SEP]",
  "subword_tokenizer_type": "wordpiece",
  "sudachi_kwargs": null,
  "tokenizer_class": "BertJapaneseTokenizer",
  "unk_token": "[UNK]",
  "word_tokenizer_type": "mecab"

cl-nagoya/ruri-largeのページには、fugashiをインストールせよとの記述もある。

# cl-nagoya/ruri-largeをsentence-transformersから利用する際の依存ライブラリ
sentence-transformers fugashi sentencepiece unidic-lite

残念ながらこれらはtext-embeddings-inferenceでは考慮されておらず、 text-embeddings-inferenceは一般的なtokenizer.jsonを用いようとするため、 先述のエラーが発生してしまう。

ではollamaのリポジトリにあるkun432/cl-nagoya-ruri-largeは 一体どうやってるんだろうと思い試してみたところ、精度が芳しくなく、的外れな検索結果が返ってくるケースが非常に多かった。 やはり適切なtokenizerを利用できる状態にしなければならない。

Text Embedding用モデルプロバイダーの実装

text-embeddings-inferenceollamaも利用できないのであれば他も同様であろうと推察される。 そこで今回は、OpenAIのEmbeddings API(/v1/embeddings)と同じフォーマットで結果を返す、互換サーバーを実装してみることにした。

といっても実装はシンプルで、次のようなリクエストが/v1/embeddings宛てにapplication/jsonで来た際に、

{
    "model": "cl-nagoya/ruri-large",
    "input": [
        "文章: てきとうなテキストだよ。",
        "文章: てきとうなテキストです。"
    ]
}

レスポンスとして次のようなJSONを返すようにするだけである。

{
    "object": "list",
    "data": [
        {
            "object": "embedding",
            "embedding": [
                0.8186585903167725,
                -0.47749972343444824,
                -0.34532251954078674,
                -0.7178557515144348,
                ...
                -0.6007830500602722
            ],
            "index": 0
        },
        {
            "object": "embedding",
            "embedding": [
                0.958991289138794,
                -0.322582870721817,
                -0.35985010862350464,
                -0.5917154550552368,
                ...
                -0.6487450003623962
            ],
            "index": 1
        }
    ],
    "model": "cl-nagoya/ruri-large",
    "usage": {
        "prompt_tokens": 0,
        "total_tokens": 0
    }
}

レスポンスのdata.embeddingには、リクエストの順にベクトル(cl-nagoya/ruri-largeの場合は1024個の浮動小数点数)が入ってくる。

実行環境と依存ライブラリの用意

Pythonの実行環境の用意には、uvを用いる。 あらかじめ、uvのInstallationを参考に、uvをインストールしておく。

$ mkdir ~/embed-rerank-server
$ cd embed-rerank-server
$ uv init

必要なライブラリをインストールする。

$ uv add fastapi uvicorn fugashi pydantic sentence-transformers sentencepiece unidic-lite

うまく準備できれば、次のようなpyproject.tomlが生成されているはずである。

[project]
name = "embed-rerank-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "fastapi>=0.115.8",
    "fugashi>=1.4.0",
    "pydantic>=2.10.6",
    "sentence-transformers>=3.4.1",
    "sentencepiece>=0.2.0",
    "unidic-lite>=1.0.8",
    "uvicorn>=0.34.0",
]

APIサーバーの実装

Text Embedding APIサーバーを、embeddings-api-server.pyとして、次のように実装する。

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Union
from sentence_transformers import SentenceTransformer


app = FastAPI()
model = SentenceTransformer("cl-nagoya/ruri-large")


class EmbeddingRequest(BaseModel):
    model: str
    input: Union[str, list[str]]


@app.post("/v1/embeddings")
def create_embedding(request: EmbeddingRequest):
    texts = [request.input] if isinstance(request.input, str) else request.input
    embeddings = model.encode(texts, convert_to_numpy=True).tolist()
    data = []
    for idx, emb in enumerate(embeddings):
        data.append({"object": "embedding", "embedding": emb, "index": idx})
    res = {
        "object": "list",
        "data": data,
        "model": request.model,
        "usage": {"prompt_tokens": 0, "total_tokens": 0},
    }
    return res

APIサーバーの起動

uvicornを通じて、APIサーバーを起動する。この際のポート番号はてきとうに空いているものを利用する。 当然ながら、Difyのインスタンスからネットワーク的に到達できるように、適宜変更が必要である。

$ uv run uvicorn embeddings-api-server:app --host 0.0.0.0 --port 8081

動作検証

実際に機能するか検証する。 curlで試す際は、次のようにリクエストを投げる。

$ curl -v http://127.0.0.1:8081/v1/embeddings -H 'Content-Type: application/json' -d '
{
    "model": "cl-nagoya/ruri-large",
    "input": [
        "文章: てきとうなテキストだよ。",
        "文章: てきとうなテキストです。"
    ]
}'

無事ながーいベクトル(cl-nagoya/ruri-largeの場合は1024要素)が返ってくれば成功である。

他のモデルプロバイダーの用意

参考文献