📝

Solrのベクトル検索がちょっとだけ楽になったらしい

2025/02/27に公開

2025/1/23にSolr9.8がリリースされましたね。
アップデート内容を見ていたら、ちょっと面白そうな機能がリリースされたので調べてみました。

結論としては、こんな感じです。

  • クエリ次元限定でSolr上でEmbeddingが可能になった
  • 軽微な設定だけでローカルモデルも外部LLMとも連携可能
  • まだ試験的な機能でパフォーマンス面など課題がある

検証に使ったサンプルコードも公開しているのでよかったらどうぞ。
https://github.com/Sashimimochi/solr-text-to-vector-demo

はじめに

ここ最近は個人的には目玉機能のアップデートがなかったので、今回もさほど期待はしていませんでした。
なので、何の気なしにSolrのアップデート情報を眺めていたのですが、ふと気になる文言が目に留まりました。

New knn_text_to_vector query parser that encodes query text into a vector (AKA "embedding") via external LLM services.

うん?どうやらSolr上でテキストのEmbeddingができるようになったらしいぞ。
そして、いつの間にかしれっとベクトル検索関連のドキュメントも更新されていました。
https://solr.apache.org/guide/solr/latest/query-guide/dense-vector-search.html
https://solr.apache.org/guide/solr/latest/query-guide/text-to-vector.html

ベクトル検索をやるときにハードルとなるのが、Embeddingを検索エンジンの外部で行う必要があることでした。
それが内部で完結できるのであれば、サービス導入へのしやすさがグッと上がる可能性があります。
これは調査せねばなるまいとのことで、一人アマゾンの奥地に向かうことにしました。

できるようになったこと

確かに、キーワード検索と同様にテキストでリクエストを送るだけでベクトル検索ができてしまいました。

例えば、以下のようなクエリを投げると、Solr側でEmbeddingを行ってベクトル検索をしてくれています。

http://localhost:8983/solr/idcc/select?fl=body&indent=true&facet=false&q={!knn_text_to_vector model=mymodel f=vector topK=10}温かい鍋料理が食べたい

技術的にはLangChain4j
というLangChainのJavaバインディングを使っているようです。

とうとうJavaからもLangChainが使えるようになったか!これは便利だ!

と思っていたのですが、よくよく調べてみるとなんとも片手落ち感がある機能でした。
というのも、Solr上でEmbeddingが可能なのはクエリ時(リクエスト時)のみです。
インデックス時は引き続き外部でEmbeddingしておく必要があります。
IndexerにEmbedding処理を実装するのからは逃れられませんでした。

一応、インデックス時もSolr上でサポートしようよという話は上がったみたいです。
ただ、それまで含めると大きすぎるということで、今回はスコープ外として見送ったようです。
https://issues.apache.org/jira/browse/SOLR-17525

インデックス時/クエリ時フルサポートではありませんが、リアルタイム検索で専用APIを用意する必要がなくなっただけでも実装はしやすくなったと言えるでしょう。
今後のアップデートに期待しつつ、現状の機能を試してみたいと思います。

実際にやってみた

今回はEmbeddingモデルとして以下を使います。
そこそこのスペックがあれば動く、多言語対応に優れた埋め込みモデルです。
https://huggingface.co/intfloat/multilingual-e5-base

インデックスデータには、いつもお世話になっているデータセットを使用します。

まずは、スキーマを用意します。
ここは以前と変わりません。
詳しくは以前記事にしているので、こちらをご覧ください。
https://zenn.dev/sashimimochi/articles/1957974d64d571

例えば以下のようにmanaged-schemaを書きます。
次元数はEmbeddingモデルに合わせて768次元としています。

<?xml version="1.0" encoding="UTF-8" ?>
<schema name="example" version="1.6">
    <field name="_version_" type="plong" indexed="false" stored="false"/>
    <field name="_root_" type="string" indexed="true" stored="false" docValues="false"/>

    <uniqueKey>id</uniqueKey>

    <field name="id" type="string" indexed="true" stored="true" required="true"/>
    <field name="_id" type="int" indexed="true" stored="true"/>
    <field name="media" type="string" indexed="true" stored="false" docValues="true"/>
    <field name="url" type="string" indexed="false" stored="false" docValues="true"/>
    <field name="created_at" type="pdate" indexed="false" stored="false" docValues="true"/> 
    <field name="title" type="string" indexed="true" stored="true"/>
    <field name="body" type="string" indexed="true" stored="true"/>
    <field name="vector" type="knn_vector" indexed="true" stored="true"/>
    <field name="metadata_title_s" type="tring" indexed="true" stored="true"/>
    <field name="metadata_body_s" type="string" indexed="true" stored="false"/>
    <dynamicField name="*_s" type="string" indexed="false" stored="false"/>
    <dynamicField name="*_i" type="pint" indexed="false" stored="false"/>

    <fieldType name="int" class="solr.TrieIntField" precisionStep="0" positionIncrementGap="0"/>
    <fieldType name="pint" class="solr.IntPointField" docValues="true"/>
    <fieldType name="plong" class="solr.LongPointField" docValues="true"/>
    <fieldType name="string" class="solr.StrField" sortMissingLast="true" docValues="true"/>
    <fieldType name="text_general" class="solr.TextField" positionIncrementGap="100"/>
    <fieldType name="pdate" class="solr.DatePointField" docValues="true"/>
    <fieldType name="knn_vector" class="solr.DenseVectorField" vectorDimension="768" similarityFunction="cosine"/>
</schema>

続いて、solrconfig.xmlを用意します。
ここが新たに設定が必要になっています。
と言っても、新設されたクエリパーサーを読み込むだけです。

<?xml version="1.0" encoding="UTF-8" ?>
<config>
    <luceneMatchVersion>9.8.0</luceneMatchVersion>

    <lib dir="/opt/solr-9.8.0/modules/extraction/lib" regex=".*\.jar" />
    <lib dir="/opt/solr-9.8.0/modules/langid/lib/" regex=".*\.jar" />
    <!--↓この行を追加する!-->
    <lib dir="/opt/solr-9.8.0/modules/llm/lib/" regex=".*\.jar" />
    <dataDir>${solr.data.dir:}</dataDir>

    <!--↓この行を追加する!-->
    <queryParser name="knn_text_to_vector" class="org.apache.solr.llm.texttovector.search.TextToVectorQParserPlugin"/>
    <!--中略-->
</config>

ただし、ここで大きな罠が潜んでします。
というのも、Solr9.8からsolrconfig.xml<lib>タグがデフォルトで無効にされるようになりました。

"<lib>" tags in solrconfig.xml are now quietly ignored by default unless explicitly enabled with the SOLR_CONFIG_LIB_ENABLED=true environment variable (or corresponding sysprop)

なので、solr.in.shなどで環境変数を設定して、<lib>タグを有効にしておく必要があります。

SOLR_CONFIG_LIB_ENABLED=true

また、起動オプションでLLMのモジュールを有効にしておく必要があります。
docker-compose.ymlでは、commandを使って以下のように設定します。

x-solr-service: &solr-service
  image: solr:9.8.0
  ports:
    - "8983:8983"
  command:
    - "-Denable.packages=true -Dsolr.modules=llm"

ここまでできたら、Solrを起動して、コレクションを作成します。
今回はidccという名前でコレクションを作成した想定で行きます。

コレクションの作成までできたら、モデルの設定ファイルを書いてSolrにアップロードします。
例えば、myModel.jsonを作成して以下のように記載します。

{
  "class": "dev.langchain4j.model.huggingface.HuggingFaceEmbeddingModel",
  "name": "mymodel",
  "params": {
    "accessToken": "<YOUR_ACCESS_TOKEN>",
    "modelId": "intfloat/multilingual-e5-base"
  }
}

上記では、HuggingFaceにあるモデルをSolr上で動かすための設定になります。
nameはモデルを呼び出すときに指定する名前を書きます。
お好みの名前を設定してください。

accessTokenにはHuggingFaceのアクセストークンを設定します。
以下を参考にしながらアクセストークンを発行してください。
https://huggingface.co/docs/hub/security-tokens

modelIdには使用するモデル名を入力します。

HuggingFace以外にもOpenAIやCohereなども使えます。
詳しくは、公式ドキュメントをご覧ください。

モデルの設定ファイルが作成出来たら、以下のようにしてSolrにアップロードします。

curl -XPUT 'http://localhost:8983/solr/idcc/schema/text-to-vector-model-store' --data-binary "@./solr/myModel.json" -H 'Content-type:application/json'

アップロードしたモデルの設定は以下のURLから確認できます。

http://localhost:8983/solr/idcc/schema/text-to-vector-model-store/currentModel

正常に登録できていれば、以下のようなレスポンスが返ってきます。

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "models": [
    {
      "name": "mymodel",
      "class": "dev.langchain4j.model.huggingface.HuggingFaceEmbeddingModel",
      "params": {
        "accessToken": "<YOUR_ACCESS_TOKEN>",
        "modelId": "intfloat/multilingual-e5-base"
      }
    }
  ]
}

なお、同じ名前のモデルは複数登録できません。
その場合は、一度削除してから再アップロードする必要があります。
削除も以下のようにAPI経由でできます。

curl -XDELETE 'http://localhost:8983/solr/idcc/schema/text-to-vector-model-store/mymodel'

ここまででSolr側に必要な設定は以上です。

ここからはベクトル検索をやっていこうということになるのですが、まずは検索対象となるインデックスを用意する必要があります。
これはSolr上では行えないので、外部で頑張ってやる必要があります。
例えば私は最近こんな感じでやってます。
https://zenn.dev/sashimimochi/articles/29d78fadaf8b17

インデックスが投入出来たら、いよいよ検索してみましょう。

http://localhost:8983/solr/idcc/select?fl=body&indent=true&facet=false&q={!knn_text_to_vector model=mymodel f=vector topK=10}温かい鍋料理が食べたい

すると、こんな感じでAPIなしでもベクトル検索ができました!

{
  "responseHeader": {
    "zkConnected": true,
    "status": 0,
    "QTime": 248,
    "params": {
      "q": "{!knn_text_to_vector model=mymodel f=vector topK=10}温かい鍋料理が食べたい",
      "df": "id",
      "indent": "true",
      "echoParams": "all",
      "fl": "body",
      "q.op": "AND",
      "sort": "score desc",
      "rows": "20",
      "facet": "false",
      "wt": "json",
      "rid": "null-83"
    }
  },
  "response": {
    "numFound": 10,
    "start": 0,
    "numFoundExact": true,
    "docs": [
      {
        "body": "[グルメ]鍋"
      },
      {
        "body": "おいしく楽しく料理をしてみてはいかがでしょうか。"
      },
      {
        "body": "熱々のおいしい鍋を家族でつつきながら食べるのは、一番の幸せだ。"
      },
      {
        "body": "だれかの作った料理がいいのだ。"
      },
      {
        "body": "ここは料理も美味しいんです。"
      },
      {
        "body": "[グルメ]好きな食べもの"
      },
      {
        "body": "冬、といえば鍋だ。"
      },
      {
        "body": "料理がやってきました・・・。"
      },
      {
        "body": "冬になり、寒くなるにつれて、煮込む系の料理がしたくなる。"
      },
      {
        "body": "[グルメ]自炊"
      }
    ]
  }
}

注意点として、Solr上でEmbeddingモデルを動かすので、その分リソースを食います。
性能がいいからと言って高次元ベクトルを実行するとうっかりサーバが落ちちゃうこともあるかもなのでお気を付けください。

ちなみに先ほど話したように、OpenAI API likeな外部APIをモデルとして使用することもできます。
OpenAI APIはもちろん、Geminiなどほかのモデルでも指定可能です[1][2]

// http://localhost:8983/solr/idcc/schema/text-to-vector-model-store/currentModel

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "models": [
    {
      "name": "mymodel",
      "class": "dev.langchain4j.model.huggingface.HuggingFaceEmbeddingModel",
      "params": {
        "accessToken": "<YOUR_API_KEY>",
        "modelId": "intfloat/multilingual-e5-base"
      }
    },
    {
      "name": "gemini",
      "class": "dev.langchain4j.model.openai.OpenAiEmbeddingModel",
      "params": {
        "baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai",
        "apiKey": "<YOUR_API_KEY>",
        "modelName": "text-embedding-004",
        "timeout": 60,
        "logRequests": true,
        "logResponses": true,
        "maxRetries": 5
      }
    }
  ]
}

上記のように複数のモデルをSolr上に登録できます。
検索時には使用したいモデル名を指定すれば、リクエスト内容によってモデルを切り替えることも可能です。

http://localhost:8983/solr/idcc/select?fl=body&indent=true&facet=false&q={!knn_text_to_vector model=gemini f=vector topK=10}温かい鍋料理が食べたい

外部APIを呼び出すのでSolrサーバの負荷は下げられるでしょう。
外部APIの呼び出しをSolrの設定として組み込めるので、Solr側としてもAPI提供側としても余計なことを意識しなくて済みます。助かりますね。

まとめ

クエリ時限定ですが、Solr上でEmbeddingが可能になりました。
インデックス時の埋め込み処理は相変わらず頑張る必要がありますが、Solrでのベクトル検索がちょっとだけ楽になったと言えるでしょう。

なかなか面白い機能ですが、まだまだ発展途上な側面もあります。
インデックス時をサポートしていないほかに

Apache Solr uses LangChain4j to interact with Large Language Models. The integration is experimental and we are going to improve our stress-test and benchmarking coverage of this query parser in future iterations: if you care about raw performance you may prefer to encode the text outside of Solr

とあるようにまだ実験的な機能でパフォーマンス面などの課題が残っているようです。

それでもこれを機にSolrを使ったベクトル検索の事例が増えると嬉しい限りです。
それではまた。

脚注
  1. GeminiをOpenAI互換で扱う場合は以下が参考になります。 https://ai.google.dev/gemini-api/docs/openai?hl=ja ↩︎

  2. text-embedding-004モデルの出力次元数は768次元です。 https://ai.google.dev/gemini-api/docs/models/gemini?hl=ja ↩︎

Discussion