Excel統計データでRAGを作る ― BGE-M3・Qdrant・Qwen3-8Bによる日本語RAG

1. 背景

2026年9月6日(日)の第十九回 岐阜AI勉強会のテーマとして、RAG を選ぶことにしました。このブログは勉強会で参照することを目的の一つとしています。

以前からローカルマシンの LLM で関係社外秘の PDF を検索して回答を返す LLM の利用を検討していました。複雑な表を多数含む日本語の PDF の内容を LLM に質問しながら確認できたらと考えていました。

そこで、表を含む日本語 PDF をターゲットにし、docling で表データの抽出を試みました。当初ターゲットにしていた PDF の表データを docling で抽出することを試みましたが、表データの抽出が上手くいかず表の並びがずれることがありました。参照する日本語 PDF をよりシンプルな構成の PDF に変えても表からのデータ抽出に失敗する場合がありました。多様な PDF からの表データの抽出が容易ではないことを確認し、目的を簡単な RAG システムを構築して動作確認することに変えました。

表データを読み取り易い下記のリンク先の Excel の統計データを表データとして参照するようにしました。

e-Stat「26-02【総計】都道府県別年齢階級別人口」

総務省が公表する「住民基本台帳に基づく人口、人口動態及び世帯数調査」の「26-02【総計】都道府県別年齢階級別人口」になります。

私が使用しているパソコンでは NVIDIA GeForce GTX 1650 が利用可能ですが、利用可能な VRAM (ビデオメモリ) は 4GB (4096MiB) 以下です。この環境で問題なく GPU メモリ上で実行できる Qwen3 1.7B を LLM として使用しました。ですが、このサイズの LLM では「北海道の65歳~69歳の人口は?」というシンプルな質問にも間違えることがありました。そこで、今回は Google Colab 上で Qwen3 8B を使用して動作確認しました。

Qwen3 8B を使用したところ、上記の Excel の表から抽出したデータを参照し、正確で柔軟な回答が得られるようになりました。

2. システム構成

下記の二つの図は、システム構成を事前準備の「インデックス作成」フェーズとユーザーの質問に回答する「質問・回答」フェーズの二つのフェーズに分けた図になります。

インデックス作成では、Excel データをチャンクに分割し、BGE-M3 で Embedding (1024次元のベクトル) を生成して Qdrant に登録します。チャンクは、データを登録・検索しやすい単位に分割したかたまりのことです。

【インデックス作成】

e-Stat 統計データ
Excelファイル
都道府県別・年齢階級別人口等

        │
        │ Excel → テキスト
        ▼

データ前処理
・表データの読み込み
・テキスト化
・チャンク分割

        │
        │ Chunks
        ▼

BGE-M3
BAAI/bge-m3 Embedding

        │
        │ テキスト → ベクトル
        ▼

Embedding

        │
        ▼

Qdrant
Vector Database
Chunk + Embedding + Metadata

質問・回答では、ユーザーの質問を BGE-M3 で Embedding 化し、Qdrant から関連するチャンクを検索します。取得したTop-K チャンクを Qwen3-8B に渡し、最終的な回答を生成します。

【質問・回答】

ユーザー質問
「北海道の65歳~69歳の人口は?」

        │
        ▼

BGE-M3
BAAI/bge-m3 Embedding

        │
        │ 質問 → ベクトル
        ▼

Query Embedding

        │
        ▼

Qdrant
ベクトル検索

        │
        │ Top-K Chunks
        ▼

Qwen3-8B (LLM)

        │
        │ 質問 + Retrieved Context
        ▼

最終回答
3. 実行した Google Colab のページ

こちらのリンク先の Goole Colab のページのコードセルを順に実行し、実行結果を確認しました。

3.1. ファイル・パッケージ等の準備
3.1.1. 都道府県別年齢階級別人口の Excel ファイルの準備

下記のコードセルを実行し、「26-02【総計】都道府県別年齢階級別人口」の Excel ファイルをダウンロードします。

import requests
from pathlib import Path

# ==========================================
# e-Stat Excel ダウンロード
# ==========================================

URL = "https://www.e-stat.go.jp/stat-search/file-download?statInfId=000040479048&fileKind=0"

OUTPUT = Path(
    "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx"
)

OUTPUT.parent.mkdir(parents=True, exist_ok=True)

response = requests.get(URL)
response.raise_for_status()

with open(OUTPUT, "wb") as f:
    f.write(response.content)

print(f"ダウンロード完了")
print(f"保存先: {OUTPUT}")
print(f"ファイルサイズ: {OUTPUT.stat().st_size:,} bytes")

下記のコードセルを実行し、ダウンロードした Excel ファイルを確認します。

import openpyxl

path = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx"

wb = openpyxl.load_workbook(path, read_only=True, data_only=True)

print("Excel読み込み成功")
print("Sheet:", wb.sheetnames)

Excel ファイルの読み込みに成功したら下記の文字列が出力されます。

Excel読み込み成功
Sheet: ['年齢別人口(都道府県別)【総計】']
3.1.2. スクリプト実行の準備

下記のコードセルを実行し、使用する Python のパッケージをインストールします。

# ==========================================
# RAG PoC 環境構築
# ==========================================

!pip install -q \
    openpyxl \
    sentence-transformers \
    qdrant-client \
    transformers \
    accelerate

下記のコードセルを実行し、使用するディレクトリを作成します。
(/content/rag-poc/data/excel は、Excel ファイルをダウンロードしたときに作成済みです。)

# ==========================================
# RAG PoC ディレクトリ作成
# ==========================================

from pathlib import Path

BASE_DIR = Path("/content/rag-poc")
DATA_DIR = BASE_DIR / "data" / "excel"
QDRANT_DIR = BASE_DIR / "qdrant_data"

DATA_DIR.mkdir(parents=True, exist_ok=True)
QDRANT_DIR.mkdir(parents=True, exist_ok=True)

print(f"BASE_DIR   : {BASE_DIR}")
print(f"DATA_DIR   : {DATA_DIR}")
print(f"QDRANT_DIR : {QDRANT_DIR}")
3.2. インデックス作成と動作確認

下記の 3.2.1. から 3.2.5. までで「2. システム構成」のインデックス作成とその動作確認をします。

3.2.1. ダウンロードした Excel ファイルからテキストデータを抽出し、JSON 形式で保存

下記のコードセルを実行し、ダウンロードした Excel ファイルからテキストデータを抽出します。

# ==========================================
# Excel → JSON
# ==========================================

import json
import openpyxl

INPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx"
OUTPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.json"

wb = openpyxl.load_workbook(INPUT, data_only=True)
ws = wb.active

# 1行目:タイトル
title = ws.cell(1, 1).value

# 2行目:年齢階級
age_headers = [cell.value for cell in ws[2][3:]]

# 3行目:単位
units = [cell.value for cell in ws[3][3:]]

# 4~147行目:データ
records = []

for row in ws.iter_rows(min_row=4, max_row=147, values_only=True):
    record = {
        "団体コード": row[0],
        "都道府県名": row[1],
        "性別": row[2],
    }

    for age, value, unit in zip(age_headers, row[3:], units):
        record[age] = {
            "value": value,
            "unit": unit,
        }

    records.append(record)

# 148~150行目:注記
notes = [
    ws.cell(row, 1).value
    for row in range(148, 151)
    if ws.cell(row, 1).value
]

data = {
    "title": title,
    "source": INPUT,
    "notes": notes,
    "records": records,
}

with open(OUTPUT, "w", encoding="utf-8") as f:
    json.dump(data, f, ensure_ascii=False, indent=2)

print(f"入力: {INPUT}")
print(f"出力: {OUTPUT}")
print(f"レコード数: {len(records)}")
print(f"注記数: {len(notes)}")

下記のような JSON 文字列として抽出したテキストデータをファイルに保存します。

{
  "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
  "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
  "notes": [
    "注1:外国人住民の「男性総数が1~9人」「女性総数が1~9人」「男女計総数が49人以下」のいずれかに該当する市区町村における5歳ごと等の内訳は、非公表である",
    "注2:注1に該当する市区町村を含む都道府県等における5歳ごと等の内訳は、非公表の市区町村分を含まないため、総数と5歳ごと等の内訳の合計が合わない",
    "注3:外国人住民のうち、在留カードの性別欄が空欄のため、住民票の性別を男女のいずれにも該当しないものとしている者については、表の男女のいずれにも計上せず、総計においてのみ計上している。"
  ],
  "records": [
    {
      "団体コード": "-",
      "都道府県名": "合計",
      "性別": "計",
      "総数": {
        "value": 123767642,
        "unit": "人"
      },
      "0歳~4歳": {
        "value": 3763640,
        "unit": "人"
      },
      "5歳~9歳": {
        "value": 4650044,
        "unit": "人"
      },
      ...
    },
    ...
    {
      "団体コード": "470007",
      "都道府県名": "沖縄県",
      "性別": "女",
      "総数": {
        "value": 752080,
        "unit": "人"
      },
      "0歳~4歳": {
        "value": 31581,
        "unit": "人"
      },
      ...
      "100歳以上": {
        "value": 1075,
        "unit": "人"
      }
    }
  ]
}
3.2.2. 抽出したテキストデータをチャンクに分割

下記のコードセルを実行します。抽出したテキストデータを意味的にまとまった複数のテキスト (チャンク) に分割し、データを登録・検索する準備をします。

# ==========================================
# JSON → Chunk
# ==========================================

import json

INPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.json"
OUTPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026-chunks.json"

with open(INPUT, "r", encoding="utf-8") as f:
    data = json.load(f)

chunks = []

for chunk_id, record in enumerate(data["records"]):
    lines = [
        data["title"],
        "",
        f"都道府県名:{record['都道府県名']}",
        f"性別:{record['性別']}",
        "",
    ]

    for key, item in record.items():
        if key in ["団体コード", "都道府県名", "性別"]:
            continue

        lines.append(
            f"{key}:{item['value']:,}{item['unit']}"
        )

    text = "\n".join(lines)

    chunks.append({
        "chunk_id": chunk_id,
        "text": text,
        "metadata": {
            "source": data["source"],
            "title": data["title"],
            "団体コード": record["団体コード"],
            "都道府県名": record["都道府県名"],
            "性別": record["性別"],
        }
    })

# 注記もChunkとして追加
for note in data["notes"]:
    chunk_id = len(chunks)

    text = f"{data['title']}\n\n注記:\n{note}"

    chunks.append({
        "chunk_id": chunk_id,
        "text": text,
        "metadata": {
            "source": data["source"],
            "title": data["title"],
            "type": "note"
        }
    })

with open(OUTPUT, "w", encoding="utf-8") as f:
    json.dump(chunks, f, ensure_ascii=False, indent=2)

print(f"入力: {INPUT}")
print(f"出力: {OUTPUT}")
print(f"Chunk数: {len(chunks)}")

下記のような JSON 文字列に変換してファイルに保存します。chunk_id は各チャンクを表す ID です。今回の例では chund_id が 0 から 146 までの 147 のチャンクに分割しています。

[
  {
    "chunk_id": 0,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             都道府県名:合計
             性別:計
             総数:123,767,642人
             0歳~4歳:3,763,640人
             5歳~9歳:4,650,044人
             ...
             100歳以上:100,507人",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "団体コード": "-",
      "都道府県名": "合計",
      "性別": "計"
    }
  },
  ...
  {
    "chunk_id": 143,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             都道府県名:沖縄県
             性別:女
             総数:752,080人
             0歳~4歳:31,581人
             ...
             100歳以上:1,075人",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "団体コード": "470007",
      "都道府県名": "沖縄県",
      "性別": "女"
    }
  },
  ...
  {
    "chunk_id": 146,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             注記:
             注3:外国人住民のうち、在留カードの性別欄が空欄のため、住民票の性別を男女のいずれにも該当しないものとしている者については、表の男女のいずれにも計上せず、総計においてのみ計上している。",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "type": "note"
    }
  }
]
3.2.3. チャンクに分割されたテキストデータに Embedding ベクトルを追加

下記のコードセルを実行します。チャンクに分割されたテキストデータ (先ほどの JSON 形式の “text” に対応する文字列) を BGE-M3 に渡し、テキストデータに対応する 1024 次元の Embedding ベクトルを生成します。

BGE-M3 は、文章を意味的な特徴を持つ Embedding ベクトルに変換する多言語対応のモデルです。RAG では、このベクトルを使って質問に関連する文章を検索します。

# ==========================================
# BGE-M3 Embedding
# ==========================================

import json
from sentence_transformers import SentenceTransformer

INPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026-chunks.json"
OUTPUT = "/content/rag-poc/data/excel/Population_by_Pref_Age_2026-embeddings.json"

MODEL_NAME = "BAAI/bge-m3"

# BGE-M3をGPUで読み込み
print("BGE-M3を読み込んでいます...")

embed_model = SentenceTransformer(
    MODEL_NAME,
    device="cuda",
)

# Chunk読み込み
with open(INPUT, "r", encoding="utf-8") as f:
    chunks = json.load(f)

texts = [chunk["text"] for chunk in chunks]

print(f"Chunk数: {len(texts)}")

# Embedding
embeddings = embed_model.encode(
    texts,
    batch_size=8,
    show_progress_bar=True,
    normalize_embeddings=True,
)

# 保存
results = []

for chunk, embedding in zip(chunks, embeddings):
    results.append({
        "chunk_id": chunk["chunk_id"],
        "text": chunk["text"],
        "metadata": chunk["metadata"],
        "embedding": embedding.tolist(),
    })

with open(OUTPUT, "w", encoding="utf-8") as f:
    json.dump(
        results,
        f,
        ensure_ascii=False,
        indent=2,
    )

print(f"入力: {INPUT}")
print(f"出力: {OUTPUT}")
print(f"Chunk数: {len(results)}")
print(f"Embedding次元数: {len(results[0]['embedding'])}")

下記の JSON 形式のテキストは保存された Embedding ベクトル付きのチャンクデータになります。「3.2.2. 抽出したテキストデータをチャンクに分割」で出力された JSON 形式のデータに Embedding ベクトルが追加されています。

“embedding” に続く数値ベクトルが Embedding ベクトルです。下記のテキストでは省略していますが 1024 次元のベクトルになります。

[
  {
    "chunk_id": 0,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             都道府県名:合計
             性別:計
             総数:123,767,642人
             0歳~4歳:3,763,640人
             5歳~9歳:4,650,044人
             ...
             100歳以上:100,507人",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "団体コード": "-",
      "都道府県名": "合計",
      "性別": "計"
    },
    "embedding": [
      0.024656925350427628,
      ...
      -0.003821698250249028
    ]
  },
  ...
  {
    "chunk_id": 143,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             都道府県名:沖縄県
             性別:女
             総数:752,080人
             0歳~4歳:31,581人
             ...
             100歳以上:1,075人",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "団体コード": "470007",
      "都道府県名": "沖縄県",
      "性別": "女"
    },
    "embedding": [
      0.04864417016506195,
      ...
      -0.008218140341341496
    ]
  },
  ...
  {
    "chunk_id": 146,
    "text": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)
             注記:
             注3:外国人住民のうち、在留カードの性別欄が空欄のため、住民票の性別を男女のいずれにも該当しないものとしている者については、表の男女のいずれにも計上せず、総計においてのみ計上している。",
    "metadata": {
      "source": "/content/rag-poc/data/excel/Population_by_Pref_Age_2026.xlsx",
      "title": "令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)",
      "type": "note"
    },
    "embedding": [
      -0.012678470462560654,
      ...
      -0.013427718542516232
    ]
  }
]
3.2.4. Qdrant Collection の作成と登録

下記のコードセルを実行します。Embedding ベクトル計算済みの Chunk を Qdrant に登録し、ベクトル検索できる状態にします。Qdrant に Collection がなければ作成し、ベクトル・テキスト・Metadata を登録します。

ベクトルサイズには 1024 次元を指定し、類似度計算には Cosine 距離を使用するよう指定しています。

# ==========================================
# Qdrant Collection作成・登録
# ==========================================

import json
from pathlib import Path

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

EMBEDDINGS_FILE = Path(
    "/content/rag-poc/data/excel/Population_by_Pref_Age_2026-embeddings.json"
)

COLLECTION_NAME = "population_age_2026"
VECTOR_SIZE = 1024

QDRANT_DIR = "/content/rag-poc/qdrant_data"

# Qdrant Local
client = QdrantClient(
    path=QDRANT_DIR
)

# Embeddings JSON読み込み
with open(EMBEDDINGS_FILE, "r", encoding="utf-8") as f:
    chunks = json.load(f)

print(f"読み込んだChunk数: {len(chunks)}")

# Collection作成
if client.collection_exists(COLLECTION_NAME):

    print(
        f"Collection '{COLLECTION_NAME}' は既に存在します。"
    )

else:

    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config=VectorParams(
            size=VECTOR_SIZE,
            distance=Distance.COSINE,
        ),
    )

    print(
        f"Collection '{COLLECTION_NAME}' を作成しました。"
    )

# Qdrant Point作成
points = []

for chunk in chunks:

    points.append(
        PointStruct(
            id=chunk["chunk_id"],
            vector=chunk["embedding"],
            payload={
                "text": chunk["text"],
                "metadata": chunk["metadata"],
            },
        )
    )

# Qdrantへ登録
client.upsert(
    collection_name=COLLECTION_NAME,
    points=points,
)

print(
    f"{len(points)}個のChunkをQdrantへ登録しました。"
)

# 登録結果確認
collection_info = client.get_collection(
    collection_name=COLLECTION_NAME
)

print()
print(f"Collection: {COLLECTION_NAME}")
print(
    f"登録されたVector数: "
    f"{collection_info.points_count}"
)
3.2.5. Qdrant に登録した Chunk の検索の動作確認

下記のコードセルを実行します。複数の質問の Embedding ベクトルを BGE-M3 で計算し、得られたベクトルを使って Qdrant に登録した Chunk データを検索します。関連度の高い上位 5 件の Chunk を取得します。

# ==========================================
# Qdrantベクトル検索
# ==========================================

import numpy as np
from sentence_transformers import SentenceTransformer

EMBED_MODEL = "BAAI/bge-m3"

QUERIES = [
    "北海道の65歳~69歳の人口は?",
    "北海道の65歳~69歳の男性人口は?",
    "北海道の20歳~24歳の女性人口は?",
    "岐阜県の75歳~79歳の人口は?",
    "東京都の100歳以上の女性人口は?",
]

TOP_K = 5

# BGE-M3
print("BGE-M3を読み込んでいます...")

embed_model = SentenceTransformer(
    EMBED_MODEL,
    device="cuda",
)

# QueryをまとめてEmbedding
query_embeddings = embed_model.encode(
    QUERIES,
    normalize_embeddings=True,
)

# QueryごとにQdrant検索
for query, query_embedding in zip(
    QUERIES,
    query_embeddings,
):

    results = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_embedding.tolist(),
        limit=TOP_K,
        with_payload=True,
    ).points

    print()
    print("=" * 80)
    print(f"Query: {query}")
    print("=" * 80)

    for rank, result in enumerate(results, start=1):

        metadata = result.payload.get(
            "metadata",
            {}
        )

        print(
            f"[{rank}] "
            f"score={result.score:.4f} "
            f"chunk_id={result.id} "
            f"{metadata.get('都道府県名')} "
            f"{metadata.get('性別')}"
        )

下記の結果が得られました。下記の結果の score は質問のテキストの Embedding ベクトルと各チャンクの Embedding ベクトルのコサイン距離になります。下記の質問の例で、参照したいチャンクが 3 位以内に入りそうなことが確認できました。

================================================================================
Query: 北海道の65歳~69歳の人口は?
================================================================================
[1] score=0.6676 chunk_id=5 北海道 女
[2] score=0.6583 chunk_id=3 北海道 計
[3] score=0.6579 chunk_id=4 北海道 男
[4] score=0.5571 chunk_id=93 鳥取県 計
[5] score=0.5514 chunk_id=2 合計 女

================================================================================
Query: 北海道の65歳~69歳の男性人口は?
================================================================================
[1] score=0.6759 chunk_id=4 北海道 男
[2] score=0.6540 chunk_id=5 北海道 女
[3] score=0.6512 chunk_id=3 北海道 計
[4] score=0.5665 chunk_id=1 合計 男
[5] score=0.5657 chunk_id=94 鳥取県 男

================================================================================
Query: 北海道の20歳~24歳の女性人口は?
================================================================================
[1] score=0.6209 chunk_id=5 北海道 女
[2] score=0.5965 chunk_id=3 北海道 計
[3] score=0.5764 chunk_id=4 北海道 男
[4] score=0.5050 chunk_id=11 岩手県 女
[5] score=0.4975 chunk_id=62 長野県 女

================================================================================
Query: 岐阜県の75歳~79歳の人口は?
================================================================================
[1] score=0.6621 chunk_id=63 岐阜県 計
[2] score=0.6569 chunk_id=65 岐阜県 女
[3] score=0.6522 chunk_id=64 岐阜県 男
[4] score=0.5462 chunk_id=120 福岡県 計
[5] score=0.5371 chunk_id=86 兵庫県 女

================================================================================
Query: 東京都の100歳以上の女性人口は?
================================================================================
[1] score=0.6847 chunk_id=41 東京都 女
[2] score=0.6589 chunk_id=39 東京都 計
[3] score=0.6363 chunk_id=40 東京都 男
[4] score=0.5890 chunk_id=35 埼玉県 女
[5] score=0.5866 chunk_id=80 京都府 女
3.3. 質問・回答フェーズの実行

下記の 3.3.1. から 3.3.4. までで「2. システム構成」の質問・回答フェーズを実行します。

3.3.1. Qwen3-8B の読み込み

下記のコードセルを実行します。LLM を 4bit・8bit などに量子化し、GPU メモリ使用量を削減するためのライブラリ bitsandbytes をインストールします。

# ==========================================
# Qwen実行環境
# ==========================================

!pip install -q bitsandbytes

下記のコードセルを実行します。Qwen3-8B を 4bit 量子化して読み込み、GPU メモリを節約しながら実行できるようにします。

# ==========================================
# Qwen3-8B読み込み
# ==========================================

import torch

from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    BitsAndBytesConfig,
)

LLM_MODEL = "Qwen/Qwen3-8B"

# 4bit量子化
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16,
)

print("Qwen Tokenizerを読み込んでいます...")

tokenizer = AutoTokenizer.from_pretrained(
    LLM_MODEL,
)

print("Qwenモデルを読み込んでいます...")

model = AutoModelForCausalLM.from_pretrained(
    LLM_MODEL,
    quantization_config=quantization_config,
    device_map="auto",
)

print("Qwenの読み込みが完了しました。")

print(
    f"GPUメモリ使用量: "
    f"{torch.cuda.memory_allocated() / 1024**3:.2f} GB"
)
3.3.2. 回答する際に参照するテキストデータの取得

下記のコードセルを実行します。”北海道の65歳~69歳の人口は?” という質問に回答する際に参照するテキストデータを取得します。

# ==========================================
# RAG回答テスト
# ==========================================

QUERY = "北海道の65歳~69歳の人口は?"

TOP_K = 3

# BGE-M3をCPUで読み込み
# Qwen用のGPUメモリを確保するためCPUで実行
print("BGE-M3を読み込んでいます...")

embed_model = SentenceTransformer(
    "BAAI/bge-m3",
    device="cpu",
)

# Query Embedding
query_vector = embed_model.encode(
    QUERY,
    normalize_embeddings=True,
)

# Qdrant検索
results = client.query_points(
    collection_name=COLLECTION_NAME,
    query=query_vector.tolist(),
    limit=TOP_K,
    with_payload=True,
).points

# Context作成
contexts = []

for i, result in enumerate(results, start=1):

    payload = result.payload
    metadata = payload.get("metadata", {})

    context = f"""
[検索結果 {i}]
都道府県名:{metadata.get('都道府県名')}
性別:{metadata.get('性別')}

{payload.get('text')}
"""

    contexts.append(context)

context_text = "\n\n---\n\n".join(contexts)

# 検索結果確認
print()
print("=" * 80)
print("検索結果")
print("=" * 80)

for i, result in enumerate(results, start=1):

    metadata = result.payload.get(
        "metadata",
        {}
    )

    print(
        f"[{i}] "
        f"{metadata.get('都道府県名')} "
        f"{metadata.get('性別')} "
        f"(score={result.score:.4f})"
    )

下記のコードセルを実行し、得られた参照用のテキストデータを確認しました。

print(context_text)

Qdrant に登録しておいた下記のようなテキストデータが得られました。”北海道の65歳~69歳の人口は?”の回答は「[検索結果 2] 都道府県名:北海道 性別:計」以下のテキストを参照すれば得られます。

[検索結果 1]
都道府県名:北海道
性別:女

令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)

都道府県名:北海道
性別:女

総数:2,630,860人
0歳~4歳:60,955人
5歳~9歳:80,115人
10歳~14歳:93,351人
15歳~19歳:100,335人
20歳~24歳:109,554人
25歳~29歳:108,341人
30歳~34歳:113,641人
35歳~39歳:125,358人
40歳~44歳:146,894人
45歳~49歳:169,686人
50歳~54歳:197,204人
55歳~59歳:179,532人
60歳~64歳:177,945人
65歳~69歳:171,549人
70歳~74歳:199,868人
75歳~79歳:219,726人
80歳~84歳:157,081人
85歳~89歳:118,113人
90歳~94歳:71,272人
95歳~99歳:25,278人
100歳以上:4,408人


---


[検索結果 2]
都道府県名:北海道
性別:計

令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)

都道府県名:北海道
性別:計

総数:4,996,492人
0歳~4歳:124,861人
5歳~9歳:164,196人
10歳~14歳:191,197人
15歳~19歳:206,511人
20歳~24歳:225,377人
25歳~29歳:224,674人
30歳~34歳:231,977人
35歳~39歳:254,123人
40歳~44歳:293,279人
45歳~49歳:338,125人
50歳~54歳:391,295人
55歳~59歳:347,479人
60歳~64歳:340,218人
65歳~69歳:327,278人
70歳~74歳:370,277人
75歳~79歳:388,205人
80歳~84歳:257,923人
85歳~89歳:181,358人
90歳~94歳:100,067人
95歳~99歳:31,737人
100歳以上:5,077人


---


[検索結果 3]
都道府県名:北海道
性別:男

令和8年1月1日住民基本台帳年齢階級別人口(都道府県別)(総計)

都道府県名:北海道
性別:男

総数:2,365,632人
0歳~4歳:63,906人
5歳~9歳:84,081人
10歳~14歳:97,846人
15歳~19歳:106,176人
20歳~24歳:115,823人
25歳~29歳:116,333人
30歳~34歳:118,336人
35歳~39歳:128,765人
40歳~44歳:146,385人
45歳~49歳:168,439人
50歳~54歳:194,091人
55歳~59歳:167,947人
60歳~64歳:162,273人
65歳~69歳:155,729人
70歳~74歳:170,409人
75歳~79歳:168,479人
80歳~84歳:100,842人
85歳~89歳:63,245人
90歳~94歳:28,795人
95歳~99歳:6,459人
100歳以上:669人
3.3.3. LLM に渡すシステムプロンプト

下記のコードセルを実行し、下記の文字列をシステムプロンプトとしてセットします。

# ==========================================
# RAG System Prompt
# ==========================================

system_prompt = """
あなたは日本の人口統計データを回答するRAGアシスタントです。

回答は、提供されたコンテキストのデータを根拠にしてください。
コンテキストにない数値を推測したり、創作したりしてはいけません。

質問の都道府県、年齢、性別などの条件を確認し、
質問に最も適切に対応するデータをコンテキストから選んでください。

性別が指定されていない場合は、可能であれば「性別:計」のデータを使用してください。
男性・女性などが指定されている場合は、対応する性別のデータを使用してください。

質問の条件とコンテキストのデータが完全に一致しない場合は、
その違いを明示してください。
完全一致するデータがない場合でも、コンテキストに参考になる近いデータがあれば、
そのデータを「参考値」として示して構いません。

ただし、近いデータを質問そのものの回答として扱わないでください。
また、近いデータから質問の値を推測してはいけません。

回答は、利用できるデータと質問の条件を考慮して、
利用者に分かりやすく簡潔に説明してください。
"""
3.3.4. 質問に対する回答の確認

下記のコードセルを実行し、5 つの質問に対する回答を確認しました。enable_thinking=True とし、reasoning ありで Qwen3-8B から回答を得ます。

質問 (Query)、検索して得られたコンテキスト (context_text)、システムプロンプト (system_prompt) を Qwen3-8B に入力として与え、回答を得ます。

# ==========================================
# Qwen RAG 失敗・境界条件テスト (enable_thinking=True)
# ==========================================

QUERIES = [
    "北海道の61歳~64歳の人口は?",
    "沖縄県の65歳~69歳の人口は?",
    "北海道の150歳~159歳の人口は?",
    "北海道の65歳~69歳の男女別人口は?",
    "北海道の人口は?",
]


# ==========================================================
# 質問ごとにRAG回答
# ==========================================================

for query_number, QUERY in enumerate(QUERIES, start=1):

    print()
    print()
    print("#" * 80)
    print(f"質問 {query_number}")
    print("#" * 80)

    print()
    print(f"質問: {QUERY}")


    # ======================================================
    # Query Embedding
    # ======================================================

    query_vector = embed_model.encode(
        QUERY,
        normalize_embeddings=True,
    )


    # ======================================================
    # Qdrant検索
    # ======================================================

    results = client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector.tolist(),
        limit=TOP_K,
        with_payload=True,
    ).points


    # ======================================================
    # 検索結果
    # ======================================================

    print()
    print("=" * 80)
    print("検索結果")
    print("=" * 80)

    for i, result in enumerate(results, start=1):

        metadata = result.payload.get(
            "metadata",
            {}
        )

        print(
            f"[{i}] "
            f"score={result.score:.4f} "
            f"{metadata.get('都道府県名')} "
            f"{metadata.get('性別')}"
        )


    # ======================================================
    # Context作成
    # ======================================================

    contexts = []

    for i, result in enumerate(results, start=1):

        payload = result.payload
        metadata = payload.get("metadata", {})

        context = f"""
[検索結果 {i}]
都道府県名:{metadata.get('都道府県名')}
性別:{metadata.get('性別')}

{payload.get('text')}
"""

        contexts.append(context)


    context_text = "\n\n---\n\n".join(contexts)


    # ======================================================
    # User Prompt
    # ======================================================

    user_prompt = f"""
### 質問

{QUERY}

### コンテキスト

{context_text}

### 回答

質問に対して簡潔に回答してください。
"""


    # ======================================================
    # Messages
    # ======================================================

    messages = [
        {
            "role": "system",
            "content": system_prompt,
        },
        {
            "role": "user",
            "content": user_prompt,
        },
    ]


    # ======================================================
    # Chat Template
    # ======================================================

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=True,
    )


    # ======================================================
    # Tokenize
    # ======================================================

    inputs = tokenizer(
        text,
        return_tensors="pt",
    )

    inputs = {
        key: value.to(model.device)
        for key, value in inputs.items()
    }

    input_tokens = inputs["input_ids"].shape[1]


    # ======================================================
    # Context Length
    # ======================================================

    remaining_tokens = (
        CONTEXT_LENGTH - input_tokens
    )

    usage_percent = (
        input_tokens
        / CONTEXT_LENGTH
        * 100
    )

    print()
    print("=" * 80)
    print("Context Length")
    print("=" * 80)

    print(f"入力トークン数 : {input_tokens}")
    print(f"Context Length : {CONTEXT_LENGTH}")
    print(f"残り          : {remaining_tokens}")
    print(f"使用率        : {usage_percent:.1f}%")


    # ======================================================
    # Qwen3-8B生成
    # ======================================================

    print()
    print("=" * 80)
    print("Qwen3-8B 回答生成")
    print("=" * 80)

    with torch.no_grad():

        output_ids = model.generate(
            **inputs,
            max_new_tokens=2048,
            do_sample=False,
        )


    # ======================================================
    # 回答取得
    # ======================================================

    generated_ids = output_ids[0][input_tokens:]

    answer = tokenizer.decode(
        generated_ids,
        skip_special_tokens=True,
    )

    output_tokens = len(generated_ids)

    total_tokens = (
        input_tokens
        + output_tokens
    )


    # ======================================================
    # Token使用量
    # ======================================================

    print()
    print("=" * 80)
    print("Token使用量")
    print("=" * 80)

    print(f"入力トークン数 : {input_tokens}")
    print(f"生成トークン数 : {output_tokens}")
    print(f"合計Token数    : {total_tokens}")
    print(f"Context Length : {CONTEXT_LENGTH}")

    remaining_tokens_total = (
        CONTEXT_LENGTH - total_tokens
    )

    usage_percent_total = (
        total_tokens
        / CONTEXT_LENGTH
        * 100
    )

    print(f"Context残り    : {remaining_tokens_total}")
    print(f"Context使用率  : {usage_percent_total:.1f}%")


    # ======================================================
    # 回答
    # ======================================================

    print()
    print("=" * 80)
    print("Qwen3-8Bの回答")
    print("=" * 80)

    print(answer)

下記のような回答が得られました。think タグ内の reasoning は英語のトークンとなっています。

################################################################################
質問 1
################################################################################

質問: 北海道の61歳~64歳の人口は?

================================================================================
検索結果
================================================================================
[1] score=0.6285 北海道 女
[2] score=0.6136 北海道 計
[3] score=0.6124 北海道 男

================================================================================
Context Length
================================================================================
入力トークン数 : 1605
Context Length : 8192
残り          : 6587
使用率        : 19.6%

================================================================================
Qwen3-8B 回答生成
================================================================================

================================================================================
Token使用量
================================================================================
入力トークン数 : 1605
生成トークン数 : 493
合計Token数    : 2098
Context Length : 8192
Context残り    : 6094
Context使用率  : 25.6%

================================================================================
Qwen3-8Bの回答
================================================================================
<think>
Okay, let's tackle this question. The user is asking for the population of Hokkaido aged 61 to 64. I need to check the provided context to find the right data.

First, looking at the search results. There are three entries: one for female, one for total, and one for male. The question doesn't specify gender, so the 'total' data should be used. 

In search result 2, which is for '計' (total), the 60-64 age group is listed as 340,218 people. Wait, but the user is asking for 61-64. The data here is for 60-64. The context doesn't have a breakdown for 61-64 specifically. So the available data is for the 60-64 age group. 

Since the data isn't split into 61-64, I can't provide an exact figure. The closest available data is the 60-64 range. I should mention that the provided data includes 60-64 and note that there's no separate entry for 61-64. Also, check if other entries have that info. The male and female data also have 60-64, but again, same issue. So the answer should state that the available data is for 60-64 and that 61-64 isn't specified. 

Therefore, the answer should reference the total 60-64 population and explain the lack of specific 61-64 data.
</think>

質問の条件(北海道の61歳~64歳)に対応するデータはコンテキストにありません。  
提供されたデータでは「60歳~64歳」の集計しか含まれていないため、61歳~64歳の特定の年齢層の人口は把握できません。  

**参考値**:  
- 北海道の「60歳~64歳」の人口(性別:計)は **340,218人** です。  
(※ このデータは60歳~64歳全体の数値であり、61歳~64歳に限定した値ではありません)


################################################################################
質問 2
################################################################################

質問: 沖縄県の65歳~69歳の人口は?

================================================================================
検索結果
================================================================================
[1] score=0.6880 沖縄県 男
[2] score=0.6878 沖縄県 女
[3] score=0.6857 沖縄県 計

================================================================================
Context Length
================================================================================
入力トークン数 : 1566
Context Length : 8192
残り          : 6626
使用率        : 19.1%

================================================================================
Qwen3-8B 回答生成
================================================================================

================================================================================
Token使用量
================================================================================
入力トークン数 : 1566
生成トークン数 : 334
合計Token数    : 1900
Context Length : 8192
Context残り    : 6292
Context使用率  : 23.2%

================================================================================
Qwen3-8Bの回答
================================================================================
<think>
Okay, let's tackle this question. The user is asking for the population of Okinawa Prefecture aged 65-69. I need to check the provided context to find the answer.

First, looking at the search results. There are three entries: male, female, and total. The question doesn't specify gender, so I should check if there's a 'total' category. 

In search result 3, the '計' (total) includes both male and female. The 65-69 age group is listed as 88,672 people. Let me verify if that's correct. 

The male data (search result 1) shows 44,043 and female (search result 2) shows 44,629. Adding those together: 44,043 + 44,629 = 88,672, which matches the total. So the answer should be 88,672. 

I need to present this clearly, mentioning the total as per the '計' category. Also, note that the male and female numbers add up to the total. The user didn't specify gender, so using the total is appropriate here.
</think>

沖縄県の65歳~69歳の人口は**88,672人**です。  
これは、性別別のデータ(男性44,043人、女性44,629人)を合計した「性別:計」の値に一致しています。


################################################################################
質問 3
################################################################################

質問: 北海道の150歳~159歳の人口は?

================================================================================
検索結果
================================================================================
[1] score=0.5982 北海道 女
[2] score=0.5925 北海道 計
[3] score=0.5836 北海道 男

================================================================================
Context Length
================================================================================
入力トークン数 : 1607
Context Length : 8192
残り          : 6585
使用率        : 19.6%

================================================================================
Qwen3-8B 回答生成
================================================================================

================================================================================
Token使用量
================================================================================
入力トークン数 : 1607
生成トークン数 : 385
合計Token数    : 1992
Context Length : 8192
Context残り    : 6200
Context使用率  : 24.3%

================================================================================
Qwen3-8Bの回答
================================================================================
<think>
Okay, let's tackle this question. The user is asking for the population of Hokkaido in the age group 150-159 years old. Hmm, first, I need to check the provided context to see if there's any data that matches this.

Looking at the search results, there are three entries: one for female, one for total, and one for male. All of them list age groups from 0-4 up to 100+ years. Wait, the highest age group mentioned is 100 years and above. The user is asking about 150-159, which isn't listed here. None of the data entries include ages beyond 100. 

So, the context doesn't have information for 150-159. The closest is the 100+ category, but that's not the same as 150-159. The user might have a typo, but assuming the question is correct, the answer should state that the data isn't available. Also, since the question specifies 150-159, which isn't covered in the provided data, I need to mention that there's no matching data. 

I should check if there's any other possible way to infer this, but the context doesn't have any data beyond 100. So the answer must clarify that the required age group isn't present in the given information.
</think>

質問の条件(北海道の150歳~159歳)に対応するデータは、提供されたコンテキストに含まれていません。コンテキストにある最大の年齢階級は「100歳以上」のみで、150歳~159歳のデータは存在しないことが確認できます。


################################################################################
質問 4
################################################################################

質問: 北海道の65歳~69歳の男女別人口は?

================================================================================
検索結果
================================================================================
[1] score=0.5869 北海道 女
[2] score=0.5779 北海道 男
[3] score=0.5712 北海道 計

================================================================================
Context Length
================================================================================
入力トークン数 : 1607
Context Length : 8192
残り          : 6585
使用率        : 19.6%

================================================================================
Qwen3-8B 回答生成
================================================================================

================================================================================
Token使用量
================================================================================
入力トークン数 : 1607
生成トークン数 : 432
合計Token数    : 2039
Context Length : 8192
Context残り    : 6153
Context使用率  : 24.9%

================================================================================
Qwen3-8Bの回答
================================================================================
<think>
Okay, let's tackle this question. The user is asking for the population of Hokkaido aged 65-69, broken down by gender. 

First, I need to check the provided context. There are three search results: one for males, one for females, and one for the total. 

Looking at the first result, it's for females. The 65-69 age group is listed as 171,549. The second result is for males, same age group: 155,729. The third result is the total, which combines both genders. The total for 65-69 is 327,278. 

Wait, let me confirm. The total's 65-69 is 327,278. If I add the female and male numbers: 171,549 + 155,729 = 327,278. That matches, so the numbers are correct. 

The user wants both male and female counts. So the answer should state the numbers for each gender as per the search results 1 and 2. No need to mention the total since the question specifies male and female. 

I should present the answer clearly, separating the male and female populations from the given data. Make sure not to include any other data or make assumptions beyond what's provided. Also, check that the age group is exactly 65-69, which it is in all entries. 

No discrepancies here, so the answer is straightforward.
</think>

北海道の65歳~69歳の男女別の人口は以下の通りです。  

- **男性**:155,729人(検索結果2)  
- **女性**:171,549人(検索結果1)  

上記のデータは、令和8年1月1日時点の住民基本台帳年齢階級別人口に基づいています。


################################################################################
質問 5
################################################################################

質問: 北海道の人口は?

================================================================================
検索結果
================================================================================
[1] score=0.7070 北海道 計
[2] score=0.7064 北海道 女
[3] score=0.7028 北海道 男

================================================================================
Context Length
================================================================================
入力トークン数 : 1597
Context Length : 8192
残り          : 6595
使用率        : 19.5%

================================================================================
Qwen3-8B 回答生成
================================================================================

================================================================================
Token使用量
================================================================================
入力トークン数 : 1597
生成トークン数 : 327
合計Token数    : 1924
Context Length : 8192
Context残り    : 6268
Context使用率  : 23.5%

================================================================================
Qwen3-8Bの回答
================================================================================
<think>
Okay, the user is asking for the population of Hokkaido. Let me check the context provided.

First, there are three search results. The first one is for Hokkaido with "計" which I think means total, including both male and female. The total number there is 4,996,492. The second and third are for female and male separately. The user didn't specify sex, so the first result should be the right one. 

Wait, the first result's total is 4,996,492. The second and third's totals are 2,630,860 and 2,365,632 respectively. Adding those two gives 2,630,860 + 2,365,632 = 4,996,492, which matches the first total. So the first result is the correct total population. 

The user's question is straightforward, just asking for the population of Hokkaido. The answer should be the total from the first search result. No need to mention the breakdown by age or sex unless necessary. Just state the total number clearly.
</think>

北海道の令和8年1月1日時点の総人口は**4,996,492人**です。  
これは「性別:計」のデータに基づく全人口数であり、男女別のデータ(検索結果2・3)を合計した結果と一致しています。

上記の Qwen3-8B の回答をまとめると下記のようになります。

  • 北海道 61~64歳

    データには「61~64歳」の完全一致する年齢階級がないため、近い「60~64歳」のデータを参考値として提示しました。完全一致しないデータを無理に推測しないことを確認できました。

  • 沖縄県 65~69歳

    性別の指定がない質問では、「男」「女」ではなく「計」のデータを選択し、88,672人と正しく回答できました。

  • 北海道 150~159歳

    データの年齢階級は「100歳以上」までのため、「150~159歳」に該当するデータはありません。存在しないデータを推測せず、「該当データなし」と回答できました。

  • 北海道 65~69歳 男女別

    「男」と「女」の別々のChunkから必要なデータを取得し、男性155,729人、女性171,549人と回答できました。複数の検索結果を組み合わせた回答も確認できました。

  • 北海道の人口

    年齢や性別を指定しない質問に対して、「計」のデータを選択し、4,996,492人と総人口を回答できました。

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA