見出し画像

AI AgentのRAG精度、上げようとして間違えてませんか? 正規化をサボると全部崩れてしまう話

AI Agentを開発する際の一番悩みどころが、”RAGの精度” です。

今回はそんなRAGの精度向上に避けて通れない”データ正規化”

①ML + Embedding で 大量に絞り込み  <コスト低>
②残ったレコードを LLM  に一括で判定   <コスト低>

という2つの方式にて、コストを掛けない ”ハイブリッド”  プロセスで実現させる試みを行ってみました。


1.RAGを入れたのに全然答えが合わない、その原因9割はここ


RAGを構成した。そのデータ格納先として、ベクトルDBも選んだ。チャンクサイズも調整した。プロンプトも何度も書き直した。

それでも答えがズレる😿

多くの方が、こうした経験をされているのではないでしょうか?

原因を探るとたいてい同じ場所に行き着きます。ベクトル検索に渡す前のデータが汚い

RAGの精度は、ざっくりと以下のように分解できるかと。

RAG精度 = チャンク品質 × 検索精度 × LLM生成品質

チャンクの「品質」というのはサイズだけの話ではなく、同じ意味を持つエンティティが複数の表記で存在してしまっていること、これが本質的な問題なのかと。

ベクトルデータ空間では、「テクノ物産株式会社」と「TECHNO BUSSAN」は別のエンティティとして扱われます。

正規化されていないデータをそのまま投入すると、検索ヒット率が下がり、LLMが参照すべき文書を見つけられなくなります。

この記事では、その問題を ML・Cohere を組み合わせて解決するパイプラインを実装ガチ系で解説してゆきます。

2. 「テクノ物産」が4種類存在する世界で検索しても無駄な話

実際のエンタープライズデータに触ると、こういう状況は日常茶飯事です。

# 同じ企業が4つの顔を持っている
records = [
    "テクノ物産株式会社",      # 正式名称
    "TECHNO BUSSAN Co., Ltd.", # 英語表記
    "(株)テクノ物産",         # 略称
    "テクノブッサン",           # カタカナ読み
]

これがRAGのチャンクに混在していると何が起きるでしょうか。

  • 「テクノ物産の売上を教えて」と聞いたとき、ベクトル検索は4つのうち1〜2件しかヒットさせない

  • LLMは残り2〜3件の情報を参照できず、不完全な回答を生成する

  • ユーザーからは「なんか情報が足りない」という感想が返ってくる

これが正規化の失敗パターン①:表記ゆれです。

他にも2つあります。

パターン②:重複レコード
同じ文書が微妙に違うメタデータで複数回インデックスされてしまった状態。検索結果に同じ内容が重複して返ってきて、LLMのコンテキストウィンドウを無駄に消費してしまいます。

パターン③:構造不整合
「売上:1,200万円」「売上: 12000000」「売上高 1200万」が別フィールドとして存在する不整合。数値の比較や集計が正しくできなくなります。

これら3つを放置したままRAGを改善しようとすると、いくら頑張っても上限に当たってしまうというわけです。

3. 全件LLMに投げたら月45,000ドルかかった件


じゃあ、全部LLMに投げて正規化すればいいじゃないか!

最初は、私もそう考えました。実際、LLMは表記ゆれの解消が得意です。でも素直に全件投げると、現実的な ”壁"  にぶち当たります。

# ナイーブな実装
records = load_all_records()  # 100万件

for record in records:
    prompt = f"以下を正規化してください: {record}"
    result = cohere.chat(message=prompt)  # 1件ずつ投げる
    save(result)

コスト試算(概算):
※荒い試算なので、ご参考程度までに留めてください。

100万件 × 平均500トークン = 5億トークン
Cohere Command-R+: $2.5 / 1Mトークン
→ 1回の実行で $1,250
毎日実行したら月 $37,500
Claude Sonnet を使ったら月 $45,000+
※円換算156円で、¥7,020,000/月

レイテンシも問題です。直列で処理すると100万件を消化するのに数日かかる見込みとなります。

つまり、LLMは「全件レコードの正規化要求を投げる道具」じゃない!「判断が難しいケースに集中させる道具」! とみなすべきなのです。

そのためにまず必要なのが、ML(機械学習)による高速フィルタリングです。

4. MLに「怪しいやつだけ」を選ばせる — MinHash の使い方

考え方はシンプルです。

100万件全体
  ↓ MLで類似候補ペアだけ抽出(高速・安い)
数千ペア
  ↓ LLMで意味判断(精度高い・高コスト)
正規化済みデータ

MLのステップでやることは「これとこれは同じエンティティかもしれない」という候補ペアの絞り込みです。

正解を出す必要はなく、”怪しいものを拾えればいい” という感じで。

MinHash / LSH で文字列の近似重複を爆速検出

実際に、「テクノ物産」という会社名の表記ゆれを、自動検出させてみようと思います。

"MinHash" というライブラリを使って類似性を計測してみます。

・Jaccard(※)類似度を高速に近似計算するためのアルゴリズム
※2つの集合がどれだけ「似ているか」を0〜1で表す指標
 →1に近い:よく似ている
        0に近い:ほとんど共通要素がない

大規模な集合間の類似度計算に使われます。

・まずは必要ライブラリのインストール

pip install datasketch
pip install pykakasi
pip install sentence-transformers

では次のサンプルコードを使ってこの表記ゆれ課題を検出させてみます。
・以下を実行。私はVS Code + Jupyterの実行環境で実施してます。


from datasketch import MinHash, MinHashLSH

def get_minhash(text: str, num_perm: int = 128) -> MinHash:
    m = MinHash(num_perm=num_perm)
    # 文字列を3-gramに分割してシャングル化
    shingles = {text[i:i+3] for i in range(len(text) - 2)}
    for s in shingles:
        m.update(s.encode("utf-8"))
    return m

# LSHインデックスを構築(Jaccard類似度0.5以上を候補に)
lsh = MinHashLSH(threshold=0.5, num_perm=128)

records = ["テクノ物産株式会社", "テクノ物産(株)", "テクノ物産",  "テクノ物産 株式会社", "全く別の会社"]

for i, record in enumerate(records):
    lsh.insert(f"record_{i}", get_minhash(record))

# 類似候補ペアを抽出
candidate_pairs = []
for i, record in enumerate(records):
    results = lsh.query(get_minhash(record))
    for r in results:
        j = int(r.split("_")[1])
        if i < j:
            candidate_pairs.append((i, j, record, records[j]))

print(f"候補ペア数: {len(candidate_pairs)}")
# 100万件 → 数千ペアに圧縮
for pair in candidate_pairs:
    print(f"{pair[2]}  ⟷  {pair[3]}")

1)MinHash、文字列を3-gram分割させてます。以下のようなイメージ。

"テクノ物産" を3-gramに分割すると:

テクノ
 クノ物
  ノ物産

→ {"テクノ", "クノ物", "ノ物産"}

文字単位で比較するので、表記ゆれに強いのがポイントです。
2)records:実際の比較サンプル用データ

実行が完了すると、以下のように出力されます。

実行結果の出力

ここで、分ったことは、
以下4つの表記文字列が類似しているという判定がだされ、

テクノ物産株式会社
テクノ物産(株)        ← 略称
テクノ物産            ← 省略形
テクノ物産 株式会社   ← スペースあり

「全く別の会社」は検知されず、誤検知はゼロでした。さらに、完全一致ではなく曖昧一致を検出できるのがMinHashの強みであることが分かりました。

この工程を経て、大量レコード群の類似性の抽出に成功しています。

5.Embeddingの使い方

まだまだ残っている課題への対応を進めます。

文字列が全然違っても意味が同じケース(「テクノブッサン」と「テクノ物産」)はMinHashでは残念ながら拾えません。

そこで、”ベクトル変換=Embedding” を使います!
文字間の距離をスコア化して、類似性を計測する方式です。

【MinHash →  Embeddingという流れへの補足】
MinHashは「同じ言語内の表記ゆれ」に強く、 Embeddingは「言語をまたいだ意味的類似」に強いです。
2つは補完関係にあり、大規模データでは MinHash → Embedding の順で使うと最もコスト効率が高いです。

Cohere Embed v3 で意味的類似度を計算

今回変換用に使うLLM Embedは Cohere Embedです。

テキストを高次元のベクトルに変換するモデルで、意味的な類似性の計算、検索、分類、RAG(Retrieval-Augmented Generation)パイプラインなどに活用されます。

・まずは必要ライブラリのインストール

 pip install cohere numpy faiss-cpu scikit-learn

次のサンプルコードを使ってこの表記ゆれ課題を検出させてみます。
※MinHashは同一言語内の文字列類似に特化しているため、 英名↔和名の検出には不向きです。 このセクションではEmbeddingの意味的類似検出に焦点を当てます。

import cohere
import numpy as np
import faiss
import re
from sklearn.cluster import DBSCAN
from sklearn.metrics.pairwise import cosine_distances
from itertools import combinations

COHERE_API_KEY = "your-cohere-api-key"
co = cohere.Client(COHERE_API_KEY)

records = [
    "テクノ物産株式会社",
    "TECHNO BUSSAN",
    "テクノブッサン",
    "株式会社山田製作所",
    "ヤマダ製作所",
    "Yamada Seisakusho",
    "全く関係ない別会社ABC",
]

# =====================
# Step 1: 正規化
# =====================
def normalize_company_name(name: str) -> str:
    noise_words = [
        "株式会社", "有限会社", "合同会社", "(株)", "(株)",
        "製作所", "工業", "商事", "産業", "ホールディングス"
    ]
    result = name
    for word in noise_words:
        result = result.replace(word, "")
    return result.strip()

normalized = [normalize_company_name(r) for r in records]
print("=== 正規化後 ===")
for orig, norm in zip(records, normalized):
    print(f"  {orig} → {norm}")

# =====================
# Step 2: Embedding
# =====================
def embed_records(texts: list[str]) -> np.ndarray:
    response = co.embed(
        texts=texts,
        model="embed-multilingual-v3.0",
        input_type="search_document"
    )
    return np.array(response.embeddings, dtype="float32")

embeddings = embed_records(normalized)

# =====================
# Step 3: FAISS
# =====================
dimension = embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)
faiss.normalize_L2(embeddings)
index.add(embeddings)
distances, indices = index.search(embeddings, k=5)

# =====================
# Step 4: 距離確認
# =====================
dist_matrix = cosine_distances(embeddings)
print("\n=== ペア間コサイン距離 ===")
for i in range(len(records)):
    for j in range(i+1, len(records)):
        print(f"  {records[i]:<25} ↔ {records[j]:<25}: {dist_matrix[i][j]:.4f}")

# =====================
# Step 5: DBSCAN
# =====================
clustering = DBSCAN(eps=0.15, min_samples=2, metric="cosine")
labels = clustering.fit_predict(embeddings)

# クラスタと孤立に分類
clusters = {}
noise_indices = []
for idx, label in enumerate(labels):
    if label == -1:
        noise_indices.append(idx)
    else:
        clusters.setdefault(label, []).append(records[idx])

# =====================
# Step 6: 結果出力
# =====================
print(f"\n候補クラスタ数: {len(clusters)}")
for label, group in clusters.items():
    print(f"  クラスタ {label}: {group}")

noise = [records[idx] for idx in noise_indices]
if noise:
    print(f"\n孤立レコード(LLM判定候補): {noise}")

1)def normalize_company_name():
  株式会社、(株)、などの共通した接尾語をノイズとして除去

2)embed_records():
各レコード(社名、住所など)を 1024次元のベクトル に変換。意味的・表記的に近いテキストは、ベクトル空間上でも近くなります。

3)faiss:FAISSで大量レコードでも高速に「似たもの候補」を絞り込み
・IndexFlatIP:内積で類似度計算
・normalize_L2:正規化することで内積=コサイン類似度になる
・k=5:各レコードの「近い順5件」を取得

4)DBSCANでクラスタリング
・eps=0.15:コサイン距離が0.15以内なら「近い」と判定
 ※実際には、eps=0.08では0件になりました。
  値のチューニングを要します。
・min_samples=2:2件以上集まればクラスタ形成
・label=-1:どのクラスタにも属さない「孤立レコード」

5)同じクラスタ = 名寄せ(マスタデータ統合)の候補 と提示

実行すると、以下のような結果が得られます。

ここでは、以下の結果が得られています。

・Embeddingで類似と判定できたもの:
→ クラスタ 0: ['テクノ物産株式会社', 'テクノブッサン'] ✅

これは、 ”名寄せ” 対象候補としてマッチさせられました!

・判定できない孤立レコード❌

孤立: ['TECHNO BUSSAN']         ← テクノ物産グループのはず
孤立: ['株式会社山田製作所']      ← 山田グループのはず
孤立: ['ヤマダ製作所']           ← 山田グループのはず
孤立: ['Yamada Seisakusho']     ← 山田グループのはず
孤立: ['全く関係ない別会社ABC']   ← 本当に孤立(正しい)

和名と英名の距離が遠く、ベクトルで判定できませんでした。

Embeddingを介してもまだまだ、”類似性の見極めができない”  課題は残っていますね。

しかし、ここに至るまで、かなりのボリューム数のデータの正規化が行われてきております。

また大事なポイントとして、LLMのベクトル変換は、圧倒的にコストが低いので、ここまでの正規化プロセスは低コストで済んでいるという事実です。

お金がかかってないのです!

6.LLMで 孤立化レコードもまとめて正規化

残念ながら、ベクトル変換で、”同じ”  と判定できないレコードデータがまだ残ってしまいます。

そこで、お待たせしました! ここから最後の砦となる LLM の登場です!
"孤立レコード群" の 類似判定をしてもらいます。

また、今回は判定対象レコードをバルクで投げて判定させているので、”一回だけのトークン利用”という超エコモードでの実行となってます!

import cohere
import json

COHERE_API_KEY = "your-cohere-api-key"
co = cohere.Client(COHERE_API_KEY)

# =====================
# Input
# =====================
# ↓ 前段のEmbeddingステップの出力結果をそのまま渡す
records_clustered = ["テクノ物産株式会社", "テクノブッサン"]

records_orphans = [
    "TECHNO BUSSAN",
    "株式会社山田製作所",
    "ヤマダ製作所",
    "Yamada Seisakusho",
    "全く関係ない別会社ABC",
]

# =====================
# LLM: 全レコードをグループ化
# =====================
def group_all_records(records: list[str]) -> list[dict]:
    prompt = f"""
以下の企業名リストを同一企業ごとにグループ化してください。

入力リスト: {json.dumps(records, ensure_ascii=False)}

以下のJSON形式のみで回答してください(説明不要):
[
  {{
    "normalized": "標準形(法人格を除去)",
    "variants": ["表記1", "表記2"],
    "confidence": 0.0〜1.0,
    "reason": "判断理由を一言で"
  }}
]

ルール:
- 言語が違っても同一企業なら同じグループにする
- 明らかに無関係なものは単独グループにする
- 法人格(株式会社、Co.,Ltd.など)は除去して標準形を決める
"""
    response = co.chat(
        model="command-r-plus-08-2024",
        message=prompt,
        temperature=0.0
    )
    raw = response.text.strip()
    raw = raw.replace("```json", "").replace("```", "").strip()
    return json.loads(raw)

# =====================
# Step 1: 全レコードをLLMでグループ化
# =====================
all_records = records_clustered + records_orphans

print("=== Step 1: 全レコードのグループ化 ===")
groups = group_all_records(all_records)
for group in groups:
    print(f"  標準形   : {group['normalized']}")
    print(f"  表記一覧 : {group['variants']}")
    print(f"  信頼度   : {group['confidence']} / 理由: {group['reason']}")

# =====================
# Step 2: 最終結果出力
# =====================
print("\n=== 最終グルーピング結果 ===")
for group in groups:
    if len(group['variants']) > 1:
        print(f"  ✅ 同一エンティティ: {group['normalized']}")
        print(f"     表記一覧 : {group['variants']}")
    else:
        print(f"  ❌ 単独    : {group['variants'][0]}")
    print()

・LLMに自然言語でグルーピングの指示(ルール)を与えてます。

ルール:
- 言語が違っても同一企業なら同じグループにする
- 明らかに無関係なものは単独グループにする
- 法人格(株式会社、Co.,Ltd.など)は除去して標準形を決める

実行すると、以下のようなLLMからの判定結果が返ります。

=== Step 1: 全レコードのグループ化 ===
  標準形   : テクノ物産
  表記一覧 : ['テクノ物産株式会社', 'テクノブッサン', 'TECHNO BUSSAN']
  信頼度   : 0.9 / 理由: 類似した表記
  標準形   : 山田製作所
  表記一覧 : ['株式会社山田製作所', 'ヤマダ製作所', 'Yamada Seisakusho']
  信頼度   : 0.8 / 理由: 類似した表記
  標準形   : 全く関係ない別会社ABC
  表記一覧 : ['全く関係ない別会社ABC']
  信頼度   : 1.0 / 理由: 他の企業と無関係

=== 最終グルーピング結果 ===
  ✅ 同一エンティティ: テクノ物産
     表記一覧 : ['テクノ物産株式会社', 'テクノブッサン', 'TECHNO BUSSAN']

  ✅ 同一エンティティ: 山田製作所
     表記一覧 : ['株式会社山田製作所', 'ヤマダ製作所', 'Yamada Seisakusho']

  ❌ 単独    : 全く関係ない別会社ABC

おおっ! 英名も和名も同一エンティティの文字列として正確に仕分けがされています!

これで、RAG向けデータ正規化が行えました。

最後に、このデータ正規化プロセスにおけるコスト観点の補足です。

【このパイプラインのコスト感】

・MinHash: ほぼ0円(ライブラリ処理のみ)
・Embedding: 約$0.001 / 1000件
 ※Cohere embed-multilingual-v3.0の実際の料金:
  $0.10 / 1M tokens 1件あたり平均10トークンとした場合の試算
・LLM判定: 1回のAPI呼び出しのみ

100万件のデータの正規化でも LLMへの投げは「1回」で済む。 これがハイブリッドの本質的な価値です。

7.まとめ

私も含めて、RAGの精度問題で悩んでいる方の多くは、ベクトルDBやプロンプト最適化アプローチにて日夜頑張ってこられたのではないでしょうか?

でもその前段の正規化が崩れていたら、何をやっても上限がでてしまっていたわけですね。

「入れるデータが綺麗なら、RAGはちゃんと動く」

シンプルな話ですが、そこに辿り着くまでの道のりが長いわけです。

この記事がその地図になれば、幸いです。


いいなと思ったら応援しよう!

スバルヒーロー よろしければ応援お願いします!