Solr におけるベクトル化されたチャンクの TopK 戦略 | KandaSearch Community Support Forum

Solr におけるベクトル化されたチャンクの TopK 戦略

トピック作成者:ks-solruserml-bot (2025/10/24 18:53 投稿)
7
OpenOpen

(The bot translated the original post https://lists.apache.org/thread/m8z4tvow83cky6xh6vtxh15kc8vr9r07 into Japanese and reposted it under Apache License 2.0. The copyright of posted content is held by the original poster.)

こんにちは、

Solr をベクターデータベースとして利用している方々に質問です。
現状、Solr はベクトル検索において 親子関係(parent/child)マルチ値ベクトルフィールド をサポートしていませんが、同一(大きな)ドキュメントに対してベクトル化された複数のチャンクを保持している場合、Top K の結果に重複が発生するのを防ぐにはどのような戦略を取っていますか?

また、ベクトルを通常の(語彙的な)インデックスと同じドキュメントに格納する場合と、ベクトル化されたチャンクを別のインデックスに格納する場合で、どのように対処しているかも教えていただけると助かります。

ありがとうございます。
Rahul

返信投稿者:ks-solruserml-bot (2025/10/24 18:53 投稿)

こんにちは、
どなたか見解をお持ちでしたら、もう一度この話題を上げさせていただきます。
よろしくお願いします。

Rahul

返信投稿者:ks-solruserml-bot (2025/10/24 18:54 投稿)

こんにちは Rahul さん。

ストリーミング式(streaming expressions)の利用を検討されたことはありますか?
それを使えば、タプルを取得してグループ化することができますよ。

よろしく、
Sergio

返信投稿者:ks-solruserml-bot (2025/10/24 18:55 投稿)

こんにちは Rahul さん、

私は現在、次のようなトポロジーを使っています:

  • 通常どおりドキュメントレコードをインデックス化します。
  • その上で、チャンク(分割されたテキスト)レコードを作成し、それぞれに親ドキュメントの ID を紐づけます。

具体的には、次のような形です(簡略化しています):

ドキュメント 1

-id: DOC_1  
-title: 2025 Annual Report  
-document_type: PDF

ドキュメント 1 のチャンク 1

-id: CHUNK_1_1  
-text: <最初のチャンクのテキスト>  
-vector: <最初のチャンクのベクトル埋め込み>  
-parent_id: DOC_1  
-position: 0

ドキュメント 1 のチャンク 2

-id: CHUNK_1_2  
-text: <2番目のチャンクのテキスト>  
-vector: <2番目のチャンクのベクトル埋め込み>  
-parent_id: DOC_1  
-position: 1

(以下同様…)


チャンクに対してセマンティック検索(ベクトル検索)を行い、ドキュメントを取得したい場合は、次のように join を使います:

q={!join from=parent_id to=id score=max}{!knn f=vector topK=100}[0.255,0.36,…]

score=max による集約を使うことで、結果セットに重複したドキュメントが含まれないようにしています。
ただし、TopK=100 のチャンクを要求しても、複数のチャンクが同じドキュメントに属しているため、実際に返されるドキュメント数はそれより少なくなります。
「max」集約によって、各ドキュメントを「最もスコアの高いチャンク」でランク付けしています。


ドキュメント側にフィルタを適用したい場合(例:PDF ドキュメントだけに絞る場合)は、少し複雑になります。
この場合、KNN 検索の preFilter で絞り込みを行います。例:

q={!join from=parent_id to=id score=max}{!knn f=vector topK=100 preFilter=$type_prefilter}[0.255,0.36,…]
&type_prefilter={!join from=id to=parent_id score=none} document_type:PDF

この preFilter はドキュメントに対して実行され、
document_type:PDF を満たすドキュメントのチャンクのみが、KNN 検索の対象(コーパス)になります。


このインデックス設計は、ドキュメントのインデックス化とチャンクのインデックス化を完全に分離して管理できる点が非常に便利です。
「部分更新(partial updates)」や「ネストされたドキュメント(nested documents)」を使う方法も検討しましたが、
stored でないフィールドがあると問題が生じたり、チャンクを追加するたびにドキュメント全体を再構築しなければならないため、避けています。

とはいえ、もっと良い方法があるかもしれません。
特に、ドキュメント数が増えると join は必ずボトルネックになる(たとえ docValues を使っていても)ので、その点は課題です。

参考になれば幸いです!

ちなみに、Alessandro Benedetti さんによる素晴らしい動画があります。ぜひご覧ください:
👉 https://youtu.be/9KJTbgtFWOU?si=YAUPNvfDhlX3NmJc&t=1450

— Guillaume

返信投稿者:ks-solruserml-bot (2025/10/24 18:55 投稿)

こんにちは Guillaume さん、

とても丁寧で実例を交えた詳しい回答をありがとうございます。
返信が遅れてしまい、申し訳ありません。

いくつか追加で質問させてください:

1️⃣ インデックスにはどのくらいのドキュメント数(親+チャンク)が含まれていますか?

2️⃣ クエリ時の join はスケールできていますか?
 (つまり、ドキュメント数が増えても性能的に問題ないでしょうか?)

3️⃣ pre-filtering についてですが、
 親ドキュメントに join し直すのではなく、
 最小限のメタデータをチャンク側に重複して持たせて、
 チャンクに直接 pre-filter をかけるという方法は検討しましたか?

4️⃣ Top K の親ドキュメントを取得するユースケースはありますか?
 もしある場合、複数のチャンクが同じ親に属して結果が縮小してしまう中で、
 どのように安定して実現しているのかを教えてください。

5️⃣ ④に関連して、ベクトル検索結果に基づくページネーション(ページ送り)のユースケースはありますか?
 もしある場合、上記のように複数チャンクが同じ親に属する制約がある中で、
 どのようにページネーションを実現しているのかを教えてください。

事前にお礼申し上げます!
— Rahul

返信投稿者:ks-solruserml-bot (2025/10/24 18:55 投稿)

こんにちは Rahul さん、

では、回答しますね!


1)
現在、Solr ドキュメントは合計で約 180 万件 あります。
そのうち 約 60 万件が親ドキュメント約 120 万件がチャンク です。
ドキュメントによってはチャンクが 1 つしかないものもあれば、数十個あるものもあります。


2)
join フィールドに DocValues を使用することでパフォーマンスは大きく改善されます。
とはいえ、大量のデータを join する処理はどうしてもコストがかかります。
ただし、Solr クラスタ内で複数ノードを利用し、
ドキュメントとそのチャンクを同じシャードに配置することで負荷を分散することは可能です。


3)
チャンクに親ドキュメントのプロパティを注入することで、
最もコストの高い join を回避できます。
ただしその場合、親ドキュメントに対して適用できるフィルタの種類が、
「チャンクに複製されたフィールド」に限定されてしまいます。
現時点ではその制約を受け入れることができないため、この方法は採用していません。


4)
特に「Top K の親ドキュメント」を制御する必要はありません。
制御しているのは 返されるチャンクの数 だけです。
ドキュメントの数は通常それより少なくなります(同じドキュメントから複数のチャンクが見つかるため)。
私のユースケースではそれが問題になることはなく、
そのため TopK を少し大きめに設定 して補っています。


5)
ページネーションも問題ありません。
実際には、最終的な join(chunks → documents) の結果に対してページネーションを行っており、
これは通常のドキュメント検索と同様に動作します。


おそらく、ネストドキュメントのインデックス化
を利用すれば join をなくすことも可能かもしれません。

まだ試していませんが、これを導入するには
チャンクが変更されるたびにドキュメント全体を再インデックスする必要があります。
(現在の手法ではその必要がありません。)
とはいえ、join を完全に排除できるなら検討する価値はあります。


最後に、Alessandro Benedetti がこの問題への解決策について議論している動画のリンクを再掲します:
🎥 https://youtu.be/9KJTbgtFWOU?si=YAUPNvfDhlX3NmJc&t=1450
この中で「Block Join」のアプローチが紹介されています。

— Guillaume

返信投稿者:ks-solruserml-bot (2025/10/24 18:56 投稿)

ネストドキュメントのインデックス化(https://solr.apache.org/guide/solr/latest/indexing-guide/indexing-nested-documents.html)を使えば、join をなくせるかもしれません。

それは可能ですし、Lucene には「親ドキュメント間で TopK を分散(diversify)」するための機能もあります。

もし Java コードを書くことに抵抗がなければ、次の JIRA を見てみると良いでしょう:

🔗 https://issues.apache.org/jira/browse/SOLR-17736

ここにリンクされている PR は Alessandro によるもので、
親子ドキュメント構造の中でベクトル化されたチャンクを扱うためのクエリパーサーの改良案 を提案しています。

添付されたパッチでは別のアプローチが提案されています(コメント欄に、なぜその方法の方が良いと考えるのか説明があります)。

このパッチのアプローチは、AbstractVectorQParserBase のサブクラスとして非常に簡単にリファクタリングでき、
Solr のカスタムプラグインとして読み込むことができます。
というのも、もともと私(Hoss)が内部用のカスタムプラグインとして作成し、
社内ユーザーが満足していたものを、パッチ提出時にリファクタリングしたものだからです。


まだ試していませんが、これを導入するにはチャンクが変更されるたびにドキュメント全体を再インデックスする必要があります。(現在の手法ではその必要がありません。)とはいえ、join を完全に排除できるなら検討する価値はあります。

これこそが 親子ドキュメント構造の最大の課題 です。
インデックスの変更を扱う際には、多くの注意点や制約があります。

🔗 https://solr.apache.org/guide/solr/latest/indexing-guide/indexing-nested-documents.html

— Hoss
http://www.lucidworks.com/

返信投稿者:ks-solruserml-bot (2025/10/24 18:56 投稿)

私たちは、ネストインデックス構造を使って、Solr 上で求めていたハイブリッド検索(Hybrid Search)を実現しました。


🔍 求めていたハイブリッド検索の内容

1 回の検索リクエスト内で、以下を「論理 OR」で組み合わせることができる検索です:

  • レキシカル検索(キーワードベースの検索)
  • ベクトル検索(ANN 検索)

つまり、ドキュメントは

  • 純粋にレキシカル検索で一致しても
  • 純粋にベクトル検索で一致しても
  • 両方で一致しても
    結果として返されます。

ドキュメントは非常に大きいため、チャンク分割(chunking)が必要です。
もちろん、重複する結果は許されません


🧱 ネストインデックスの構造

この構造では、

  • 親ドキュメント(parent) が元の大きなドキュメント、
  • 子ドキュメント(child) がベクトル化されたチャンク、
    という関係になっています。
<doc>
  <field name="id">doc-1</field>
  <field name="type_s">parent</field>
  <field name="full_doc_title">This is the title text</field>
  <field name="full_doc_body">This is the full body text</field>
  <field name="metadata1_s">some metadata</field>

  <doc>
    <field name="id">doc-1.1</field>
    <field name="parentDoc">doc-1</field>
    <field name="type_s">child</field>
    <field name="chunk_body">This is the chunk body text</field>
    <field name="chunkoffsets_s">8123-12123</field>
    <field name="vector_field"><![CDATA[-0.0037859276]]></field>
    <field name="vector_field"><![CDATA[-0.012503299]]></field>
    <field name="vector_field"><![CDATA[0.018080892]]></field>
    <field name="vector_field"><![CDATA[0.0024048693]]></field>
  </doc>

  <doc>
    <field name="id">doc-1.2</field>
    <field name="parentDoc">doc-1</field>
    <field name="type_s">child</field>
    <field name="chunk_body">This is the body text of another chunk</field>
    <field name="chunkoffsets_s">12200-12788</field>
    <field name="vector_field"><![CDATA[-0.0034859276]]></field>
    <field name="vector_field"><![CDATA[0.0024048693]]></field>
    <field name="vector_field"><![CDATA[-0.016224038]]></field>
    <field name="vector_field"><![CDATA[0.025224038]]></field>
  </doc>
</doc>

🧮 クエリ構造(親ドキュメントを対象)

このクエリでは:

  • 親ドキュメントを レキシカル検索 で探し、
  • 子ドキュメントを ANN 検索 で探します。
    結果には 完全な親ドキュメントのみ が含まれます。

レキシカル検索とベクトル検索の重みづけは
kwweightvectorweight で制御できます。
(クエリの性質に応じて動的に変更可能)

スコア正規化(score normalization)は含まれていません。
なぜなら、多数の結果を処理する際にコストが高く、
さらに正規化しても「両検索結果の適切な融合」を保証できないためです。

params: {
  uf:"* _query_",
  q:"{!bool filter=$hybridlogic must=$hybridscore}",
  hybridlogic:"{!bool should=$kwq should=$vectorq}",
  hybridscore:"{!func}sum(product($kwweight,$kwq),product($vectorweight,query($vectorq)))",
  kwq:"{!type=edismax qf="full_doc_body full_doc_title^3" v=$qq}",
  qq:"What is the income tax in New York?",
  vectorq:"{!parent which="type_s:parent" score=max v=$childq}",
  childq:"{!knn f=vector_field topK=10}[-0.0034859276,-0.028224038,0.0024048693,...]",
  kwweight:1,
  vectorweight:4
}

🧩 クエリ構造(チャンクのみを対象)

次の構成では、チャンク自体 を対象にレキシカル+ANN検索を行います。
結果には チャンクのみ が含まれます。
これは RAG(Retrieval-Augmented Generation)用途
つまり LLM に文脈として渡すチャンク検索に最適です。

params: {
  uf:"* _query_",
  q:"{!bool filter=$hybridlogic must=$hybridscore}",
  hybridlogic:"{!bool should=$kwq should=$vectorq}",
  hybridscore:"{!func}sum(product($kwweight,$kwq),product($vectorweight,query($vectorq)))",
  kwq:"{!type=edismax qf="chunk_body" v=$qq}",
  qq:"What is the income tax in New York?",
  vectorq:"{!knn f=vector_field topK=10}[-0.002503299,-0.001550957,0.018080892,...]",
  kwweight:1,
  vectorweight:4
}

🎤 この仕組みやその他の内容について、
Haystack EU 2025 カンファレンスで発表を行いました。
▶️ YouTube: https://www.youtube.com/watch?v=3CPa1MpnLlI


よろしく、
Tom

トピックへ返信するには、ログインが必要です。

KandaSearch

Copyright © 2006-2025 RONDHUIT Co, Ltd. All Rights Reserved.

投稿の削除

この投稿を削除します。よろしいですか?