0Pricing
AI Engineering Academy · レッスン

クエリ・検索・生成

ユーザーの質問をembeddingし、上位k件のチャンクを取得し、拡張プロンプトを整形してLLMを呼び出し、引用付きの回答を返すクエリパイプラインを作成します。

「クエリ・検索・生成」はCoddyKit上の無料AI Engineering Academyレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはAI Engineering Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 AI Engineering Academyコースには全4レッスンが含まれています。

クエリパイプライン:エンドツーエンド

クエリパイプラインはRAGのオンライン側です。つまり、ユーザーが質問したときにリアルタイムで実行されるコードです。インデックス作成時に構築したすべてのコンポーネント、つまり埋め込みモデル、ベクトルストア、プロンプトテンプレート、LLMを接続します。適切に実装されたクエリパイプラインは、ほとんどのワークロードで500ms未満に処理を完了し、根拠と引用を含む回答を生成します。このレッスンでは、各ステップをゼロから構築します。

ステップ1:ユーザーのクエリを埋め込む

最初のステップは、インデックス作成時に使用した同じモデルを使って、ユーザーの自然言語による質問をベクトル埋め込みに変換することです。この埋め込みには質問の意味がエンコードされ、ベクトルストア内のドキュメントチャンクの埋め込みと比較されます。このステップは高速に保ってください。text-embedding-3-smallのような軽量モデルを使用し、同一のクエリが繰り返された場合に備えて埋め込みをキャッシュします。

from openai import OpenAI

client = OpenAI()

def embed_query(question: str) -> list:
    response = client.embeddings.create(
        model='text-embedding-3-small',
        input=[question]
    )
    return response.data[0].embedding

user_question = 'What is our remote work policy?'
query_vector = embed_query(user_question)
print(f'Query embedded: {len(query_vector)}-dim vector')

ステップ2:上位K件のチャンクを取得する

クエリベクトルをベクトルストアに送信し、意味的に最も類似したK件のチャンクを検索します。返される結果はコサイン類似度スコア(通常は0.0~1.0で、値が大きいほど良好)によって順位付けされます。理想的なKの値は、コンテキストの豊富さとコンテキストウィンドウのコストのバランスで決まります。K=5は一般的な開始値です。ここでメタデータフィルターを適用し、特定のdepartment、ドキュメントタイプ、日付範囲に検索対象を限定することもできます。

def retrieve_chunks(query_vector, index, top_k=5, filters=None):
    query_params = {
        'vector': query_vector,
        'top_k': top_k,
        'include_metadata': True
    }
    if filters:
        query_params['filter'] = filters

    results = index.query(**query_params)

    chunks = []
    for match in results.matches:
        chunks.append({
            'score': match.score,
            'text': match.metadata['text'],
            'source': match.metadata.get('source', ''),
            'page': match.metadata.get('page', '')
        })
    return chunks

ステップ3:スコアのしきい値でフィルタリングする

取得されたすべてのチャンクが本当に関連しているとは限りません。クエリがインデックスのカバレッジ外にある場合、類似度スコアが低くても上位K件に入ることがあります。最低スコアのしきい値を適用して、信頼度の低い結果を除外してください。取得されたすべてのチャンクがしきい値を下回る場合は、無関係なコンテキストをLLMに送るのではなく、「情報が見つかりませんでした」という応答を返します。そうしないと、丁寧に回答を控える場合よりも悪い回答が生成される可能性があります。

MIN_SCORE_THRESHOLD = 0.75

def filter_by_score(chunks, threshold=MIN_SCORE_THRESHOLD):
    relevant = [c for c in chunks if c['score'] >= threshold]
    if not relevant:
        print(f'No chunks above threshold {threshold}. Scores: {[c["score"] for c in chunks]}')
    return relevant

retrieved = retrieve_chunks(query_vector, index, top_k=5)
filtered = filter_by_score(retrieved)
if not filtered:
    print('Responding: no relevant information found')

ステップ4:コンテキストブロックを整形する

取得したチャンクを、LLMが読み取る構造化されたコンテキストブロックにまとめます。モデルが正確に引用できるよう、各チャンクに出典を示すラベルを付けます。明確にするため、チャンクの間には区切り文字を追加します。コンテキスト全体をトークン予算内に収めてください。tiktokenでトークン数を数え、上限を超えた場合はスコアの低いチャンクを切り捨てるか削除します。コンテキストブロックは、システム指示とユーザーの質問の間にあるプロンプトへ挿入します。

def format_context(chunks):
    parts = []
    for i, chunk in enumerate(chunks, start=1):
        source_label = chunk['source']
        if chunk.get('page'):
            source_label += f", page {chunk['page']}"
        parts.append(
            f'[Document {i} | Source: {source_label}]\n{chunk["text"]}'
        )
    return '\n\n---\n\n'.join(parts)

context = format_context(filtered)
print(f'Context block: {len(context)} characters')

ステップ5:拡張プロンプトを構築する

コンテキストブロック、システム指示、ユーザーの質問を最終プロンプトに組み合わせます。システムメッセージでは、モデルに提供されたコンテキストだけを使用し、出典を引用するよう指示します。ユーザーメッセージには、整形済みのコンテキストに続けて質問を含めます。このように明確に分離することで、モデルがコンテキストの内容と質問を混同するのを防ぎ、取得したデータとユーザー入力の境界を明確にできます。

def build_prompt(question, context):
    system_message = (
        'You are a helpful assistant. Answer the question using ONLY '
        'the information in the provided documents. '
        'Cite the document number(s) used, like [Doc 1]. '
        'If the documents do not contain the answer, say so.'
    )
    user_message = (
        f'Documents:\n\n{context}\n\n'
        f'Question: {question}'
    )
    return system_message, user_message

ステップ6:LLMを呼び出して回答を取得する

Chat Completions APIを使用して、組み立てたプロンプトをLLMに送信します。事実に基づくQ&Aでは、低いtemperature(0.0~0.3)を使用すると、一貫性のある根拠に基づく回答を得られます。temperatureを高くすると、より創造的な応答になりますが、コンテキストにない情報をモデルが追加するリスクが高まります。応答を解析し、回答テキストと取得した出典の両方を返して、アプリケーションからユーザーに引用を表示できるようにします。

def generate_answer(question, context, sources):
    system_msg, user_msg = build_prompt(question, context)

    response = client.chat.completions.create(
        model='gpt-4o',
        temperature=0.1,   # low temperature for factual Q&A
        messages=[
            {'role': 'system', 'content': system_msg},
            {'role': 'user', 'content': user_msg}
        ]
    )
    answer = response.choices[0].message.content
    return {
        'answer': answer,
        'sources': sources,
        'tokens_used': response.usage.total_tokens
    }

すべてを組み合わせる

完全なクエリパイプラインでは、これらのステップを順番に実行します。各ステップは独立してテストできる純粋関数であり、データはあるステップから次のステップへと明確に流れます。各ステップにログ出力を追加すると、パイプラインの可観測性が高まります。どのチャンクが取得されたか、どのスコアだったか、コンテキストがどのように構成されたか、何個のトークンが使用されたかを正確に確認できます。この可視性は、検索品質のデバッグと改善に不可欠です。

def answer_question(user_question, vector_index):
    # Step 1: Embed query
    q_vector = embed_query(user_question)

    # Step 2: Retrieve
    chunks = retrieve_chunks(q_vector, vector_index, top_k=5)

    # Step 3: Filter low-confidence matches
    chunks = filter_by_score(chunks, threshold=0.70)
    if not chunks:
        return {'answer': 'I do not have information about that topic.', 'sources': []}

    # Step 4 & 5: Format and build prompt
    context = format_context(chunks)
    sources = [c['source'] for c in chunks]

    # Step 6: Generate
    return generate_answer(user_question, context, sources)

レイテンシーの最適化

クエリパイプラインには、I/Oバウンドなステップが2つあります。埋め込みの呼び出しとLLMの呼び出しです。不要な待ち時間を発生させずに実行してください。埋め込みの呼び出しは高速(<100ms)ですが、LLMの呼び出しは低速です(500ms~3秒)。体感レイテンシーを短縮するには、LLMの応答をストリーミングし、完全な応答を待つのではなく、生成されたトークンを順次表示します。同一のクエリが繰り返された場合は、その埋め込みをキャッシュして重複したAPI呼び出しを避けてください。

async def answer_question_streaming(question, index):
    q_vector = embed_query(question)
    chunks = retrieve_chunks(q_vector, index, top_k=5)
    chunks = filter_by_score(chunks)
    if not chunks:
        yield 'I do not have information about that topic.'
        return
    context = format_context(chunks)
    system_msg, user_msg = build_prompt(question, context)

    stream = await client.chat.completions.create(
        model='gpt-4o',
        stream=True,
        messages=[
            {'role': 'system', 'content': system_msg},
            {'role': 'user', 'content': user_msg}
        ]
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta.content or ''
        yield delta

可観測性のためのロギング

本番環境のRAGパイプラインには、検索に失敗した場合やLLMが悪い回答を返した場合に原因を診断できる構造化ログが必要です。すべてのリクエストについて、クエリ、取得したチャンクIDとスコア、コンテキストのトークン数、回答、レイテンシーを記録します。これらのログは、データベースまたは可観測性プラットフォームに保存してください。ユーザーから悪い回答の報告を受けたときは、同じクエリを正確に再実行し、どのチャンクが取得されたか、なぜ不十分だったかを調査できます。

import time
import logging
import json

def answer_question_with_logging(question, index):
    start = time.time()
    q_vector = embed_query(question)
    chunks = retrieve_chunks(q_vector, index, top_k=5)
    chunks = filter_by_score(chunks)
    context = format_context(chunks)
    result = generate_answer(question, context, [c['source'] for c in chunks])
    latency_ms = (time.time() - start) * 1000
    log_entry = {
        'question': question,
        'num_chunks_retrieved': len(chunks),
        'chunk_scores': [c['score'] for c in chunks],
        'tokens_used': result.get('tokens_used'),
        'latency_ms': round(latency_ms)
    }
    logging.info(json.dumps(log_entry))
    return result

クエリ埋め込みのキャッシュ

FAQボットのように、ユーザーが同じ質問を頻繁に尋ねるアプリケーションでは、繰り返し発生するクエリやほぼ同一のクエリに対して、クエリの埋め込みをキャッシュすることが、簡単で効果の大きい最適化になります。クエリ文字列をハッシュ化し、対応する埋め込みがRedisキャッシュにあるか確認します。キャッシュミスの場合にのみ、埋め込みAPIを呼び出します。本番環境のFAQやサポートチャットボットでは、埋め込みキャッシュのヒット率が30~60%になることが一般的です。これにより、大幅なAPIコストを削減し、キャッシュがヒットしたクエリごとにレイテンシーを50~100ms短縮できます。

import hashlib
import json
import redis

r = redis.Redis(host='localhost', port=6379)
EMBED_CACHE_TTL = 86400  # 24 hours

def embed_query_cached(question):
    cache_key = 'embed:' + hashlib.sha256(question.encode()).hexdigest()
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)  # cache hit
    # Cache miss: call the API
    vector = embed_query(question)
    r.setex(cache_key, EMBED_CACHE_TTL, json.dumps(vector))
    return vector

クイックチェック

このレッスンで学んだAIエンジニアリングの概念について理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、6段階のクエリパイプライン(クエリの埋め込み、チャンクの取得、スコアによるフィルタリング、コンテキストの整形、プロンプトの構築、回答の生成)、インデックスのカバレッジ外にあるクエリに対処するためのスコアしきい値によるフィルタリング、そして応答のストリーミング、構造化ロギング、レイテンシーの最適化などの本番環境向けの機能強化について学びました。次は、完成したRAGシステムが実際に正しく動作しているかを評価する方法を学びます。

よくある質問

「クエリ・検索・生成」レッスンは無料ですか?

はい。「クエリ・検索・生成」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、AI Engineering Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 AI Engineering Academyコースには全4レッスンが含まれています。

「クエリ・検索・生成」で何を学びますか?

ユーザーの質問をembeddingし、上位k件のチャンクを取得し、拡張プロンプトを整形してLLMを呼び出し、引用付きの回答を返すクエリパイプラインを作成します。 ブラウザで直接実行するハンズオンコードでAI Engineering Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

AI Engineering Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのAI Engineering Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「クエリ・検索・生成」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このAI Engineering Academyレッスンでコードを書いて実行できますか?

はい。すべてのAI Engineering Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. ドキュメントの読み込みとテキスト抽出
  2. チャンク化戦略:固定長・文単位・再帰的分割
  3. インデックス作成:チャンクのEmbeddingと保存
  4. クエリ・検索・生成
← AI Engineering Academyに戻る