注: 以下の翻訳の正確性は検証されていません。AIPを利用して英語版の原文から機械的に翻訳されたものです。
LLM は、業務固有のコンテキストに適用すると非常に強力です。特定のタスクに取り組む際、ほぼ必ず最初のステップとなるのは、LLM に与えるべき関連コンテキストを見つけることです。関連コンテキストを見つけることは、検索拡張生成システムの設計において最も難しい部分であることがよくあります。このセクションでは、コンテキスト検索の一般的なアプローチの概要を説明します。最適な解決策はデータの特性に大きく左右されるため、唯一の「最適なアプローチ」はありません。ただし、ここで説明する考え方は出発点として適しており、必要に応じて変更したり組み合わせたりできます。
新しい世代のモデルではコンテキスト長が拡大しているため、セマンティック検索をまったく使用せず、代わりにコンテキスト全体をプロンプトに渡せる場合があります。たとえば、128,000トークンのコンテキストウィンドウは、300ページ以上のテキストに相当します。アプリケーションのコンテキスト全体がこの制限内に収まる場合は、検索を使わずに始めることを推奨します。
基本的なセマンティック検索を作成するには、次の手順を実行します。
埋め込みの詳細については、Palantir 提供のモデルを使用してセマンティック検索ワークフローを作成する方法に関するドキュメントを参照してください。
検索の詳細については、Palantir 提供のモデルを使用してセマンティック検索ワークフローを作成する方法に関するドキュメントを参照してください。
AIP に関連付けられたツールが、文書コーパス内に答えがあるはずの質問に回答できない場合は、まず関連するコンテキストが検索され、プロンプトに渡されたかどうかを調べる必要があります。多くの場合、後続のステップで LLM が適切に回答するために必要な、最も関連性の高いコンテキストを検索ステップで見つけられていないことが原因です。
データやクエリの内容に応じて、検索を改善する方法は数多くあります。以下にその一部を示します。
最終的には、具体的なユースケースの要件と、どれだけの時間をかけられるかによって決まります。ユースケースによっては、最初のシンプルな設定で十分に機能する場合もあります。そうでない場合でも、HyDE とセマンティックチャンキングを追加するだけで、残りはそのままにしておけばよいかもしれません。基本的な実装から始めて、必要に応じて機能を追加することを推奨します。
検索性能を改善する方法の1つが、仮想文書埋め込みとも呼ばれる HyDE です。基本的な考え方は、クエリを直接埋め込みに変換するのではなく、まず LLM にその質問に回答する仮想的なチャンクを生成させ、そのチャンクを埋め込みに変換するというものです。直感的には、これによりクエリとその回答の非対称性を緩和できます。関連する学術論文「Precise Zero-Shot Dense Retrieval without Relevance Labels ↗」も参照できます。
これは、元の文書と章の情報を含めるために、チャンクが特定の形式に整えられている場合に、特に役立ちます。
例として、「動物との衝突にはどのように対処しますか?」という質問に対する適切な回答として、次のチャンクを考えてみます。
Claim Management - Motor: Animal Collision:
Animal collision claims are generally covered in type A, B, D policies. However,
exclusions apply...
まず、次のように LLM に指示して仮想的なチャンクを生成させます。
You are an insurance specialist assistant tasked to assist your colleagues with
finding relevant documents for their queries.
Given the following user query:
{query}
Produce a hypothetical paragraph answering it. Give your response in the following
format:
{Document Name}: {Chapter name}: {Section} > ...
{Content}
where {Document Name} is the name of the document that contains the passage,
{Chapter name} is the name of the chapter, {Section} is the name of the section
and {Content} is the content of the section.
このプロンプトに対して、次のようなレスポンスが返されます。
Animal Claims Management: General Terms:
Animal collision is commonly insured in fully comprehensive packages...
LLM のレスポンスは、実際の回答にすでに構造的に「近い」ため、その埋め込みも実際の回答を含むチャンクに近くなります。そのため、関数内のセマンティック検索は次のようになります。
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15async searchChunksByEmbedding(query: string, k: Integer): Promise<Chunk[]> { // 仮想チャンク用の完全なプロンプトを作成します const prompt = `...` // 仮想チャンクを生成します const hypothetical = await GPT_4o.createChatCompletion({messages: [{"role": "user", "contents": [{"text": prompt}]}]}) // 結果を埋め込みに変換します const embedding = await TextEmbeddingAda_002.createEmbeddings({inputs: [hypothetical]}) // 最近傍検索で埋め込みを使用します const docs = Objects.search() .chunks() .nearestNeighbors(chunk => chunk.vectorProperty.near(embedding, {kValue: k}) .orderByRelevance() .takeAsync(k) return docs
OpenAI Ada などの汎用埋め込みモデルは、多様なデータからなる大規模なコーパスで学習されています。特定の分野(製造業など)のコーパスを検索する必要があるユースケースでは、検索が期待されるほど機能しない場合があります。これは、汎用埋め込みモデルでは、特定の分野に埋め込み空間のごく一部しか使われていないためです。
このような場合に検索を改善する方法の1つとして、カスタムモデルのファインチューニングがあります。ただし、すぐに使えるはるかにシンプルな解決策は、必要に応じて LLM による前処理を加えた、ランキング付きキーワード検索を使用することです。
これは、Object Storage v2 が使用するインデックスに、クエリが与えられたときの「関連性」という概念がすでに備わっているためです。この関連性は、ほかのチャンクとの相対的なものであり、チャンクの分野固有のコンテキストが自動的に考慮されます。
Functions on Objects では、この関連性に基づいてオブジェクトクエリの結果を並べ替えられるため、次のような関数を記述できます。
Copied!1 2 3 4 5 6 7 8async searchChunksByKeywords(query: string, k: Integer): Promise<Chunk[]> { const chunks = Objects.search() .chunks() .filter(chunk => chunk.text.matchAnyToken(query)) .orderByRelevance() .takeAsync(k) return chunks }
ただし、この方法では、セマンティック検索の意味的な要素が捨象されます。たとえば、ユーザーが「鹿との衝突にはどのように対処しますか?」と質問し、その質問をそのまま関数に入力すると、動物との衝突全般について述べているチャンクは見つかりません。LLM を使うと、以下で説明するクエリ拡張によって、意味的な要素を再び取り入れられます。
クエリの前処理は、返される結果の関連性を最大化するための重要なステップです。基本的には、検索の種類に応じて、ユーザークエリから中核となる要素を絞り込みます。クエリの充実化と、もう1つの方法であるクエリ抽出を検討できます。
ユーザークエリとキーワード検索に渡す内容との間に LLM のステップを挟むことで、クエリを精選し、関連性を高められます。LLM に指示して、ストップワードや関連性のないつなぎの表現(「〜を探すのを手伝ってください」)を削除し、ほかの関連語や同義語を追加できます。
次のようなプロンプトを設定できます。
You are an insurance AI assistant tasked to help users find relevant documents.
To do so, you can use keyword-search in the company's internal database.
Given the following user query: {query}
Give a list of search terms that would find relevant results. Be sure to remove stop words,
and add synonyms and related terms to the most important terms.
Give your answer as a list of comma-separated values.
質問の例「動物との衝突にはどのように対処しますか?」に対して、LLM は次のようなレスポンスを返します。
deer, animal, collision, claims, wildlife, accidents, vehicle, damage, car, insurance, policy, coverage, comprehensive, reimbursement
これにより、ユーザーは「鹿との衝突」という表現が一切登場しない文書や、「動物」「野生動物」「事故」全般について述べている文書も見つけられるようになります。
コードでは次のようになります。
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13async searchChunksByAugmentedKeywords(query: string, k: Integer): Promise<Chunk[]> { // クエリ拡張用の完全なプロンプトを作成します const prompt = `...` const augmentedQuery = await GPT_4o.createChatCompletion({messages: [{"role": "user", "contents": [{"text": prompt}]}]}) const chunks = Objects.search() .chunks() .filter(chunk => chunk.text.matchAnyToken(augmentedQuery)) .orderByRelevance() .takeAsync(k) return chunks }
クエリ拡張は、関連性順のキーワード検索では有効です。ただし、セマンティック検索では、ユーザークエリから中心となる要求を抽出する必要があります。その過程で、ストップワードなど意味的な情報を持たない余分な語句を削除し、必要に応じてクエリ内の語句の見出し語化や語幹抽出 ↗を行います。
そのためには、クエリ抽出を行い、質問をユーザーの中心的な要求に変換します。
プロンプトの例を以下に示します。
You are preparing a user-given query in order to perform a semantic search.
Extract the key user actions from the given query, removing unnecessary stop words
in the process.
Given the following user query: {query}
Return concatenated actions delimited by full stops.
質問の例「動物との衝突にはどのように対処しますか?」に対して、LLM は次のように返します。
Deal with animal collisions.
このレスポンスによってクエリの意味的な内容が最大限に引き出され、セマンティック検索を実行した際に、下流でより強く一致する可能性が高まります。上記の例は、ストップワードの削除だけでも対応できます。
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14async searchChunksByExtractedQuery(query: string, k: Integer): Promise<Chunk[]> { // クエリ拡張用の完全なプロンプトを作成します const prompt = `...` const augmentedQuery = await GPT_4o.createChatCompletion({messages: [{"role": "user", "contents": [{"text": prompt}]}]}) const embedding = await TextEmbeddingAda_002.createEmbeddings({inputs: [augmentedQuery]}) const chunks = Objects.search() .chunks() .nearestNeighbors(obj =>obj.embeddingProperty .near(embedding, { kValue: k })) .allAsync() return chunks }
逆順位融合(RRF)は、複数の種類の検索結果を1つのリストにまとめるシンプルなアルゴリズムです。基本的には、あるリスト内での文書の順位が高いほど、その文書に高いスコアを与えます。合計スコアは、各リストのスコアの合計です。
k は正則化パラメーターとして機能します。k が大きいほど、文書がリスト内のどこに現れるかは重要ではなくなり、リストに現れること自体が重要になります。
`RRFscore(d ∈ D) = Σ [1 / (k + r(d))]`
`# k is a constant that helps to balance between high and low ranking.`
`# r(d)is the rank/position of the document`
Copied!1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34public combineResultsWithRRF(vectorSearchResults: Chunk[], keywordSearchResults: Chunk[], k: Integer = 60): Chunk[] { // RRF スコアリング関数を定義します const RRF = (r: number, k: number) => 1 / (r + k); // 各チャンクのスコアを記録するマップを初期化します // 後続の処理では、各 Chunk に文字列の主キープロパティ "id" があると仮定する点に注意してください const resultMap: Map<string, {chunk: Chunk, score: number}> = new Map(); const combinedResults: Chunk[] = []; const searchResultsList = [vectorSearchResults, keywordSearchResults]; searchResultsList.forEach((searchResults) => { searchResults.forEach((chunk, rank) => { // リスト内の各 Chunk のスコアを計算し、 // マップ内のその Chunk の合計スコアに追加します const rrfScore = RRF(rank, k); const chunkData = resultMap.get(chunk.id) || {chunk: chunk, score: 0}; chunkData.score += rrfScore; resultMap.set(chunk.id, chunkData); }); }); // すべての Chunk をリストに格納します resultMap.forEach((chunkData) => { combinedResults.push(chunkData.chunk); }); // resultMap 内のスコアに基づいて降順に並べ替えます combinedResults.sort((a, b) => resultMap.get(b.id).score - resultMap.get(a.id).score); return combinedResults; }
ハイブリッド検索の完全な実装は、次のようになります。
Copied!1 2 3 4 5 6 7 8 9 10 11async hybridSearch(query: string, k: Integer, n1: Integer, n2: Integer): Promise<Chunk[]> { // キーワード検索とベクトル検索を並列で開始します const keywordSearchResultsPromise = searchChunksByKeywords(query, n1) const vectorSearchResultsPromise = searchChunksByEmbedding(query, n2) const [keywordSearchResults, vectorSearchResults] = await Promise.all([keywordSearchResultsPromise, vectorSearchResultsPromise]) const rerankedResults = combineResultsWithRRF(vectorSearchResults, keywordSearchResults) return rerankedResults.slice(0, k)