🤯

完全未経験がDX職で奮闘する話① RAGとAzureでのつまづき

に公開

はじめに

こんにちは。かい(@kaithon_dx)です。
プライム上場企業でDX担当をしている28歳・完全未経験の派遣社員です。

前回の記事(完全未経験がDX職で奮闘する話⓪)では、キャリアと全体像の話を書きました。
今回は技術的に最もつまずいたことに絞って書いていきます。

結論からお伝えします。

Azure AI Foundry周りの情報は、GPT-5.2にもClaude Opus 4.5にもGeminiにも聞いても、軒並み正確に答えてもらえなかった。

Web検索をONにしても、システムプロンプトに「最新のAzure公式ドキュメントを参照せよ」と制約をかけてもダメでした。これはAzure AI Foundryを触った人間なら共感してもらえると思います。

この記事では、私がぶつかった壁を順番に整理し、最後にAzure AI FoundryでデプロイしたモデルをPythonから呼び出す最小構成のコードを紹介します。

初期構想:何を作ろうとしていたか

社内ドキュメント(PDF・PNG)を検索・要約できるRAGシステムです。大きく2つのサブシステムで構成しています。

サブシステム①:OCRパイプライン
複雑な数式や図表を含むPDF・PNGをOCR → Markdown化する処理。Azure AI Foundryからデプロイしたモデル(Claude Opus 4.6等)を使います。

  • 数式をLaTeXで正確に表現すること
  • 結合セルの多い表を単一セルに分解してテーブルを作成すること

このあたりの実現が非常に大変でした。
※こちらについての詳細は次回の記事でご紹介します。

サブシステム②:チャットUI + 検索基盤
チャットUIの裏側で、OCR済みMarkdownをAzure AI Searchでインデックス化し、セマンティック検索+キーワード検索のハイブリッド検索を行う。頭脳としてGPT-5.2やClaude Opus 4.5を接続します。

構成自体はシンプルです。

しかし、Azureのリソースを立ち上げる段階で下記の4つの壁に阻まれ、つまずきました。

壁①:リージョンによるモデル制限

Azure AI Foundryでモデルをデプロイしようとして、まず判明したのがこれです。

リージョンによってデプロイできるモデルが違う。

結局、GPT-5.2もClaude Opus 4.5も使うにはEast US 2にAI Foundryを構える必要がありました。Japan Eastではダメだったんです。エンタープライズ環境ということもあり「データは日本リージョンに」という形で進めていたのに、結局East US2でのデプロイが必須でした。

壁②:Azure用語の多様性地獄

Azureは1つの概念に対して複数の名前が存在することが非常に多い。

例えば「Azure AI Search」。Azure Portalでリソースを作ろうとすると、**「Search Service(検索サービス)」**という名前で出てきます。初心者だった自分は「あれ、違うもの?」となりました。

さらにAI Foundry自体が「Azure AI Studio → Azure AI Foundry → Microsoft Foundry」と短期間で名前が変わっています。検索しても古い名称の情報がヒットするため、今どの名前が正しいのかすら判断しづらい

この「名前の多様性」が、後述する「LLMが正確に答えられない」問題の根本原因の1つです。学習データにある旧名称と現在のUI上の名称が一致しないため、モデルが混乱するんじゃないかなと推測しています。

壁③:閉域ネットワーク構成との格闘

エンタープライズ環境なので、Azureリソースをパブリックインターネットに露出させることはできません。ここからVNet(仮想ネットワーク)、Private Endpoint、VPN、DNSリゾルバー、RBACといった、未経験者には完全に未知の領域だったので、理解と実装に非常に苦労しました。

※LLMはこういった「すでに確立された知識」を教えてくれるのが得意なので、ここは大いに助けられました。

最初のイメージではAI Foundryハブを作ってManaged VNetで閉域構成を組む予定でしたが、用意いただいたサブスクリプションでMachine Learningリソースプロバイダーの権限がなく、申請し承認されるまでのタイムラグを待つのならと方向転換。

クラシック構成でVNet → サブネット → Private Endpoint → P2S VPN → プライベートDNSゾーン → DNSリゾルバー → RBAC設定…と1つずつ積み上げて、接続自体は成功させました

※ RBAC に関しては、「このロールを付ければ良いよ」というおすすめ機能のようなものがなく、膨大なリストから自力で検索して見つけ出す必要があります。さらに Azure 環境を日本語に設定していると、ロール名の一部が日本語化され、一部は英語のままという羅列になっており、英語と日本語の両方で検索を駆使しないと見つけられません。

そして、かくかくしかじかを上長に報告したところ、こう言われました。

「その構成で、情シスじゃない自分たちが運用・保証を続けられるか?」

というのも、私の所属が情報システム部ではなく、事業部付けのDX担当だからです。ネットワーク構成の保証は事業部の責任範囲を超えています。そこで、閉域構成の知見は回収した上で、Azure上に全てを構築してWebでデプロイする方針自体を見直すことになりました。

方向転換:Azure = APIデプロイ基盤に割り切る

結局、Azureの利用目的を「モデルのAPIデプロイに絞る判断をしました。

UI(フロントエンド)はOpenWebUI(OSSのチャットUI)を採用し、AzureでデプロイしたモデルにはAPI経由で接続する構成です。ちなみにAI Foundryのプレイグラウンドから簡易にデプロイできるWebアプリUIも試しましたが、カスタマイズ性が低く要件を満たせませんでした。

この方向転換により開発速度は一気に上がりました。

※OpenWebUIの構成については別の記事で詳しく書きます。

壁④(本題):LLMに聞いても正確に答えてもらえない

ここからが本題です。

Azureでデプロイしたモデルを、自作のPythonコードからAPI経由で呼び出す。やりたいことはシンプルです。しかしここでLLMたちが軒並み間違った情報を返してきました

何が間違っていたか

1. SDKの指定が古い / 存在しない
LLMが存在しないインポートパスを平然と返してくる。Web検索をONにしても、旧バージョンのドキュメントを拾って古いコードを生成する。
Azure OpenAI(標準デプロイ)とサーバーレスAPI(マーケットプレイスモデル)では、使うSDK自体が違います。でも、LLMはこの2つを混ぜた回答を返してくることがありました。

2. エンドポイントURLの形式が間違っている
Azure OpenAI(標準デプロイ)とサーバーレスAPI(マーケットプレイスモデル)ではエンドポイントURLの形式が全く異なる。LLMはこの区別ができず、混在した情報を返す。

さらに、Azure の仕様としてややこしいのが、以下の二つのパターンがある点です:

  • Foundry リソース自体のキーとエンドポイントを利用できる場合
  • Foundry ポータルから各モデルのキーとターゲット URI を取得する必要がある場合

3. API Versionの不一致
Azure OpenAIのAPIはapi_versionパラメータが必須ですが、LLMがその必要性すら返してくれなかったり、「202601」のような架空のバージョンを回答したりすることがありました。

実際には「2024-12-10」などの正しいバージョンを指定しないと、Azure API を通して GPT-5.2-chat などのモデルを利用することはできないため、ここにも非常に苦労しました。

なぜLLMは正確に答えられないのか

  1. Azure自体が過渡期の真っ只中: AI Studio → AI Foundry → Microsoft Foundryと名前が変わり、SDKも頻繁に更新されるため、カットオフ以降の情報収集をもってしてもハルシネーションが起こりうる
  2. 名前の多義性: 同じ概念に複数の名前があるため、モデルが文脈を正しく解釈できない
  3. 実例記事がWebに少ない: エンタープライズ環境での構成情報は公開されにくく、学習データに入りにくい

実動コード:最小構成

ここからが、LLMに何度聞いても辿り着けず、公式ドキュメントとエラーログの往復でようやく見つけた実際に動いたコードです。

① Azure OpenAIでGPT-5.2-chatを呼び出す

GPT系モデルをAzure OpenAIリソースにデプロイした場合、openaiライブラリのAzureOpenAIクラスを使います。

pip install openai
import os
from openai import AzureOpenAI

client = AzureOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    # 例: "https://my-resource-name.openai.azure.com/"
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version="2025-01-01-preview",
    # ↑ 重要。LLMが返すバージョンは高確率で古い。
    # 最新は公式ドキュメントで確認すること。
)

response = client.chat.completions.create(
    model="gpt-52-chat",
    # ↑ 「モデル名」ではなく「デプロイ名」を指定する。
    # AI Foundryでデプロイ時に自分がつけた名前。
    messages=[
        {"role": "system", "content": "あなたはアシスタントです。"},
        {"role": "user", "content": "Azureのリージョンについて教えてください。"},
    ],
    temperature=0.0,
)

print(response.choices[0].message.content)

ハマりポイント:

  • modelにはモデル名(gpt-5.2)ではなく、自分がつけたデプロイ名を入れる
  • api_version公式のAPIバージョン一覧で最新を確認する
  • azure_endpointの末尾に/がないとエラーになるケースがある

② AI FoundryでデプロイしたClaude Opus 4.6を呼び出す(サーバーレスAPI)

AI FoundryでデプロイしたClaudeOpus4.6を呼び出すには、Anthropic互換 Messages APIでが答えです。

ポイントは2つだけ:

  1. FOUNDRY_INFERENCE_ENDPOINT(または AZURE_AI_INFERENCE_ENDPOINT)が resource root でも、コード側で /anthropic/v1/messages に正規化する
  2. FOUNDRY_INFERENCE_MODEL(または AZURE_AI_INFERENCE_MODEL)が未設定なら claude-opus-4-6 を使う

.env(例)

FOUNDRY_INFERENCE_ENDPOINT=https://<resource-name>.services.ai.azure.com
FOUNDRY_INFERENCE_KEY=<your-key>
# FOUNDRY_INFERENCE_MODEL=claude-opus-4-6  # 任意(未指定なら既定)

【最小Python(標準ライブラリのみ)】

import json
import os
import urllib.request
from urllib.parse import urlparse

ENV_ENDPOINT_KEYS = ("AZURE_AI_INFERENCE_ENDPOINT", "FOUNDRY_INFERENCE_ENDPOINT")
ENV_KEY_KEYS = ("AZURE_AI_INFERENCE_KEY", "FOUNDRY_INFERENCE_KEY")
ENV_MODEL_KEYS = ("AZURE_AI_INFERENCE_MODEL", "FOUNDRY_INFERENCE_MODEL")

DEFAULT_MODEL = "claude-opus-4-6"
DEFAULT_MAX_TOKENS = 2048
DEFAULT_TEMPERATURE = 0.0


def _get_env(*keys: str, default: str = "") -> str:
    for key in keys:
        value = os.getenv(key)
        if value is not None:
            value = value.strip()
            if value != "":
                return value
    return default


def load_dotenv(path: str = ".env") -> None:
    if not os.path.exists(path):
        return
    with open(path, "r", encoding="utf-8") as file:
        for line in file:
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            if "=" not in stripped:
                continue
            key, value = stripped.split("=", 1)
            key = key.strip()
            value = value.strip().strip("\"").strip("'")
            os.environ[key] = value


def _is_resource_root_endpoint(endpoint: str) -> bool:
    try:
        parsed = urlparse(endpoint)
    except Exception:
        return False
    path = (parsed.path or "").strip("/")
    return parsed.netloc.endswith(".services.ai.azure.com") and path == ""


def _is_anthropic_messages_endpoint(endpoint: str) -> bool:
    try:
        parsed = urlparse(endpoint)
    except Exception:
        return False
    path = (parsed.path or "").rstrip("/").lower()
    return path.endswith("/anthropic/v1/messages")


def _normalize_anthropic_messages_endpoint(endpoint: str) -> str:
    parsed = urlparse(endpoint)
    path = parsed.path or ""
    stripped = path.rstrip("/")
    lower = stripped.lower()
    if lower.endswith("/anthropic/v1/messages"):
        return endpoint.rstrip("/")
    if lower.endswith("/anthropic"):
        return endpoint.rstrip("/") + "/v1/messages"
    if _is_resource_root_endpoint(endpoint):
        return endpoint.rstrip("/") + "/anthropic/v1/messages"
    return endpoint.rstrip("/")


# ---- 実行 ----
load_dotenv(".env")

endpoint = _get_env(*ENV_ENDPOINT_KEYS)
key = _get_env(*ENV_KEY_KEYS)
model = _get_env(*ENV_MODEL_KEYS)

if not endpoint or not key:
    raise RuntimeError(
        "AZURE_AI_INFERENCE_ENDPOINT と AZURE_AI_INFERENCE_KEY(または FOUNDRY_*)を .env に設定してください。"
    )

normalized = _normalize_anthropic_messages_endpoint(endpoint)
if not _is_anthropic_messages_endpoint(normalized):
    raise RuntimeError(
        "このスクリプトは画像入力を使うため、Anthropic互換 Messages エンドポイントが必要です。\n"
        "例: https://<resource>.services.ai.azure.com/anthropic/v1/messages\n"
        "現在: " + normalized
    )

if not model:
    model = DEFAULT_MODEL

max_tokens = int(_get_env("AZURE_AI_INFERENCE_MAX_TOKENS", default=str(DEFAULT_MAX_TOKENS)))
temperature = float(_get_env("AZURE_AI_INFERENCE_TEMPERATURE", default=str(DEFAULT_TEMPERATURE)))

payload = {
    "model": model,
    "max_tokens": max_tokens,
    "temperature": temperature,
    "messages": [{"role": "user", "content": "疎通テスト:Hello Claude Opus 4.6 on Foundry"}],
}

req = urllib.request.Request(
    normalized,
    data=json.dumps(payload).encode("utf-8"),
    headers={
        "content-type": "application/json",
        "api-key": key,
    },
    method="POST",
)

with urllib.request.urlopen(req, timeout=120) as resp:
    print(resp.read().decode("utf-8"))

実行:

python main.py

おわりに

Azure AI Foundryは強力な基盤ですが、2026年2月時点では過渡期の真っ只中にあり、LLMがカバーしきれない領域がまだ多く残っています。

「LLMに聞けばわかる」時代に、LLMに聞いてもわからないことがある
この体験は、逆説的に「人間が手を動かして自分で確かめて検証する価値」を教えてくれた気がします。

同じようにAzure AI Foundryで格闘している方の参考になれば幸いです。

次回は、Claude Opus 4.6を使ったOCRパイプライン(技術書PDF → Markdown変換)の実装について書きます。その次はOpenWebUIの構成について書く予定です。


Discussion