CSVカラム自動認識の実装パターン - ヒューリスティック vs LLM ベースアプローチの設計と使い分け

重岡 正 ·  Mon, March 23, 2026

EC バックオフィスの開発において、CSV インポート機能は避けて通れません。楽天・Amazon・Shopify・自社基幹システムなど、データソースごとにカラム名の命名規則はバラバラです。「商品コード」「item_code」「SKU」「品番」──すべて同じフィールドを指しているのに、システム側がこれを自動認識できなければ、ユーザーは毎回手動でマッピングを設定しなければなりません。

本記事では、CSV ヘッダーからデータ種別とカラムマッピングを自動推定する仕組みを、2 つのアプローチで設計・実装していきます。

  1. ヒューリスティックアプローチ: エイリアス辞書 + スコアリングによる決定論的マッチング
  2. LLM ベースアプローチ: 大規模言語モデルによる柔軟な推論

それぞれの実装を TypeScript で示しながら、精度・速度・コスト・運用性の観点から使い分けの指針を提示します。

前提:EC ドメインにおける CSV インポートの課題

典型的な EC バックオフィスでは、以下のようなデータ種別の CSV を日常的にインポートしています。

データ種別代表的なソースカラム名の例
商品マスタ基幹システム、PIM商品コード, product_id, SKU
受注データモール API エクスポート注文番号, order_no, 受注ID
在庫データWMS、倉庫管理在庫数, stock_qty, available
顧客データCRM エクスポート顧客名, customer_name, 氏名
配送データ配送業者 CSV追跡番号, tracking_no, 伝票番号

問題は、同じデータ種別であっても ソースシステムごとにカラム名が異なる ことです。さらに日本語・英語の混在、全角半角、括弧の有無といった表記揺れも加わります。

アプローチ 1:ヒューリスティックベースのカラム認識

設計思想

ヒューリスティックアプローチの核心は、既知のシステムプロファイル(データ種別ごとのフィールド定義とエイリアス辞書)を事前に定義し、入力された CSV ヘッダーとのマッチングスコアを算出するところにあります。

処理フローは以下の 3 ステップで構成されます。

graph LR
    A[CSV ヘッダー] --> B[正規化]
    B --> C[プロファイル照合]
    C --> D[最高スコア選択]
    D --> E[マッピング結果]

ステップ 1:既知システムプロファイルの定義

まず、データ種別ごとにターゲットフィールドとエイリアスの辞書を定義します。

// known-ec-systems.ts
 
export type EcDataType =
  | "product_master"
  | "orders"
  | "inventory"
  | "customers"
  | "shipments";
 
export type KnownSystemField = {
  targetField: string;
  aliases: string[];
};
 
export type KnownSystemProfile = {
  dataType: EcDataType;
  systemName: string;
  fields: KnownSystemField[];
};
 
export const KNOWN_SYSTEM_PROFILES: readonly KnownSystemProfile[] = [
  {
    dataType: "product_master",
    systemName: "商品マスタ",
    fields: [
      {
        targetField: "product_id",
        aliases: [
          "商品コード",
          "product id",
          "product code",
          "SKU",
          "品番",
          "item code",
          "商品ID",
          "jan code",
          "JAN",
        ],
      },
      {
        targetField: "product_name",
        aliases: [
          "商品名",
          "product name",
          "item name",
          "品名",
          "name",
          "タイトル",
          "title",
        ],
      },
      {
        targetField: "price",
        aliases: [
          "販売価格",
          "price",
          "unit price",
          "売価",
          "税込価格",
          "税抜価格",
          "定価",
        ],
      },
      {
        targetField: "category",
        aliases: [
          "カテゴリ",
          "category",
          "分類",
          "ジャンル",
          "genre",
          "商品カテゴリ",
        ],
      },
      {
        targetField: "description",
        aliases: [
          "商品説明",
          "description",
          "説明文",
          "商品詳細",
          "detail",
        ],
      },
      {
        targetField: "stock_quantity",
        aliases: [
          "在庫数",
          "stock",
          "quantity",
          "在庫",
          "stock qty",
          "数量",
        ],
      },
      {
        targetField: "brand",
        aliases: ["ブランド", "brand", "メーカー", "manufacturer", "maker"],
      },
      {
        targetField: "weight",
        aliases: ["重量", "weight", "重さ", "kg", "g"],
      },
    ],
  },
  {
    dataType: "orders",
    systemName: "受注データ",
    fields: [
      {
        targetField: "order_id",
        aliases: [
          "注文番号",
          "order id",
          "order no",
          "受注ID",
          "受注番号",
          "注文ID",
        ],
      },
      {
        targetField: "order_date",
        aliases: [
          "注文日",
          "order date",
          "受注日",
          "購入日",
          "注文日時",
          "ordered at",
        ],
      },
      {
        targetField: "customer_name",
        aliases: [
          "購入者名",
          "customer name",
          "顧客名",
          "氏名",
          "お名前",
          "buyer name",
        ],
      },
      {
        targetField: "customer_email",
        aliases: [
          "メールアドレス",
          "email",
          "mail",
          "購入者メール",
          "連絡先メール",
        ],
      },
      {
        targetField: "total_amount",
        aliases: [
          "合計金額",
          "total",
          "total amount",
          "合計",
          "注文金額",
          "支払金額",
        ],
      },
      {
        targetField: "status",
        aliases: [
          "ステータス",
          "status",
          "注文状態",
          "受注ステータス",
          "処理状況",
        ],
      },
      {
        targetField: "shipping_address",
        aliases: [
          "配送先住所",
          "shipping address",
          "送付先",
          "届け先",
          "住所",
        ],
      },
      {
        targetField: "payment_method",
        aliases: [
          "支払方法",
          "payment method",
          "決済方法",
          "支払い方法",
          "payment",
        ],
      },
    ],
  },
  // ... inventory, customers, shipments プロファイルも同様に定義
] as const;

ポイントは、各フィールドに対して 日本語・英語・略称・表記揺れ を網羅的にエイリアスとして登録することです。この辞書の充実度がヒューリスティック認識の精度を直接左右します。

ステップ 2:正規化とスコアリング

CSV ヘッダー文字列とエイリアスを比較する前に、両方を正規化します。全角英数を半角に変換し、区切り文字を統一し、小文字化します。

// column-recognizer.ts
 
function normalize(value: string): string {
  return value
    .toLowerCase()
    .normalize("NFKC") // 全角 → 半角変換
    .replace(/[_\-/]+/g, " ") // 区切り文字をスペースに統一
    .replace(/[()()[\]]+/g, " ") // 括弧を除去
    .replace(/\s+/g, " ")
    .trim();
}

normalize("NFKC") は Unicode 互換分解と正準合成を行い、「A」→「A」「1」→「1」といった全角英数の変換を一括で処理します。日本語 CSV では全角文字が混入しやすいため、この正規化は必須です。

次に、正規化されたヘッダーとエイリアスの類似度をスコアリングします。

function scoreAlias(header: string, alias: string): number {
  const h = normalize(header);
  const a = normalize(alias);
 
  // 完全一致: 最高スコア
  if (h === a) return 1;
 
  // 部分包含: 高スコア
  if (h.includes(a) || a.includes(h)) return 0.8;
 
  // トークン重複: 中スコア
  const headerTokens = new Set(h.split(" "));
  const aliasTokens = a.split(" ");
  const overlap = aliasTokens.filter((t) => headerTokens.has(t)).length;
  if (overlap === 0) return 0;
  return Math.min(0.7, overlap / aliasTokens.length);
}

スコアリング関数は 3 段階の判定を行います。

マッチ種別スコア
完全一致1.0"商品コード" vs "商品コード"
部分包含0.8"商品コード(JAN)" vs "商品コード"
トークン重複0.0 - 0.7"商品 管理 コード" vs "商品コード"

ステップ 3:プロファイル照合と最適マッチ選択

各ヘッダーに対して最もスコアが高いフィールドを選び、プロファイル全体の適合度(信頼度)を算出します。

export type ColumnMapping = Record<string, string | null>;
 
export type ColumnRecognitionResult = {
  dataType: EcDataType;
  confidence: number;
  columnMapping: ColumnMapping;
  unmappedColumns: string[];
  detectedSystem: string;
};
 
function pickMappingForHeader(
  header: string,
  profile: KnownSystemProfile
): string | null {
  let bestField: string | null = null;
  let bestScore = 0;
 
  for (const field of profile.fields) {
    for (const alias of field.aliases) {
      const score = scoreAlias(header, alias);
      if (score > bestScore) {
        bestScore = score;
        bestField = field.targetField;
      }
    }
  }
 
  // 閾値 0.55 未満はマッピング不成立
  return bestScore >= 0.55 ? bestField : null;
}
 
function scoreProfile(
  headers: string[],
  profile: KnownSystemProfile
) {
  const columnMapping: ColumnMapping = {};
  let matchedHeaders = 0;
 
  for (const header of headers) {
    const matchedField = pickMappingForHeader(header, profile);
    columnMapping[header] = matchedField;
    if (matchedField) matchedHeaders += 1;
  }
 
  const headerCoverage =
    headers.length > 0 ? matchedHeaders / headers.length : 0;
  const fieldCoverage =
    profile.fields.length > 0
      ? matchedHeaders / Math.min(profile.fields.length, headers.length)
      : 0;
 
  return {
    profile,
    columnMapping,
    matchedHeaders,
    confidence: Number(
      ((headerCoverage * 0.7 + fieldCoverage * 0.3) * 100).toFixed(1)
    ),
    score: matchedHeaders,
  };
}

信頼度の算出には 2 つのカバレッジ を加重平均しています。

  • ヘッダーカバレッジ(重み 0.7): 入力 CSV のヘッダーのうち、何割がマッピングされたか。ユーザーが「認識されなかった列が多い」と感じるかどうかに直結します。
  • フィールドカバレッジ(重み 0.3): プロファイルが定義するフィールドのうち、何割がカバーされたか。データ種別の推定精度に影響します。

ヘッダーカバレッジに高い重みを置いている理由は、実運用上「未マッピングの列が少ないこと」がユーザー体験に直結するためです。

最後に、全プロファイルを照合して最高スコアのものを選択します。

export function recognizeColumns(
  headers: string[]
): ColumnRecognitionResult {
  const candidates = KNOWN_SYSTEM_PROFILES.map((profile) => ({
    ...scoreProfile(headers, profile),
  }));
 
  const best = candidates.sort((a, b) => {
    if (b.score !== a.score) return b.score - a.score;
    return b.confidence - a.confidence;
  })[0];
 
  return {
    dataType: best.profile.dataType,
    confidence: best.confidence,
    columnMapping: best.columnMapping,
    unmappedColumns: Object.entries(best.columnMapping)
      .filter(([, v]) => v === null)
      .map(([k]) => k),
    detectedSystem: best.profile.systemName,
  };
}

動作例

楽天の受注 CSV エクスポートを想定した入力です。

注文番号, 注文日時, 購入者名, メールアドレス, 合計金額, 注文状態, 送付先, 決済方法

認識結果:

{
  "dataType": "orders",
  "confidence": 100,
  "detectedSystem": "受注データ",
  "columnMapping": {
    "注文番号": "order_id",
    "注文日時": "order_date",
    "購入者名": "customer_name",
    "メールアドレス": "customer_email",
    "合計金額": "total_amount",
    "注文状態": "status",
    "送付先": "shipping_address",
    "決済方法": "payment_method"
  },
  "unmappedColumns": []
}

全 8 列が正しくマッピングされ、信頼度 100% が得られました。

一方、独自フォーマットの CSV ではこうなります。

item_no, title, 売値(税込), stock_count, ジャンル名, maker_name, memo
{
  "dataType": "product_master",
  "confidence": 61.4,
  "columnMapping": {
    "item_no": null,
    "title": "product_name",
    "売値(税込)": "price",
    "stock_count": "stock_quantity",
    "ジャンル名": "category",
    "maker_name": "brand",
    "memo": null
  },
  "unmappedColumns": ["item_no", "memo"]
}

item_no はエイリアス辞書に "item code" はあるものの "item no" はないため、スコアが閾値に達せずマッピング不成立となりました。こうしたケースはエイリアス辞書の拡充で対応できますが、無限に増え続ける表記揺れをすべて事前定義できるわけではありません。ここに LLM アプローチの出番があります。

アプローチ 2:LLM ベースのカラム認識

設計思想

LLM ベースアプローチでは、カラムヘッダーとサンプルデータを プロンプト として LLM に渡し、データ種別の推定とカラムマッピングを自然言語の理解力で推論させます。

graph LR
    A[CSV ヘッダー<br/>+ サンプル行] --> B[LLM 推論]
    B --> C[構造化 JSON]
    C --> D[マッピング結果]

実装

// llm-column-recognizer.ts
 
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod/v4";
 
const LlmMappingSchema = z.object({
  dataType: z.enum([
    "product_master",
    "orders",
    "inventory",
    "customers",
    "shipments",
    "unknown",
  ]),
  confidence: z.number().min(0).max(100),
  detectedSystem: z.string(),
  columnMapping: z.record(z.string(), z.string().nullable()),
  reasoning: z.string(),
});
 
type LlmMappingResult = z.infer<typeof LlmMappingSchema>;
 
const TARGET_FIELDS: Record<string, string[]> = {
  product_master: [
    "product_id",
    "product_name",
    "price",
    "category",
    "description",
    "stock_quantity",
    "brand",
    "weight",
  ],
  orders: [
    "order_id",
    "order_date",
    "customer_name",
    "customer_email",
    "total_amount",
    "status",
    "shipping_address",
    "payment_method",
  ],
  inventory: [
    "product_id",
    "warehouse",
    "quantity",
    "reserved",
    "last_updated",
  ],
  customers: [
    "customer_id",
    "customer_name",
    "email",
    "phone",
    "address",
    "registered_at",
  ],
  shipments: [
    "order_id",
    "tracking_number",
    "carrier",
    "shipped_at",
    "delivered_at",
    "status",
  ],
};
 
function buildPrompt(
  headers: string[],
  sampleRows: string[][]
): string {
  const sampleText = sampleRows
    .slice(0, 3)
    .map((row) => row.join(" | "))
    .join("\n");
 
  return `あなたはECサイトのデータ分析エキスパートです。
以下のCSVヘッダーとサンプルデータから、データ種別を判定し、各カラムを正規化されたターゲットフィールドにマッピングしてください。
 
## CSVヘッダー
${headers.join(" | ")}
 
## サンプルデータ(最大3行)
${sampleText}
 
## ターゲットフィールド定義
${Object.entries(TARGET_FIELDS)
  .map(([type, fields]) => `- ${type}: ${fields.join(", ")}`)
  .join("\n")}
 
## 出力フォーマット(JSON)
{
  "dataType": "データ種別(上記のいずれか、または unknown)",
  "confidence": 0-100の信頼度,
  "detectedSystem": "推定されるデータソースの説明",
  "columnMapping": { "元のカラム名": "ターゲットフィールド名 or null" },
  "reasoning": "判定理由の簡潔な説明"
}
 
JSONのみを出力してください。`;
}
 
export async function recognizeColumnsWithLlm(
  headers: string[],
  sampleRows: string[][]
): Promise<LlmMappingResult> {
  const client = new Anthropic();
 
  const message = await client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    messages: [
      {
        role: "user",
        content: buildPrompt(headers, sampleRows),
      },
    ],
  });
 
  const text =
    message.content[0].type === "text" ? message.content[0].text : "";
 
  // JSON 部分を抽出(コードブロックで囲まれている場合に対応)
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) {
    throw new Error("LLM response did not contain valid JSON");
  }
 
  const parsed = JSON.parse(jsonMatch[0]);
  return LlmMappingSchema.parse(parsed);
}

LLM アプローチの強み

先ほどヒューリスティックで認識できなかった item_no を含む CSV を、LLM はどう処理するでしょうか。

入力:

item_no, title, 売値(税込), stock_count, ジャンル名, maker_name, memo
A001, ワイヤレスイヤホン Pro, 12800, 150, オーディオ, SoundTech, 人気商品

LLM の推論結果:

{
  "dataType": "product_master",
  "confidence": 92,
  "detectedSystem": "自社ECの商品マスタエクスポート",
  "columnMapping": {
    "item_no": "product_id",
    "title": "product_name",
    "売値(税込)": "price",
    "stock_count": "stock_quantity",
    "ジャンル名": "category",
    "maker_name": "brand",
    "memo": null
  },
  "reasoning": "item_no は商品の一意識別子であり product_id に対応。title は商品名。売値は数値で価格を表す。stock_count は在庫数。ジャンル名はカテゴリ。maker_name はブランド/メーカー。memo は備考でありターゲットフィールドに該当なし。"
}

LLM は item_noproduct_id に対応することを 文脈から推論 できます。"no""number" の略であり、"item" + "number" が商品の識別子を意味するという、自然言語レベルの理解がエイリアス辞書にはない柔軟性を提供してくれます。

さらに、サンプルデータの値A001 というコード体系、12800 という価格帯)も判断材料にできるため、ヘッダー名だけでは曖昧なケースでも精度が上がります。

2 つのアプローチの比較

評価軸ヒューリスティックLLM ベース
レイテンシ< 5ms500ms - 3s
API コスト0約 $0.003 / リクエスト
オフライン動作✅ 可能⚠️ クラウド API は不可(ローカル LLM なら可能)
既知パターンの精度非常に高い(辞書に登録済みなら確実)高い(ただし確率的)
未知パターンへの対応不可(辞書にないものは認識できない)対応可能(自然言語理解で推論)
決定論性同じ入力に対して常に同じ結果温度パラメータにより揺らぐ可能性あり
保守コストエイリアス辞書の継続的な拡充が必要プロンプトの改善のみ
デバッグ容易性スコアを追跡すれば原因特定が容易推論過程がブラックボックスになりがち

ハイブリッド戦略:実運用での推奨パターン

実運用では、両者を組み合わせた ハイブリッド戦略 が最も現実的です。

// hybrid-recognizer.ts
 
export async function recognizeColumnsHybrid(
  headers: string[],
  sampleRows: string[][]
): Promise<ColumnRecognitionResult> {
  // Phase 1: ヒューリスティックで高速に試行
  const heuristicResult = recognizeColumns(headers);
 
  // 信頼度が十分に高ければ、ヒューリスティック結果をそのまま返す
  if (heuristicResult.confidence >= 80) {
    return {
      ...heuristicResult,
      recognitionMethod: "heuristic",
    };
  }
 
  // Phase 2: 信頼度が低い場合、LLM にフォールバック
  try {
    const llmResult = await recognizeColumnsWithLlm(
      headers,
      sampleRows
    );
 
    return {
      dataType: llmResult.dataType,
      confidence: llmResult.confidence,
      columnMapping: llmResult.columnMapping,
      unmappedColumns: Object.entries(llmResult.columnMapping)
        .filter(([, v]) => v === null)
        .map(([k]) => k),
      detectedSystem: llmResult.detectedSystem,
      recognitionMethod: "llm",
    };
  } catch {
    // LLM が失敗した場合はヒューリスティック結果にフォールバック
    return {
      ...heuristicResult,
      recognitionMethod: "heuristic-fallback",
    };
  }
}

フロー図

graph TD
    A[CSV アップロード] --> B[ヒューリスティック認識]
    B -->|confidence ≥ 80%| C[結果を返す<br/>即座・無料]
    B -->|confidence < 80%| D[LLM 認識を実行]
    D -->|成功| E[LLM の結果を返す]
    D -->|失敗| F[ヒューリスティック結果に<br/>フォールバック]

この設計のポイントは以下の通りです。

  1. 大多数のリクエストは LLM を呼ばない: 繰り返しインポートされる定形 CSV は辞書に登録済みなので、ヒューリスティックだけで処理が完結します。実運用では 80% 以上のリクエストがここで解決します。
  2. LLM コストを最小化: LLM を呼ぶのは「初見のフォーマット」だけなので、API コストが線形に増加しません。
  3. フォールバックの安全性: LLM の API エラーやタイムアウト時も、精度は落ちますがヒューリスティック結果で最低限の機能を維持できます。

フィードバックループ:LLM 結果をヒューリスティック辞書に還元する

ハイブリッド戦略をさらに進化させるなら、LLM が正しく認識したマッピングをヒューリスティック辞書にフィードバックする仕組みが有効です。

// feedback-loop.ts(概念的な実装)
 
type MappingFeedback = {
  header: string;
  targetField: string;
  dataType: EcDataType;
  confirmedByUser: boolean;
};
 
async function learnFromMapping(
  feedback: MappingFeedback,
  profileStore: ProfileStore
): Promise<void> {
  if (!feedback.confirmedByUser) return;
 
  const profile = profileStore.getProfile(feedback.dataType);
  const field = profile.fields.find(
    (f) => f.targetField === feedback.targetField
  );
 
  if (field && !field.aliases.includes(normalize(feedback.header))) {
    field.aliases.push(normalize(feedback.header));
    await profileStore.save(profile);
  }
}

ユーザーが LLM のマッピング提案を「承認」した場合、そのヘッダー名をエイリアス辞書に追加します。次回以降、同じヘッダーはヒューリスティックだけで認識できるようになります。

初回:

graph LR
    A[CSV] --> B[ヒューリスティック<br/>低信頼度]
    B --> C[LLM で推論]
    C --> D[ユーザー承認]
    D -->|学習| E[エイリアス辞書に追加]

2回目以降:

graph LR
    A[CSV] --> B[ヒューリスティック<br/>高信頼度]
    B --> C[即座に返却]

まとめ

CSV カラムの自動認識は、一見地味な機能ですが、データインポートの UX を大きく左右します。

  • ヒューリスティックアプローチ は高速・無料・決定論的で、既知のパターンに対して最も信頼性が高いです。エイリアス辞書の設計(正規化、スコアリング閾値、カバレッジの加重平均)が精度の鍵を握ります。
  • LLM アプローチ は未知のカラム名やフォーマットに対して柔軟ですが、レイテンシとコストのトレードオフがあります。サンプルデータを含めたプロンプト設計で精度が向上します。
  • ハイブリッド戦略 は両者の長所を組み合わせ、コストとレイテンシを最適化しながら、未知パターンへの対応力も確保できます。

さらにフィードバックループを組み込めば、LLM の推論結果がヒューリスティック辞書を継続的に強化する、自律的に成長するカラム認識システム を構築できます。

実装する際は、まずヒューリスティックだけで始め、未マッピングの発生率をモニタリングしながら、必要に応じて LLM フォールバックを導入するのが現実的なロードマップです。

以上、CSVカラム自動認識の実装パターンについて、現場からお送りしました。