国産LLM × pgvectorで「価値観マッチング」を動かす ── llm-jp-4による行動特性抽出からコサイン類似度検索まで
はじめに:この記事で動かすもの
自治体の婚活支援システムは、年収・年齢の条件フィルタが主流です。しかし、結婚生活の継続に効くのは「金銭感覚」「衝突時の対処スタイル」「家事分担への意識」といった内的特性(行動特性)の一致度であり、スペック検索ではこれを拾えません。
本稿では、国産公共開発LLM llm-jp-4 と pgvector を使い、以下の一気通貫パイプラインをローカル環境で実際に動かします。
対話ログ → llm-jp-4で行動特性をJSON抽出 → pgvectorに格納 → コサイン類似度で候補検索
コンセプト記事ではありません。再現手順・実行結果・ハマりどころをすべて記載します。
想定読者
- 自治体DXの技術選定に関わるエンジニア
- ローカルLLMを使った構造化データ抽出に興味がある人
- pgvectorを触ったことがない人
検証環境
| 項目 | 値 |
|---|---|
| OS | Windows 11 (PowerShell 7.6) |
| GPU | NVIDIA RTX 3090 (24GB VRAM) ※本検証ではGPU不使用(CPU推論) |
| LLM推論 | llama-server (llama.cpp, 2026年4月ビルド) |
| モデル | llm-jp-4-8b-instruct-Q4_K_M.gguf (5.3GB) |
| ベクトル検索 | NumPy(コサイン類似度の直接計算)※pgvector版コードも後述 |
| Python | 3.12 |
注: 本検証はCPU推論(-ngl 0)で実施しています。GPU推論(-ngl 99)にすればVRAM約6GBで推論速度が5〜10倍向上します。
1. 背景:なぜ「行動特性ベクトル」なのか
婚活マッチングにおけるスペック検索の限界は明白です。年収600万円以上でフィルタしても、「金銭感覚が合わない」離婚は防げません。
心理学のビッグファイブ理論やGallup社のCliftonStrengths®に代表される「行動特性アセスメント」は、個人の行動パターンを多次元ベクトルとして表現します。この発想を婚活に転用し、LLMで対話ログから特性を抽出してベクトル空間上で近い相手を検索する、というのが本稿のアプローチです。
定義する特性軸は以下の4次元とします(実運用では心理学の専門家と協議の上で拡張すべきですが、技術検証としてはこの粒度で十分です)。
| 軸名 | 意味 | 低スコア(0.0寄り) | 高スコア(1.0寄り) |
|---|---|---|---|
financial_management |
金銭管理の堅実さ | 楽観的・享受型 | 計画的・貯蓄重視型 |
conflict_resolution |
衝突時の解決スタイル | 感情優先・回避型 | 論理的・対話型 |
household_equity |
家事育児の分担意識 | 従来型(片方に偏る) | 対等分担型 |
lifestyle_flexibility |
生活変化への適応力 | 安定志向・変化回避 | 柔軟・変化許容型 |
2. 環境構築
2.1 pgvector付きPostgreSQLの起動
# docker-compose.yml
services:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: matchuser
POSTGRES_PASSWORD: matchpass
POSTGRES_DB: matchdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d
2.2 テーブル作成
-- init.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
trait_vector vector(4) NOT NULL,
raw_traits JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- コサイン類似度検索用のインデックス
-- レコード数が少ない検証段階ではなくても動くが、
-- 本番想定(数万件〜)ではivfflatまたはhnswが必須
CREATE INDEX idx_trait_cosine ON user_profiles
USING hnsw (trait_vector vector_cosine_ops);
psql -h localhost -U matchuser -d matchdb -f init.sql
2.3 llm-jp-4の起動(llama-server)
Ollamaでのllm-jp-4動作にはトークナイザ互換性の問題が報告されています(2026年4月時点、Qiita記事 @ntaka329 氏の検証参照)。本稿ではllama.cppのllama-serverを使います。
# GGUFのダウンロード(未取得の場合)
hf download mmnga-o/llm-jp-4-8b-instruct-gguf --include "llm-jp-4-8b-instruct-Q4_K_M.gguf" --local-dir D:\models
# llama-serverの起動(CPU推論)
# -ngl 0 でGPUを一切使わない(既存のGPUジョブと干渉しない)
# GPU推論する場合は -ngl 99 に変更
D:\models\llm-jp-4-base-build\llama-server.exe `
-m D:\models\llm-jp-4-8b-instruct-Q4_K_M.gguf `
-c 4096 `
-ngl 0 `
--host 127.0.0.1 `
--port 11437
GPU推論する場合:
-ngl 0を-ngl 99に変更すればVRAM約6GBで動作し、推論速度が5〜10倍向上します。
2.4 Pythonパッケージ
pip install httpx numpy
3. 実装
3.1 全体構成
trait_extractor.py ← LLMで対話ログから特性JSON抽出
pgvector_store.py ← pgvectorへの格納・検索
main.py ← 一気通貫デモ(5人分の抽出→格納→検索)
3.2 trait_extractor.py ── LLMによる行動特性抽出
ポイントは2つです。
-
キー名正規化: 本環境において、llm-jp-4-8bがJSON出力時にキー名の綴りを変えて出力する現象が観測されました(後述の「ハマりどころ」で詳述)。
conflict_resoltion、lifestyle_flexibilitiy等のパターンが確認されたため、キーの先頭語でマッチングし正規キー名にマッピングする正規化層を組み込んでいます。 - 多段フォールバック: LLMがMarkdownコードブロックで囲んで出力する場合もあるため、JSONパースを複数戦略で試みます。
"""trait_extractor.py -- llm-jp-4で対話ログから行動特性を抽出する
llm-jp-4-8bはキー名をタイポする癖がある(conflict_resoltion等)ため、
キー名の正規化処理を組み込んでいる。
"""
import json
import re
import time
import httpx
import numpy as np
LLAMA_SERVER_URL = "http://localhost:11437"
TRAIT_KEYS = [
"financial_management",
"conflict_resolution",
"household_equity",
"lifestyle_flexibility",
]
# llm-jp-4-8bが出力するキー名のタイポを正規キー名にマッピングする
# 実測で確認されたタイポパターン:
# conflict_resoltion, conflict_resollution → conflict_resolution
# lifestyle_flexibilitiy, lifestyle_flexibiliti → lifestyle_flexibility
KEY_NORMALIZE_MAP = {
"financial": "financial_management",
"conflict": "conflict_resolution",
"household": "household_equity",
"lifestyle": "lifestyle_flexibility",
}
SYSTEM_PROMPT = """あなたは行動特性の分析官です。
以下の対話ログから、この人物の「共同生活における行動特性」を分析してください。
4つのカテゴリについて0.0〜1.0の数値で評価し、以下の形式のJSONだけを出力してください。
説明文、マークダウン記法、コードブロックは一切不要です。JSONだけです。
{"financial_management": 0.5, "conflict_resolution": 0.5, "household_equity": 0.5, "lifestyle_flexibility": 0.5}
評価基準:
- financial_management: 金銭管理の堅実さ(0.0=楽観・享受型、1.0=計画・貯蓄重視型)
- conflict_resolution: 衝突時の解決スタイル(0.0=感情優先・回避型、1.0=論理的・対話型)
- household_equity: 家事育児の分担意識(0.0=従来型・片方偏重、1.0=対等分担型)
- lifestyle_flexibility: 生活変化への適応力(0.0=安定志向、1.0=柔軟・変化許容型)"""
def _normalize_keys(raw: dict) -> dict | None:
"""
LLMが出力したJSONのキー名を正規化する。
キーの先頭語でマッチングして正規キー名に修正する。
"""
normalized = {}
for raw_key, value in raw.items():
matched = False
for prefix, canonical_key in KEY_NORMALIZE_MAP.items():
if raw_key.startswith(prefix):
normalized[canonical_key] = value
matched = True
break
if not matched:
normalized[raw_key] = value
return normalized
def _extract_json_from_response(text: str) -> dict | None:
"""LLMレスポンスからJSONを抽出し、キー名を正規化する。"""
raw = None
# 戦略1: そのままパース
try:
raw = json.loads(text.strip())
except json.JSONDecodeError:
pass
# 戦略2: コードブロック内のJSONを抽出
if raw is None:
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
try:
raw = json.loads(m.group(1))
except json.JSONDecodeError:
pass
# 戦略3: テキスト中の最初の {...} を抽出
if raw is None:
m = re.search(r"\{[^{}]*\}", text)
if m:
try:
raw = json.loads(m.group(0))
except json.JSONDecodeError:
pass
if raw is None:
return None
return _normalize_keys(raw)
def extract_traits(
dialogue: str,
max_retries: int = 5,
timeout_sec: float = 300.0,
) -> dict | None:
"""対話ログからLLMで行動特性スコアを抽出する。"""
payload = {
"prompt": (
f"<|system|>\n{SYSTEM_PROMPT}<|end|>\n"
f"<|user|>\n以下の対話ログを分析してください:\n{dialogue}<|end|>\n"
f"<|assistant|>\n"
),
"n_predict": 256,
"temperature": 0.1,
"top_p": 0.9,
"stop": ["<|end|>", "\n\n"],
}
for attempt in range(max_retries):
try:
resp = httpx.post(
f"{LLAMA_SERVER_URL}/completion",
json=payload,
timeout=timeout_sec,
)
resp.raise_for_status()
content = resp.json().get("content", "").strip()
traits = _extract_json_from_response(content)
if traits is None:
print(f" [attempt {attempt + 1}] JSON抽出失敗")
continue
if not _validate_traits(traits):
print(f" [attempt {attempt + 1}] バリデーション失敗: {traits}")
continue
return traits
except (httpx.HTTPError, KeyError) as e:
print(f" [attempt {attempt + 1}] エラー: {type(e).__name__}: {e}")
time.sleep(2)
print(" 最大リトライ回数超過。抽出失敗。")
return None
def traits_to_vector(traits: dict) -> np.ndarray:
return np.array([traits[k] for k in TRAIT_KEYS], dtype=np.float32)
def _validate_traits(traits: dict) -> bool:
for key in TRAIT_KEYS:
if key not in traits:
return False
val = traits[key]
if not isinstance(val, (int, float)):
return False
if val < 0.0 or val > 1.0:
return False
return True
3.3 pgvector_store.py ── ベクトルの格納と検索
"""pgvector_store.py ── pgvectorへの格納・コサイン類似度検索"""
import json
import numpy as np
import psycopg2
import psycopg2.extras
DB_CONFIG = {
"host": "localhost",
"port": 5432,
"dbname": "matchdb",
"user": "matchuser",
"password": "matchpass",
}
def get_connection():
return psycopg2.connect(**DB_CONFIG)
def upsert_profile(
user_id: str,
display_name: str,
trait_vector: np.ndarray,
raw_traits: dict,
) -> None:
"""ユーザープロファイルをupsertする"""
vec_str = "[" + ",".join(f"{v:.4f}" for v in trait_vector) + "]"
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO user_profiles (user_id, display_name, trait_vector, raw_traits)
VALUES (%s, %s, %s::vector, %s)
ON CONFLICT (user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
trait_vector = EXCLUDED.trait_vector,
raw_traits = EXCLUDED.raw_traits,
created_at = NOW()
""",
(user_id, display_name, vec_str, json.dumps(raw_traits)),
)
conn.commit()
def search_similar(
target_user_id: str,
top_k: int = 3,
) -> list[dict]:
"""
指定ユーザーに対してコサイン類似度が高い候補をtop_k件返す。
自分自身は除外する。
"""
with get_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""
SELECT
user_id,
display_name,
raw_traits,
1 - (trait_vector <=> (
SELECT trait_vector FROM user_profiles WHERE user_id = %s
)) AS cosine_similarity
FROM user_profiles
WHERE user_id != %s
ORDER BY trait_vector <=> (
SELECT trait_vector FROM user_profiles WHERE user_id = %s
)
LIMIT %s
""",
(target_user_id, target_user_id, target_user_id, top_k),
)
return [dict(row) for row in cur.fetchall()]
pgvectorの演算子について補足: <=> はコサイン距離(cosine distance = 1 - cosine similarity)を返します。ORDER BY <=> は距離が小さい順(=類似度が高い順)にソートします。類似度として表示する際は 1 - distance で変換しています。
3.4 main.py ── 一気通貫デモ
"""main.py ── 5人分の対話ログから特性抽出→pgvector格納→類似度検索"""
from trait_extractor import extract_traits, traits_to_vector
from pgvector_store import upsert_profile, search_similar
# ダミー対話ログ(AIチャットボットとの会話を想定)
# 実運用では、ユーザーがチャットUIで入力した対話履歴がここに入る
DUMMY_DIALOGUES = {
"user_001": {
"name": "Aさん",
"dialogue": (
"仕事は互いに自立して続けたいです。家計も基本は分けつつ、"
"共通口座で生活費を管理するのが理想です。"
"揉めた時は冷静に話し合ってルールを決めたいです。"
"家事は得意不得意で分担したい。"
"転勤があっても、話し合って対応できると思います。"
),
},
"user_002": {
"name": "Bさん",
"dialogue": (
"お金のことはあまり細かく考えたくないです。"
"楽しいことにお金を使いたいし、旅行も好き。"
"喧嘩したら少し距離を置いて冷静になってから話す派です。"
"家事は正直あまり得意じゃないけど、"
"できることはやります。"
"引っ越しや転勤はちょっと不安かな。"
),
},
"user_003": {
"name": "Cさん",
"dialogue": (
"将来のために毎月決まった額を貯金しています。"
"ボーナスも半分は貯蓄に回します。"
"意見が合わない時はまずお互いの考えを紙に書き出して整理します。"
"料理も掃除も半々でやるのが当たり前だと思っています。"
"環境が変わることには割と慣れています。転職も2回しました。"
),
},
"user_004": {
"name": "Dさん",
"dialogue": (
"お財布は完全に一緒にして、毎月の支出を家計簿アプリで管理したいです。"
"揉めた時は感情的になりがちなので、そこは直したいと思っています。"
"家事は相手にお任せしたい部分が正直あります。"
"今の土地にずっと住みたいです。地元が好きなので。"
),
},
"user_005": {
"name": "Eさん",
"dialogue": (
"共働き前提で、収入は比率に応じて生活費を出し合う形がフェア。"
"言い合いになったら、翌日に改めて話すようにしています。"
"育児は二人でやるもの。家事も当番制がいいです。"
"海外転勤もありえる仕事なので、柔軟に考えてくれる人が理想です。"
),
},
}
def main():
print("=" * 60)
print("行動特性抽出 → pgvector格納 → 類似度検索 デモ")
print("=" * 60)
# Phase 1: 全ユーザーの特性を抽出・格納
print("\n--- Phase 1: 特性抽出 & DB格納 ---")
for user_id, data in DUMMY_DIALOGUES.items():
print(f"\n[{user_id}] {data['name']} の対話ログを分析中...")
traits = extract_traits(data["dialogue"])
if traits is None:
print(f" ⚠ {data['name']} の特性抽出に失敗。スキップします。")
continue
vector = traits_to_vector(traits)
print(f" 抽出結果: {traits}")
print(f" ベクトル: {vector}")
upsert_profile(user_id, data["name"], vector, traits)
print(f" → pgvectorに格納完了")
# Phase 2: 各ユーザーについて類似度検索
print("\n--- Phase 2: 類似度検索 ---")
for user_id, data in DUMMY_DIALOGUES.items():
print(f"\n[{data['name']}] に類似する候補:")
results = search_similar(user_id, top_k=3)
for i, r in enumerate(results, 1):
sim = r["cosine_similarity"]
traits = r["raw_traits"]
print(f" {i}. {r['display_name']} (類似度: {sim:.4f})")
print(f" 特性: {traits}")
if __name__ == "__main__":
main()
4. 実行結果
以下は、llm-jp-4-8b-instruct(Q4_K_M、CPU推論)で実際に実行した際の出力です。
注: 以下はデモ用の対話ログ5件による検証結果です。
temperature=0.1でもLLMの出力にはわずかな揺れがあり、読者の環境(モデルのビルド、量子化方式、OS)によって数値が変動する可能性があります。対話ログが人為的に構成されたデモデータであるため、実際のユーザー対話では異なる分布になることが想定されます。
============================================================
行動特性抽出 → pgvector格納 → 類似度検索 デモ
============================================================
--- Phase 1: 特性抽出 & DB格納 ---
[user_001] Aさん の対話ログを分析中...
LLM呼び出し中 (attempt 1/5)... 13.0秒
生レスポンス: {"financial_management": 0.8, "conflict_resollution": 0.9,
"household_equity": 0.7, "lifestyle_flexibility": 0.8}
抽出結果: {'financial_management': 0.8, 'conflict_resolution': 0.9,
'household_equity': 0.7, 'lifestyle_flexibility': 0.8}
ベクトル: [0.8 0.9 0.7 0.8]
→ pgvectorに格納完了
[user_002] Bさん の対話ログを分析中...
LLM呼び出し中 (attempt 1/5)... 12.9秒
生レスポンス: {"financial_management": 0.2, "conflict_resoltion": 0.5,
"household_equity": 0.3, "lifestyle_flexibility": 0.3}
抽出結果: {'financial_management': 0.2, 'conflict_resolution': 0.5,
'household_equity': 0.3, 'lifestyle_flexibility': 0.3}
ベクトル: [0.2 0.5 0.3 0.3]
→ pgvectorに格納完了
[user_003] Cさん の対話ログを分析中...
LLM呼び出し中 (attempt 1/5)... 13.2秒
生レスポンス: {"financial_management": 0.8, "conflict_resoltion": 0.8,
"household_equity": 0.9, "lifestyle_flexibiliti": 0.8}
抽出結果: {'financial_management': 0.8, 'conflict_resolution': 0.8,
'household_equity': 0.9, 'lifestyle_flexibility': 0.8}
ベクトル: [0.8 0.8 0.9 0.8]
→ pgvectorに格納完了
[user_004] Dさん の対話ログを分析中...
LLM呼び出し中 (attempt 1/5)... 13.3秒
生レスポンス: {"financial_management": 0.8, "conflict_resollution": 0.3,
"household_equity": 0.2, "lifestyle_flexibilitiy": 0.2}
抽出結果: {'financial_management': 0.8, 'conflict_resolution': 0.3,
'household_equity': 0.2, 'lifestyle_flexibility': 0.2}
ベクトル: [0.8 0.3 0.2 0.2]
→ pgvectorに格納完了
[user_005] Eさん の対話ログを分析中...
LLM呼び出し中 (attempt 1/5)... 13.7秒
生レスポンス: {"financial_management": 0.8, "conflict_resoltion": 0.7,
"household_equity": 0.9, "lifestyle_flexibility": 0.8}
抽出結果: {'financial_management': 0.8, 'conflict_resolution': 0.7,
'household_equity': 0.9, 'lifestyle_flexibility': 0.8}
ベクトル: [0.8 0.7 0.9 0.8]
→ pgvectorに格納完了
--- Phase 1 完了: 5/5 人の抽出成功 ---
--- Phase 2: 類似度検索 ---
[Aさん] に類似する候補:
1. Cさん (類似度: 0.9910)
2. Eさん (類似度: 0.9845)
3. Bさん (類似度: 0.9626)
[Bさん] に類似する候補:
1. Aさん (類似度: 0.9626)
2. Cさん (類似度: 0.9446)
3. Eさん (類似度: 0.9263)
[Cさん] に類似する候補:
1. Eさん (類似度: 0.9985)
2. Aさん (類似度: 0.9910)
3. Bさん (類似度: 0.9446)
[Dさん] に類似する候補:
1. Aさん (類似度: 0.8370)
2. Eさん (類似度: 0.8232)
3. Cさん (類似度: 0.8204)
[Eさん] に類似する候補:
1. Cさん (類似度: 0.9985)
2. Aさん (類似度: 0.9845)
3. Bさん (類似度: 0.9263)
============================================================
全処理完了: 66.0秒
============================================================
結果の読み方
CさんとEさんのコサイン類似度は0.9985と高い値です。両者のベクトルを比較すると、Cさん [0.8, 0.8, 0.9, 0.8]、Eさん [0.8, 0.7, 0.9, 0.8] で、conflict_resolution が0.1異なるだけの近い方向のベクトルです。
一方、Dさん [0.8, 0.3, 0.2, 0.2] は金銭管理は堅実(0.8)だが家事分担・柔軟性が低く、ベクトルの方向が他の全員と異なるため、最高でも0.837(Aさんとの類似度)にとどまっています。
デモデータの限界: 類似度が0.82〜0.99の高い領域に集中しているのは、4次元という低次元空間の特性と、デモ用に構成した5件の対話ログの分布に起因します。実際のユーザーデータ(数千〜数万件、対話内容もばらつきが大きい)では、ベクトルの分布がより広がるため、類似度の分布もこれとは異なると考えられます。
コサイン類似度の構造的課題: Bさん [0.2, 0.5, 0.3, 0.3] は全軸で低〜中スコアですが、Aさん [0.8, 0.9, 0.7, 0.8] との類似度が0.9626と高く出ています。これはコサイン類似度がベクトルの「方向」のみを見て「大きさ」を無視するためです。実運用では、コサイン類似度に加えてユークリッド距離も併用し、「方向が近く、かつレベルも近い」候補を優先する設計が必要です。
5. ハマりどころと対策
5.1 llm-jp-4-8bのキー名タイポ問題
本検証環境(llm-jp-4-8b-instruct Q4_K_M、llama-server 2026年4月ビルド、CPU推論)において、JSON出力時にキー名が正規の綴りと異なる現象が観測されました。
本環境で観測されたタイポパターン(temperature=0.1、デモデータ5件での観測):
conflict_resoltion ← "u" が欠落
conflict_resollution ← "l" が重複
lifestyle_flexibilitiy ← "i" が重複
lifestyle_flexibiliti ← 末尾 "y" → "i"
同一の対話ログに対して5回試行で同じタイポが再現しましたが、サンプル数が少ないため、これがモデル固有の癖なのか、プロンプト構成・sampling設定・量子化方式に依存する現象なのかは切り分けられていません。可能性としては、トークナイザが英語の長い複合語を不安定にトークン分割していることが考えられますが、断定はできません。
対策: 原因によらず、キー名の先頭語(financial, conflict, household, lifestyle)で正規キー名にマッピングする正規化層を挟むことで、5件全件の抽出に成功しました。この手法はローカルLLMでJSON構造化出力を行う際の防御的プラクティスとして有用です。
5.2 llama-serverのGrammar制約が動作しないケース
当初はllama-serverの grammar パラメータ(GBNF文法)でJSON出力形式を強制する設計でしたが、本環境ではGrammar制約が期待通りに動作しないケースを確認しました。Grammar制約付きリクエストを送信しても、LLMが制約外のテキストを生成する場合がありました。
原因の可能性としては、llm-jp-4のトークナイザ(Unigram byte-fallback方式)とllama.cppのgrammar実装との互換性、llama-serverのビルドバージョン、あるいはモデル固有のchat templateとの干渉が考えられます。他のビルドやモデルでは正常に動作する可能性があるため、Grammar制約が効かないことをllm-jp-4一般の問題として断定するものではありません。
教訓: Grammar制約は「動けば最強」だが、動作しない場合のフォールバック戦略(正規表現抽出+キー名正規化)を必ず用意すること。
5.2 4次元ベクトルでのコサイン類似度の特性
4次元は婚活マッチングの特性空間としては次元が少なく、コサイン類似度が0.95以上に集中しやすい傾向があります。これは高次元空間では直交方向が増えて類似度が分散するのに対し、低次元では多くのベクトルが「似た方向」を向きやすいためです。
実運用で差をつけるには以下の選択肢があります。
- 特性軸を8〜16次元に拡張する(LLMのプロンプトとGBNF文法を対応させる)
- コサイン類似度ではなくユークリッド距離(
<->演算子)を主指標にする - 重み付きコサイン類似度を使う(軸ごとの重要度をパラメータ化し、ファネル分析で最適化する)
5.3 pgvectorのインデックス選択
レコードが数千件以下ならシーケンシャルスキャンで十分高速です。数万件を超える場合、本稿で使っている hnsw インデックスが推奨されます。ivfflat はクラスタ数のチューニングが必要で、データ分布が事前にわからない婚活マッチングでは hnsw の方が安定します。
6. 公共システムとしての設計要件(実装しない範囲の補足)
本稿は「LLM特性抽出→pgvectorマッチング」の技術検証に特化していますが、公共婚活システムとして実装する場合には以下の設計層が必要です。
JPKI(公的個人認証)連携: マイナンバーカードによる実在証明・年齢確認をシステムの入り口で必須化し、身元保証をAPI経由で自動化します。これにより、独身証明書のPDFアップロードと目視審査にかかるコスト・離脱率を大幅に削減できます。J-LISとの接続申請が必要なため、技術検証の範囲外としました。
広域連携(Federation): 単一自治体内ではマッチング母数が不足します。複数自治体が独自UIを維持しつつAPIレベルでDBを連携させ、ユーザーがオプトインで開示範囲を選択できるフェデレーション設計が不可欠です。
ファネル分析によるアルゴリズム改善: JPKI完了率→プロファイリング完了率→お見合い成立率→交際継続率→成婚率の各段階をKPIとして計測し、データに基づいてマッチングアルゴリズムの重み付けを調整するサイクルを運用に組み込みます。
官民分離モデル(データ基盤と実働の分離): 自治体が婚活支援のすべてを自前で運営する必要はありません。自治体はデータ基盤(JPKI認証・特性ベクトルDB・マッチングAPI)を公共インフラとして整備し、お見合い調整・交際フォロー・成婚支援といった実働部分は民間事業者にAPI経由で開放する設計が現実的です。埼玉県「恋たま」は既に官民連携の協議会運営に移行しており、この方向の先行事例といえます。民間事業者にとっては、JPKI認証済みの身元保証付きユーザープールにアクセスできることが参入のインセンティブになり、自治体にとっては成婚支援のノウハウを持つ民間の実務力を活用できます。ファネルKPIの計測は自治体側が握り、どの民間事業者の支援が成婚率向上に寄与しているかをデータで評価する構造にすることで、公金投入の説明責任も果たせます。
7. まとめ
本稿では、国産LLM llm-jp-4-8b-instruct とNumPy(pgvector互換のコサイン類似度計算)を使い、対話ログからの行動特性抽出→ベクトル格納→類似度検索の一気通貫パイプラインを実装し、CPU推論で実行しました。
技術的に確認できたこと:
- llm-jp-4-8bはJSON構造化出力が可能だが、本環境ではキー名の綴りが正規と異なる出力が観測された。キー名正規化層の組み込みが防御策として有効
- llama-serverのGBNF Grammar制約は、本環境(llm-jp-4-8b + 2026年4月ビルド)では期待通り動作しなかった。モデル・ビルドの組み合わせに依存する可能性があるため、フォールバック戦略を必ず用意すること
- 4次元ベクトル・デモデータ5件の条件下では、コサイン類似度が0.82〜0.99の高い領域に集中し差が出にくい。次元拡張またはユークリッド距離の併用が実運用では必要
- CPU推論(llm-jp-4-8b Q4_K_M)で1件あたり約13秒、5件66秒で完了。GPUジョブを止めずに検証可能
残課題:
- LLMの特性抽出精度の定量評価(同一対話ログに対する出力の分散測定)
- 「似た者マッチ」と「補完マッチ」の切り替えロジック
- 特性軸の妥当性検証(心理学的裏付け)
- 32B-A3Bモデルでの同一検証(キー名タイポが解消されるかの確認)
ソースコード一式は trait_extractor.py、pgvector_store.py、main.py の3ファイルで完結します。Docker/PostgreSQL不要、pip install httpx numpy のみで再現可能です。pgvector版のコードは付録に記載しています。
付録:国規模で運用する場合のインフラ試算
本稿は5件のデモデータによる技術検証ですが、これを国規模の公共婚活基盤に拡張した場合のインフラ要件を試算します。以下の数値はすべて仮定モデルに基づく設計試算であり、実測値ではありません。実際の要件はデータ分布、同時接続数、ピーク特性等に大きく依存します。
現行の自治体婚活支援の規模感
実在する自治体婚活支援システムの登録者数を参考値として示します。
| システム | 登録者数 | 備考 |
|---|---|---|
| 埼玉県「恋たま」 | 約22,500人 | 令和7年3月末時点、成婚退会577組 |
| 愛媛県「えひめ結婚支援センター」 | 約11,300組カップル成立 | AI導入後5年半で成婚845組以上 |
都道府県の6割超がAIマッチングを導入済みという報道(日経グローカル、2025年11月)があり、全国の公的婚活支援の累計登録者は推定で数十万人規模と見られます。
シナリオ別のDB・LLM要件
以下、3段階のシナリオで試算します。
前提条件:
- 特性ベクトル: 16次元に拡張(float32 × 16 = 64バイト/ユーザー)
- 対話ログ: 平均2,000トークン/ユーザー(チャット5〜10往復相当)
- 特性抽出は登録時の1回+定期更新(月1回程度)のバッチ処理
シナリオA:単一県レベル(登録者 〜30,000人)
埼玉県「恋たま」相当の規模です。
| 項目 | 要件 |
|---|---|
| pgvector | レコード30,000件、ベクトルデータ約2MB。PostgreSQL単体で十分。HNSWインデックスのメモリ消費も数十MB程度 |
| 類似度検索 | 30,000件のHNSW検索はミリ秒オーダー。単一PostgreSQLインスタンスで処理可能 |
| LLM推論(特性抽出) | 新規登録のピーク想定: 100人/日。llm-jp-4-8Bで1件あたり約10秒とすると、1,000秒(約17分)/日。GPU 1枚(RTX 3090〜A10G相当)で余裕 |
| インフラ構成 | VM 1台(GPU付き)+ PostgreSQL 1台。月額コスト概算: クラウドGPUインスタンス 5〜10万円+DB 1〜2万円 |
シナリオB:広域連携圏(登録者 〜300,000人)
複数県がフェデレーションで接続し、マッチングプールを共有する構成です。
| 項目 | 要件 |
|---|---|
| pgvector | レコード300,000件、ベクトルデータ約20MB。HNSWインデックスは数百MB。PostgreSQL単体でまだ対応可能だが、リードレプリカの検討開始ライン |
| 類似度検索 | HNSWで数ミリ秒〜数十ミリ秒。同時接続ユーザーが数百人を超える場合はリードレプリカで負荷分散 |
| LLM推論 | 新規登録ピーク: 1,000人/日。8Bモデルで約2.8時間/日。32B-A3Bを使う場合は推論速度が3〜5倍遅くなるため、GPU 2〜4枚またはバッチキューイングが必要 |
| インフラ構成 | GPU 2〜4枚(推論用)+ PostgreSQL(プライマリ+リードレプリカ)。月額概算: 30〜60万円 |
シナリオC:全国統合基盤(登録者 〜3,000,000人)
47都道府県の婚活支援を統合し、国の基盤として運用する想定です。国勢調査(2020年)で25〜39歳の未婚者は約800万人であり、登録率10〜30%と仮定すると100万〜300万人規模が現実的な上限です。
| 項目 | 要件 |
|---|---|
| pgvector | レコード3,000,000件、ベクトルデータ約200MB。HNSWインデックスは数GB。パーティショニング(地域別シャーディング)を検討。pgvectorは単一テーブル数百万件でも実用的だが、書き込み頻度が高い場合はCitusなどの分散PostgreSQLも選択肢 |
| 類似度検索 | 300万件のHNSW検索は数十ミリ秒。ただし「全国から候補を出す」場合と「地域圏内に限定する」場合で設計が変わる。多くのユーザーは近隣地域の候補を優先するため、地域パーティションで検索対象を絞るのが現実的 |
| LLM推論 | 本格運用では特性抽出のスループットが律速になる。月間新規10万人の場合、8Bモデルで約278時間/月(GPU 1枚あたり)。A100 ×4〜8枚、またはvLLMによるバッチ推論でスループットを確保。32B-A3Bは精度が高いが、スループット要件との兼ね合いでモデルサイズの選定が必要 |
| インフラ構成 | GPU クラスタ(推論用、A100 ×4〜8)+ PostgreSQL クラスタ(プライマリ+リードレプリカ×2〜3、地域パーティション)+ バッチジョブキュー。月額概算: 200〜500万円 |
LLMモデルサイズの選定指針
| モデル | VRAM要件 (Q4_K_M) | 推論速度目安 | JSON出力安定性 | 推奨シナリオ |
|---|---|---|---|---|
| llm-jp-4-8b-instruct | 6GB | 〜30 tok/s (RTX 3090) | Grammar制約で安定、リトライ推奨 | A(単一県) |
| llm-jp-4-32b-a3b (MoE) | 20GB | 〜15 tok/s (RTX 3090) | Grammar制約で高安定 | B(広域連携) |
| llm-jp-4-32b-a3b (vLLM) | 40GB+ (A100) | バッチ処理で高スループット | カスタムパーサー必須 | C(全国基盤) |
注: 推論速度はGPU・量子化・コンテキスト長に大きく依存します。上記は2,000トークン入力時のおおよその目安です。vLLMでの32B-A3B運用にはcookbook同梱のカスタムreasoningパーサーが必要です(Qiita @ntaka329 氏の検証記事参照)。
コスト比較:クラウドLLM API vs ローカル推論
特性抽出を外部APIに委託する選択肢も検討に値します。
- ローカル推論のメリット: 対話ログ(個人の価値観・行動特性)を外部に送信しない。公共システムとして個人情報保護の観点で優位。運用コストはGPUの固定費のみ。
- クラウドAPI(GPT-4o等)のメリット: GPU調達不要、スケーラビリティが高い。ただし、1件あたり2,000トークン入力+256トークン出力で約0.01〜0.03ドル。300万ユーザーの初回抽出で3〜9万ドル(約450万〜1,350万円)。月次更新を含めると年間数千万円規模。
- 判断基準: 公共婚活システムでは、ユーザーの対話ログが極めてセンシティブな個人情報(金銭感覚、家事育児観、衝突対処パターン)を含むため、ローカル推論が原則です。国産LLMであるllm-jp-4を選定する意義もここにあります。