自分の文書に答えるRAGを、Azure OpenAI Service と Azure AI Search で組みました。
この記事は2本のうちの後編です。前編の はじめてのAzure(1/2) では、無料アカウントの作り方とリソース1の消し方を書いています。
動いたあとに「現職はどこですか」と聞いたら、検索が 0件 を返しました。文書には勤め先のことがはっきり書いてあるのにです。
この記事は、その0件の原因を追って、キーワード検索とベクトル検索を同じ質問で並べて測った記録です。無料アカウントの作成から削除まで1日で通しました。
この記事を読むと明日できること
自分のRAGで、キーワード検索とベクトル検索を並べて測って、どちらがどの質問に当たるかを数字で見られます。
想定している読者は、RAGを組んだことはあるが、検索の当たり外れを測ったことがない人です。
作ったもの
Pythonのファイルを1本実行すると、自分が入れておいた文書から答えを探して、日本語で返してきます。
$ MODE=hybrid python rag.py "現職はどこですか"
=== 質問: 現職はどこですか (MODE=hybrid)===
--- ① 検索で見つかった文書: 3件 ---
id=2 勤務先の履歴 (スコア 0.0167)
id=1 職務要約 (スコア 0.0164)
id=3 2026年からの個人開発 (スコア 0.0161)
--- ③ 答え ---
現職は〇〇株式会社です。[2]
入れた文書は、自分の職務経歴を3件に分けたものです。答えの [2] は出典の番号で、どの文書を根拠にしたかを表します。
中で起きていることは3つだけです。
使った部品は2つだけです。
| 役割 | 使ったもの |
|---|---|
| 文書を置いて探す | Azure AI Search(Freeレベル) |
| 答えの文章を作る | Azure OpenAI Service の gpt-4.1-mini
|
1. 文書を入れる箱を作る
Azure AI Search の「インデックス」2に文書を入れます。
この言葉には注意が要ります。Oracleを長く使ってきた人ほど引っかかるところです。
| 意味 | |
|---|---|
| Oracleのインデックス | 索引。検索を速くするためにテーブルとは別に作るもの |
| Azure AI Searchのインデックス | テーブルそのもの |
対応を並べるとこうなります。
| Oracle | Azure AI Search |
|---|---|
CREATE TABLE |
インデックスを作る |
| テーブル | インデックス |
| 行 | 文書(ドキュメント) |
| 列 | フィールド |
| 主キー |
key=True のフィールド |
INSERT |
upload_documents() |
SELECT ... WHERE |
search() |
違うところも3つあります。
-
ALTER TABLEに当たるものが無い(列を足すならインデックスごと作り直し) -
JOINが無い - 全文検索と関連度スコアが最初から入っている
作るコードはこれだけです。
from azure.core.credentials import AzureKeyCredential
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import (
SearchableField, SearchIndex, SimpleField, SearchFieldDataType,
)
client = SearchIndexClient(
endpoint=os.environ["AZURE_SEARCH_ENDPOINT"],
credential=AzureKeyCredential(os.environ["AZURE_SEARCH_API_KEY"]),
)
fields = [
SimpleField(name="id", type=SearchFieldDataType.String, key=True),
SearchableField(name="title", type=SearchFieldDataType.String, analyzer_name="ja.lucene"),
SearchableField(name="content", type=SearchFieldDataType.String, analyzer_name="ja.lucene"),
]
index = SearchIndex(name="kb-index", fields=fields)
client.create_or_update_index(index)
ja.lucene を忘れると日本語が検索できない
analyzer_name="ja.lucene" の指定が要ります。
書かないと既定の standard.lucene が使われます。英語向けなので、日本語が単語に分かれません。
2. 文書を3件入れる
JSONを渡すだけです。
from azure.search.documents import SearchClient
client = SearchClient(
endpoint=os.environ["AZURE_SEARCH_ENDPOINT"],
index_name="kb-index",
credential=AzureKeyCredential(os.environ["AZURE_SEARCH_API_KEY"]),
)
results = client.upload_documents(documents=docs)
for r in results:
print(f"id={r.key} 成功={r.succeeded} ステータス={r.status_code}")
id=1 成功=True ステータス=201
id=2 成功=True ステータス=201
id=3 成功=True ステータス=201
--- インデックスの中の件数: 0 ---
3件とも 201(作成成功)なのに、件数が 0 です。
これは反映待ちでした。2秒後に数え直したら3件になりました。ここで「入っていない」と判断して入れ直すと、原因を取り違えます。
3. 検索だけしてみる——ここで0件が出た
GPTを通さず、検索だけを試しました。
results = client.search(search_text=query, top=3)
for r in results:
print(f"id={r['id']} スコア={r['@search.score']:.4f} {r['title']}")
結果です。
| 検索した言葉 | 当たった文書 | スコア |
|---|---|---|
在籍している会社 |
id=2 | 2.7056 |
いま勤めている会社はどこですか |
id=2 | 1.6219 |
今の勤め先 |
id=2 | 1.0837 |
現職はどこですか |
0件 | — |
言い方としては一番自然な「現職はどこですか」だけが当たりません。
なぜ0件なのかを、単語で見る
Azure AI Search には、アナライザー3が文をどう分けたかを返すAPIがあります。
from azure.search.documents.indexes.models import AnalyzeTextOptions
r = client.analyze_text("kb-index", AnalyzeTextOptions(text=t, analyzer_name="ja.lucene"))
print(" / ".join(tok.token for tok in r.tokens))
出力です。
【いま勤めている会社はどこですか】
→ いま / 勤める / 会社 / どこ
【現職はどこですか】
→ 現職 / どこ
【3社目が〇〇株式会社で、ここには19年在籍している】
→ 3 / 社 / 目 / 株式 / 会社 / 19 / 年 / 在籍
文書側の単語は 株式 会社 19 年 在籍。
現職はどこですか の単語は 現職 どこ。重なる語が1つもありません。
いま勤めている会社はどこですか のほうは 会社 が文書側にもあるので当たりました。
ついでに2つ分かります。
-
勤めている→勤めるに戻している。活用形を揃えているので「勤めた」でも当たる - 固有名詞の末尾の長音を落とす。表記ゆれを吸収するため
キーワード検索が見ているのは「言葉が合っているか」だけです。聞き方が正しいかも、意味が合っているかも関係ありません。
4. 検索結果をGPTに渡す
見つかった文章を、質問の前にくっつけてGPTに送ります。
sources = "\n\n".join(f"[{h['id']}] {h['title']}\n{h['content']}" for h in hits)
prompt = f"""次の文章だけを根拠に答えてください。書いていないことは「分かりません」と答えてください。
答えの最後に、使った文章の番号を [1] のように書いてください。
【文章】
{sources}
【質問】
{question}"""
res = client.chat.completions.create(
model="gpt-4.1-mini",
messages=[{"role": "user", "content": prompt}],
)
特別な仕組みはありません。GPTに渡す文字列の前に、探してきた文章を足しているだけです。
指示文の2行目だけで、出典の番号が出るようになります。
--- ③ 答え ---
現職は〇〇株式会社です。[2]
指示文を外すと、文書に無いことを答える
比較のため、「次の文章だけを根拠に」の2行を外して同じ質問を投げました。
| 質問 | 指示文あり | 指示文なし |
|---|---|---|
Oracleとは何ですか |
分かりません。[1] | 「Oracle Corporationが開発・提供しており、業務システムやWebサービスで…」 |
〇〇はどんな会社ですか |
分かりません。[2] | 「自治体向けの基幹システムの開発と運用保守を行っている会社です」 |
入れた文書には「Oracleを20年以上扱っている」としか書いていません。指示文を外すと、Oracleの説明が出てきました。
怖いのは、この説明が正しいことです。Oracleは実際にRDBMSで、Oracle Corporationが作っています。内容が合っているので、読んだ人は「文書に書いてあったのだろう」と思います。社内文書のRAGで同じことが起きたら、気づく手立てがありません。
下の行は逆向きの失敗です。文書に「自治体向けの基幹システムを担当し、開発と運用保守の両方を行ってきた」と書いてあるのに、指示文ありだと「分かりません」で止まりました。厳しくしすぎて、答えられるものまで答えなくなっています。
指示文の調整は、2つの失敗の間でつり合いを取る作業になります。
| 指示文が | 起きること |
|---|---|
| ゆるい | 文書に無い知識が混ざる(気づけない) |
| 厳しい | 文書にあることまで「分かりません」になる |
5. ベクトル検索に切り替える
「現職はどこですか」を当てるには、言葉ではなく意味で探す必要があります。
埋め込み4モデル text-embedding-3-small をデプロイ5して、文書をベクトルにして入れ直しました。
from azure.search.documents.indexes.models import (
HnswAlgorithmConfiguration, SearchField, VectorSearch, VectorSearchProfile,
)
fields = [
# ... id / title / content は同じ ...
SearchField(
name="contentVector",
type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
searchable=True,
vector_search_dimensions=1536,
vector_search_profile_name="my-profile",
),
]
vector_search = VectorSearch(
algorithms=[HnswAlgorithmConfiguration(name="my-hnsw")],
profiles=[VectorSearchProfile(name="my-profile", algorithm_configuration_name="my-hnsw")],
)
index = SearchIndex(name="kb-index-vec", fields=fields, vector_search=vector_search)
文書をベクトルにします。
res = aoai.embeddings.create(model="text-embedding-3-small", input=d["content"])
d["contentVector"] = res.data[0].embedding
id=1 → 1536個の数字 (先頭3つ: [-0.0163, 0.0198, 0.0727])
id=2 → 1536個の数字 (先頭3つ: [-0.0076, -0.0328, 0.0508])
id=3 → 1536個の数字 (先頭3つ: [0.0061, -0.0054, 0.0743])
--- ベクトル化に使ったトークン: 520 ---
検索するときは、質問もベクトルにして投げます。
from azure.search.documents.models import VectorizedQuery
emb = aoai.embeddings.create(model="text-embedding-3-small", input=query)
vq = VectorizedQuery(vector=emb.data[0].embedding, k_nearest_neighbors=3, fields="contentVector")
results = client.search(search_text=None, vector_queries=[vq])
6. 並べて測る
同じ言葉を両方に投げました。
| 検索した言葉 | キーワード検索 | ベクトル検索 |
|---|---|---|
現職はどこですか |
0件 | id=2(0.6161)が1位/他2件も返る |
Oracle |
id=1 のみ(0.9066) | id=1(0.5947)/id=3(0.5615)/id=2(0.5439)全部 |
| 固有名詞 | id=2 のみ(1.4428) | id=2(0.6378)/id=3(0.5556)/id=1(0.5371)全部 |
「現職はどこですか」は当たるようになりました。言い換えに強いのがベクトル検索です。
ただし、ベクトル検索には「関係ないので0件」がありません。k_nearest_neighbors=3 と書いたら必ず3件返します。1位と3位のスコア差が0.1しかないので、どこで切るかを数字だけでは決められません。
これはお金に効きます。3件全部をGPTに渡すと、入力トークンがその分増えます。
| キーワード検索 | ベクトル検索 | |
|---|---|---|
| 言い換え | 当たらない | 当たる |
| 型番・固有名詞 | 強い | 似た別のものも拾う |
| 検索そのものの料金 | 0円 | 質問をベクトル化する分がかかる |
| なぜ当たったか | 「この語が一致した」と言える | 説明できない |
| 「0件」が出るか | 出る | 出ない |
3つ目の道——ハイブリッド検索
Azure AI Search は、両方を同時に投げられます。search_text と vector_queries を一緒に渡すだけです。
results = client.search(search_text=question, vector_queries=[vq], top=3)
同じ質問を3つのやり方で通した結果です。
| MODE | 検索結果 | 答え | トークン |
|---|---|---|---|
| キーワード | 0件 | GPTを呼ばずに終了 | 0(0円) |
| ベクトル | 3件(0.6161 / 0.5999 / 0.5606) | 現職は〇〇株式会社です。[2] | 埋め込み9/入力499/出力14 |
| ハイブリッド | 3件(0.0167 / 0.0164 / 0.0161) | 現職は〇〇株式会社です。[2] | 埋め込み9/入力499/出力14 |
ハイブリッドのスコアだけ桁が違います。これは「弱い」という意味ではありません。ハイブリッド検索は RRF6(Reciprocal Rank Fusion)という別の計算で、キーワード側の順位とベクトル側の順位から 1/(60+順位) を足した値を返します。BM25のスコアともベクトルのスコアとも比べられません。
入力トークンが 214 から 499 に増えています。キーワード検索のときは文書1件だけ渡していましたが、ベクトル検索は必ず3件返すので全部渡すことになったためです。同じ質問で2.3倍でした。
7. ローカルで作ったRAGとの違い
2026年8月に、自分のGPUサーバー上で同じものを作っていました。Ollama と Chroma と FastAPI の組み合わせです。
並べると、同じRAGでも別物でした。
| ローカル版(8月) | Azure版(今回) | |
|---|---|---|
| 検索 | Chroma(ベクトルのみ) | Azure AI Search(キーワード+ベクトル) |
| 答えを作るモデル |
qwen3:8b(Ollama) |
gpt-4.1-mini |
| ベクトル化 |
bge-m3(1024次元) |
text-embedding-3-small(1536次元) |
| 日本語の分かち書き | 設定が要らない |
ja.lucene の指定が要る |
| 渡す文書を絞る理由 | 速度のため | お金のため |
Chroma はベクトルだけなので、言語の設定が要りませんでした。そのかわり、上の表のとおり「関係ない文書が必ず混ざる」状態だったはずです。当時は気づいていませんでした。キーワード検索と並べて初めて見えました。
次元が違うので、埋め込みモデルを取り替えると全文書を入れ直すことになります。
8. 消す
リソースグループごと消しました。
「すべてのリソース」が「フィルターに一致するリソースはありません」になれば0件です。
Azure OpenAI のリソースは、消しても48時間は名前が予約されます。「論理削除」という状態で残り、同じ名前では作り直せません。完全に消すなら「Azure AI services の削除済みリソース」から「消去」します。課金は止まっているので、急がなければそのままで構いません。
付録A:かかったお金
1日で全部通して、クレジットは1円も減りませんでした。
| 項目 | 値 |
|---|---|
| 残りのクレジット | ¥31,864.00($200) |
| 使用済み | 0.00% |
| 請求額 | ¥0.00 |
gpt-4.1-mini を10回ほど呼び、text-embedding-3-small で520トークン分のベクトル化をしましたが、円に出ない金額でした。
無料アカウントは「使い切ったら停止」の設定が最初から入っているので、請求が来る形にはなりません。
| かかるところ | 単価 |
|---|---|
gpt-4.1-mini の入力 |
100万トークンで $0.40 |
gpt-4.1-mini の出力 |
100万トークンで $1.60 |
text-embedding-3-small |
100万トークンで $0.02 |
| Azure AI Search(Free) | 0円 |
| リソースを置いておくだけ | 0円 |
課金の反映には8〜24時間かかります。当日は ¥0.00 に見えても、使っていないわけではありません。
付録B:かかった時間
無料アカウントの作成から削除まで、1日で通りました。うち半分近くは、付録Cに書いた404の切り分けに使っています。
付録C:つまずいたところ
「Foundryポータルに移動」を押すと、別のリージョンにリソースが2つ勝手に作られる
Japan East にリソースを作ったあと、画面の「Foundryポータルに移動」を押しました。そこでモデルをデプロイしたら、Pythonから呼んだときに404が返りました。
原因は、ボタンを押した時点で East US 2 に別のリソースが2つ自動で作られていたことでした。デプロイはそちらに入り、Japan East のリソースは空のままでした。
正しい道は「Foundryクラシックで開く」です。Foundryポータルの左上の切り替えメニューから「すべてのリソースを表示」→ 目的のリソースを選ぶ →「Foundryクラシックで開く」。こちらから入れば、選んだリソースにデプロイが作られます。
旧型のAzure OpenAIリソースには、左メニューに「モデル デプロイ」がありません。ポータルから直接デプロイできない作りです。
404の切り分けに効いた手順
Resource not found だけでは何も分かりません。3つ叩いて場所を絞りました。
| 叩いた先 | 結果 | 分かったこと |
|---|---|---|
/openai/v1/models |
200 OK | キーとエンドポイントは正しい |
/openai/deployments?api-version=... |
404(5つのapi-version全部) | 旧形式の道が塞がっている |
/openai/v1/chat/completions |
DeploymentNotFound |
このリソースにデプロイが無い |
Resource not found より DeploymentNotFound のほうが原因を名指ししてくれます。404で詰まったら、新しい /openai/v1/ の形式で叩き直すと切り分けが進みます。
api_version は 2024-12-01 ではなく 2024-12-01-preview
-preview を落とすと404になります。デプロイ詳細の画面に出ているサンプルコードの値をそのまま使うのが確実です。
リージョンを変えると価格レベルが Standard に戻る
Azure AI Search の作成画面で、価格レベルを Free にしてから場所を変えたら、Standard に戻っていました。気づかずに作ってしまい、削除して作り直しました。
Standard は時間課金です。数分でも1時間分が計上されることがあります。
場所を先に決める → 価格レベルを最後に Free にする → そこから何も触らずに作成、の順番なら踏みません。
削除した名前はすぐ再利用できない
検索サービス名は https://<名前>.search.windows.net として世界で一意に予約されます。削除しても数時間から1日は戻ってきません。末尾に1文字足して作り直しました。
「キー」は「設定」ではなく「セキュリティとネットワーク」の中
左メニューの項目は入れ替わります。名前で探すより、階層を1つずつ開くほうが早いです。
モデルには使える期限がある
gpt-4o-mini を選ぼうとしたら、グローバル標準での廃止予定が9日後でした。記事に書くモデルは期限が先のものを選ぶ必要があります。
PTU計算ツールに迷い込まないこと
新しいFoundryポータルの左メニュー「モデル」から入ると、PTU(Provisioned Throughput Unit)の計算ツールに着くことがあります。これは容量を月ぎめで買い取る契約の見積もり画面で、月数十万円の単位です。ここでは何も押しません。
正しい道は「モデル」→ タブ「デプロイ」→「基本モデルをデプロイする」です。
関連記事
- はじめてのAzure(1/2)——無料アカウントを作って、リソースを1つ作って、その日のうちに全部消すまでの記録
- ミニチュア版Gemini NoteBookの構築(v0.1)——今回と同じRAGを、自分のGPUサーバー上に Ollama・Chroma・FastAPI で組んだときの記録
-
リソース。Azure に作ったもの1つ1つの呼び名。中身は URL とキーで、データを保存する場所ではありません。 ↩
-
インデックス。Azure AI Search ではテーブルそのものを指します。Oracle などの索引(検索を速くするためにテーブルとは別に作るもの)とは意味が逆です。 ↩
-
アナライザー。文を単語に分ける仕組み。日本語は
ja.luceneを指定します。指定しないと日本語が単語に分かれず、検索が当たりません。 ↩ -
埋め込み。文を数字の並びに変えること。
text-embedding-3-smallは1つの文を1536個の数字にします。意味が近い文どうしは、その数字の並びも近くなります。 ↩ -
デプロイ。Azure OpenAI では「このリソースからこのモデルを呼んでいい」という設定のことで、モデルの実体がリソースに入るわけではありません。展開の種類が「グローバル標準」と表示されるのがその証拠です。 ↩
-
RRF(Reciprocal Rank Fusion)。ハイブリッド検索のスコアの計算方法。キーワード側とベクトル側それぞれの順位から
1/(60+順位)を計算して足します。BM25 のスコアともベクトルのスコアとも比べられません。 ↩