C#/ASP.NETで固有表現抽出(NER)を無料で実現する方法|ONNX RuntimeとspaCyの実装解説

「C#/ASP.NETで固有表現抽出(NER)をやりたいけれど、クラウドの有料AIサービスは避けたい」――そんなニーズはここ数年で一気に増えました。本記事では、ONNX RuntimeやspaCyなどの無料ツールを組み合わせて、人名・企業名・地名・数値などをローカル環境で抽出するための実装パターンを、サンプルコード付きで詳しく解説します。

目次

C#/ASP.NETで固有表現抽出を「無料」で実現する全体像

固有表現抽出(Named Entity Recognition / NER)は、文章中から「意味のあるまとまり」を取り出す技術です。例えば、以下のような情報を自動で抜き出すことができます。

  • 人名(PERSON)
  • 企業・組織名(ORG)
  • 地名(LOC / GPE)
  • 金額・日付・時間・割合などの数値情報

C#/ASP.NETで、かつ無料で固有表現抽出を行う場合、主な選択肢は次の4つです。

案概要特徴クラウド依存
AONNX Runtime+事前学習済みNERモデル.NETだけで完結、ローカル推論なし(完全ローカル)
BML.NETからONNXモデルを利用.NETパイプラインに統合しやすいなし
CspaCyなどPython製NERをローカルAPI化実績豊富なNERエンジンを流用なし(自前サーバ)
D正規表現・辞書によるルールベース実装がシンプル、補完用に最適なし

実務的には、AまたはCをメインに、Dで補完する構成が現実的です。以下でそれぞれ詳しく見ていきます。

ONNX Runtime+事前学習済みNERモデルでローカル推論(おすすめ)

アーキテクチャのイメージ

ONNX Runtimeを使う場合の構成を、シンプルにテキストで表現すると次のようになります。

  • ASP.NET(Web API/MVC/Minimal APIなど)
  • ↓(C#コード内で呼び出し)
  • OnnxRuntime(NuGet)
  • ↓(ローカルファイルを参照)
  • 事前学習済みNERモデル(BERT系などのONNXファイル)

HTTPリクエストを受けたASP.NETアプリが、テキストをトークン化→ONNXモデルで推論→固有表現の一覧をJSONで返す、という流れです。クラウドサービスは一切利用せず、モデルファイルはサーバのローカルディスク上に置きます。

必要なコンポーネントと準備

最低限必要になるものは以下の通りです。

項目例ポイント
推論エンジンMicrosoft.ML.OnnxRuntimeNuGetからインストール
NERモデルBERT系のToken ClassificationモデルHugging Faceなどから取得しONNX化
トークナイザWordPiece/BPEvocab.txtやmerges.txtとペアで使用
前処理・後処理ロジックC#の自前実装input_ids作成とBIOラベルの結合

モデル自体は無料で配布されていることが多いですが、商用利用の可否など、ライセンス条項は必ず確認してください。

トークナイザ実装の戦略

多くのBERT系モデルでは、トークナイズは「サブワード分割(WordPiece/BPE)」で行われます。C#から利用する方法としては以下の2パターンがあります。

  • ① C#で簡易トークナイザを実装する
    • 外部ライブラリに依存せず完結するのがメリット。
    • 実装コストはやや高いですが、一度作れば長く使える。
  • ② Pythonなどのトークナイザを「ローカルプロセス」として呼び出す
    • PythonのtokenizersやTransformersを使えば、モデル付属のトークナイザをそのまま利用可能。
    • C#側からは標準入力/標準出力やHTTP経由でやり取りする。

「C#だけで完結させたい」か「初期コストを下げたい」かで選ぶとよいでしょう。小さなPoCであれば②、長期運用を見据えるなら①がおすすめです。

前処理:トークン→テンソル(input_ids / attention_mask)

モデルに入力する前に、テキストを以下の形式に変換します。

  • input_ids:トークンごとのID配列
  • attention_mask:有効トークンを1、パディングを0とした配列
  • token_type_ids:ペア文章を扱う一部モデルで使用(NERでは省略可なことが多い)

以下はC#で推論用テンソルを組み立てる例です(トークナイズは独自クラスと想定)。


using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;

public class NerService
{
    private readonly InferenceSession _session;

    public NerService(string modelPath)
    {
        _session = new InferenceSession(modelPath);
    }

    public IReadOnlyList<NamedEntity> Analyze(string text)
    {
        // 1) トークナイズ(トークンID+元文のオフセットを取得する想定)
        var tokenized = MyTokenizer.EncodeWithOffsets(text, maxLen: 512);

        var inputIds = tokenized.InputIds;        // int[] or long[]
        var attention = tokenized.AttentionMask;  // int[] or long[]
        var offsets = tokenized.Offsets;         // List<TokenOffset>

        // 2) DenseTensorに詰める(バッチサイズ1を想定)
        var idsTensor  = new DenseTensor<long>(new[] { 1, inputIds.Length });
        var attnTensor = new DenseTensor<long>(new[] { 1, attention.Length });

        for (int i = 0; i < inputIds.Length; i++)
        {
            idsTensor[0, i]  = inputIds[i];
            attnTensor[0, i] = attention[i];
        }

        var inputs = new List<NamedOnnxValue>
        {
            NamedOnnxValue.CreateFromTensor("input_ids", idsTensor),
            NamedOnnxValue.CreateFromTensor("attention_mask", attnTensor)
        };

        // 3) 推論
        using var results = _session.Run(inputs);
        // 出力名はモデルによって異なるので要確認
        var logits = results.First().AsTensor<float>(); // [1, seqLen, numLabels]

        // 4) BIOラベルからエンティティへ復元
        var entities = NerPostProcessor.Decode(logits, offsets, LabelMaps.NerLabelMap);

        return entities;
    }
}

後処理:BIOラベルを原文のエンティティへ復元する

多くのNERモデルは、トークンごとに「BIO形式」のラベルを返します。

  • B-PER:人名(PERSON)の先頭
  • I-PER:人名の継続
  • O:エンティティ外

これを連結して、元の文字列上の「開始位置・終了位置」を持つエンティティに変換します。


public static class NerPostProcessor
{
    public static List<NamedEntity> Decode(
        Tensor<float> logits,
        IReadOnlyList<TokenOffset> offsets,
        IReadOnlyDictionary<int, string> labelMap)
    {
        var entities = new List<NamedEntity>();

        int seqLen = logits.Dimensions[1];
        int numLabels = logits.Dimensions[2];

        string? currentType = null;
        int? currentStart = null;
        int? currentEnd = null;

        for (int i = 0; i < seqLen; i++)
        {
            // 1) tokenごとに最大スコアのラベルIDを取得
            int bestLabelId = 0;
            float bestScore = float.MinValue;
            for (int j = 0; j < numLabels; j++)
            {
                float score = logits[0, i, j];
                if (score > bestScore)
                {
                    bestScore = score;
                    bestLabelId = j;
                }
            }

            var label = labelMap[bestLabelId]; // 例: "B-ORG", "I-ORG", "O" など

            // 2) BIOをパース
            if (label == "O")
            {
                // 現在のエンティティがあれば確定して追加
                if (currentType != null && currentStart.HasValue)
                {
                    entities.Add(new NamedEntity
                    {
                        Type = currentType,
                        Start = currentStart.Value,
                        End = currentEnd ?? currentStart.Value
                    });
                }
                currentType = null;
                currentStart = null;
                currentEnd = null;
                continue;
            }

            var parts = label.Split('-');
            var tag = parts[0];    // "B" or "I"
            var type = parts[1];   // "PER" など

            var tokenOffset = offsets[i]; // トークンに対応する原文上の位置

            if (tag == "B" || currentType != type)
            {
                // 既存エンティティを確定
                if (currentType != null && currentStart.HasValue)
                {
                    entities.Add(new NamedEntity
                    {
                        Type = currentType,
                        Start = currentStart.Value,
                        End = currentEnd ?? currentStart.Value
                    });
                }

                currentType = type;
                currentStart = tokenOffset.Start;
                currentEnd = tokenOffset.End;
            }
            else
            {
                // I-XXX かつ同じタイプの場合、エンティティを延長
                if (currentStart.HasValue)
                {
                    currentEnd = tokenOffset.End;
                }
            }
        }

        // ループ終了後、最後のエンティティを確定
        if (currentType != null && currentStart.HasValue)
        {
            entities.Add(new NamedEntity
            {
                Type = currentType,
                Start = currentStart.Value,
                End = currentEnd ?? currentStart.Value
            });
        }

        return entities;
    }
}

public class NamedEntity
{
    public string Type { get; set; } = "";
    public int Start { get; set; }
    public int End { get; set; }
}

このとき、トークナイザ側で「原文のインデックス(開始/終了)」を必ず保持しておくことが重要です。サブワード単位でトークン化すると境界がズレやすいため、オフセットの管理が品質に直結します。

ASP.NET(Minimal API)への組み込み例

上記のNerServiceをASP.NET Minimal APIから呼び出す例を示します。


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(new NerService("Models/ner-model.onnx"));

var app = builder.Build();

app.MapPost("/api/ner", async (HttpContext ctx, NerService ner) =>
{
    using var reader = new StreamReader(ctx.Request.Body);
    var body = await reader.ReadToEndAsync();

    // ここでは単純に生テキストが送られてくる想定
    var entities = ner.Analyze(body);

    // テキスト断片も含めて返す
    var response = entities.Select(e => new
    {
        type = e.Type,
        start = e.Start,
        end = e.End,
        text = body.Substring(e.Start, e.End - e.Start)
    });

    await ctx.Response.WriteAsJsonAsync(response);
});

app.Run();

これで、HTTP POSTで文章を送ると、固有表現の一覧がJSONで返る小さなNER APIが完成します。このAPIをMVCアプリやBlazorから呼び出せば、入力フォームに貼り付けた文章を即時解析することも可能です。

パフォーマンスチューニングのポイント

  • ASP.NET側はReleaseビルド+x64で動かす。
  • OnnxRuntimeのセッションはシングルトンで使い回す(毎回ロードしない)。
  • CPUの場合はAVX2対応のマシンを選び、場合によってはGPU EP(CUDA/DirectML)を検討。
  • 長文は512トークン単位でスライディングウィンドウ分割し、結果をマージする。

業務システムの場合、秒間数十リクエスト程度であればCPUだけでも十分処理できるケースが多く、クラウドAIに比べてレイテンシも安定します。

ML.NET経由でONNX NERモデルを使う(構成をシンプルに)

ML.NETにはONNXモデルを読み込むためのトランスフォーマが用意されており、データ前処理パイプラインとまとめて扱うことができます。

ML.NETを使うメリット

  • 学習済みONNXモデルを他のML処理と一緒にパイプライン化できる。
  • データクラス(入力用・出力用)を定義しておけば、PredictionEngineで型安全に扱える。
  • 将来的に、自前で特徴量を追加したり、他の分類器と組み合わせたりしやすい。

ML.NETでの基本的な流れ

概念的には、以下のようなコード構成になります。


public class NerInput
{
    [VectorType(1, 512)]
    [ColumnName("input_ids")]
    public long[] InputIds { get; set; } = new long[512];

    [VectorType(1, 512)]
    [ColumnName("attention_mask")]
    public long[] AttentionMask { get; set; } = new long[512];
}

public class NerOutput
{
    // モデルの出力名に合わせる
    [VectorType(1, 512, 9)] // numLabels=9など
    [ColumnName("logits")]
    public float[] Logits { get; set; } = default!;
}

using Microsoft.ML;

var mlContext = new MLContext();

var onnxModelPath = "Models/ner-model.onnx";

var dataView = mlContext.Data.LoadFromEnumerable(new List<NerInput>());

var pipeline = mlContext.Transforms.ApplyOnnxModel(
    modelFile: onnxModelPath,
    outputColumnNames: new[] { "logits" },
    inputColumnNames: new[] { "input_ids", "attention_mask" }
);

var model = pipeline.Fit(dataView);

var engine = mlContext.Model.CreatePredictionEngine<NerInput, NerOutput>(model);

// 入力作成(トークナイズは別途)
var input = new NerInput
{
    InputIds = BuildInputIds(...),
    AttentionMask = BuildAttentionMask(...)
};

var output = engine.Predict(input);

// output.Logits を Tensor<float>相当として後処理

注意点として、NerOutputのLogitsは「一次元配列」として返ってくるため、[1, seqLen, numLabels]の形に自前で復元してからBIO結合処理を行う必要があります。前処理・後処理のロジックは、OnnxRuntimeを直接使う場合と同じです。

spaCyなどPython製NERをローカルAPI化してC#から呼ぶ

「C#だけにこだわらない」のであれば、Python製のNERライブラリをローカルHTTPサーバとして立ててしまい、ASP.NETからHTTPクライアントで呼ぶ構成も実務ではよく使われます。

この構成のメリット・デメリット

観点メリットデメリット
実装コストspaCyなどの高品質なNERをほぼそのまま利用できるPython環境のセットアップが必須
メンテナンスモデル更新が容易(pip install -Uなど)C#とPythonの2言語運用になる
パフォーマンス1台サーバ内であればレイテンシは許容範囲プロセス間通信のオーバーヘッドが少し増える

Python側(FastAPI+spaCy)の簡易サンプル

Python側でFastAPIを使ってNER APIを立てる例です。


# ner_api.py
from fastapi import FastAPI
from pydantic import BaseModel
import spacy

nlp = spacy.load("en_core_web_sm")  # 言語に合わせて変更

app = FastAPI()

class NerRequest(BaseModel):
    text: str

class NerEntity(BaseModel):
    label: str
    start: int
    end: int
    text: str

@app.post("/ner", response_model=list[NerEntity])
def ner(req: NerRequest):
    doc = nlp(req.text)
    entities = []
    for ent in doc.ents:
        entities.append(NerEntity(
            label=ent.label_,
            start=ent.start_char,
            end=ent.end_char,
            text=ent.text
        ))
    return entities

このサーバをローカル(または社内ネットワーク)で起動し、ASP.NETから呼び出します。

C#(ASP.NET)からspaCy APIを呼ぶ例


public class SpaCyNerClient
{
    private readonly HttpClient _http;

    public SpaCyNerClient(HttpClient http)
    {
        _http = http;
        _http.BaseAddress = new Uri("http://localhost:8000/");
    }

    public async Task<IReadOnlyList<NamedEntity>> AnalyzeAsync(string text)
    {
        var payload = new { text };
        var response = await _http.PostAsJsonAsync("ner", payload);
        response.EnsureSuccessStatusCode();

        var entities = await response.Content.ReadFromJsonAsync<List<SpaCyEntity>>();

        return entities!.Select(e => new NamedEntity
        {
            Type = e.Label,
            Start = e.Start,
            End = e.End
        }).ToList();
    }

    private class SpaCyEntity
    {
        public string Label { get; set; } = "";
        public int Start { get; set; }
        public int End { get; set; }
        public string Text { get; set; } = "";
    }
}

この方式なら、トークナイズやBIO結合などの面倒な処理はすべてspaCy側に任せられます。サーバ構築の手間は増えますが、「できるだけ早く高品質なNERを導入したい」「社内PoCでまず動くものが欲しい」という場面では非常に有効です。

正規表現・辞書によるルールベース補完

機械学習ベースのNERだけで、すべての固有表現を完璧に抽出するのは難しい場合が多くあります。特に「金額」「日付」「メールアドレス」「URL」など、パターンが明確な情報は、ルールベースで補完する方が安定します。

代表的なルールの例


// 金額: 例) 1,234円 / 1000円 / ¥1,000
var moneyRegex = new Regex(@"\b[¥¥]?\d{1,3}(,\d{3})*円?", RegexOptions.Compiled);

// 日付: 例) 2024/01/01, 2024-01-01, 2024年1月1日
var dateRegex = new Regex(
    @"\b\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?\b",
    RegexOptions.Compiled);

// メールアドレス
var mailRegex = new Regex(
    @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
    RegexOptions.Compiled);

これらを、機械学習ベースのNERの結果に対して「追加で走らせる」構成にすることが多いです。具体的には次のような処理順がおすすめです。

  1. 文章をNERモデルに通し、人名・組織名・地名などを抽出する。
  2. その結果をエンティティリストとして保持しておく。
  3. 同じ文章に対して、正規表現で金額・日付・メールアドレスなどを抽出する。
  4. 既存エンティティとオーバーラップしないものだけを追加する。

こうすることで、「人名や組織名など曖昧なものはモデルに任せる」「パターンがはっきりしているものはルールで確定」いう役割分担ができます。

導入時にハマりがちなポイントと対策

トークンと原文の位置合わせ

サブワード分割を行うモデルでは、原文の「文字数」とトークン列の「長さ」が一致しません。そこで、トークナイザで以下の情報を必ず保持します。

  • トークンのテキスト(例:"東京", "##都")
  • 原文中の開始位置(Start)と終了位置(End)

NERのラベルをトークンから原文に戻す際は、各トークンに紐づいたStart/Endを元に結合していきます。この設計を最初にしっかり決めておくと、後からラベル体系を変えたり別のモデルに差し替えたりしても、アプリ側のコードをあまり変えずに済みます。

最大シーケンス長と長文対応

多くのBERT系モデルは512トークンを超える入力に対応していません。ニュース記事や長い問い合わせ文など、1件が長くなりがちな場合には、次のような戦略を取ります。

  • 最大512トークンの「ウィンドウ」で文章を分割する。
  • 前半の一部を次のウィンドウに重ねる(オーバーラップ)ことで境界のエンティティを取りこぼさない。
  • ウィンドウごとにNERを実行し、原文インデックスをベースにすべてのエンティティをマージする。

この方法なら、モデル側に手を入れずに長文を扱うことができます。

言語とモデルのミスマッチ

英語モデルに日本語を入力すると、ほぼ意味のない結果になることが多いです。逆も同様です。日本語文章を処理したい場合は、日本語に対応したNERモデル(多言語モデルも含む)を選択する必要があります。

また、同じ日本語でも「ニュース記事」「SNS」「契約書」など、ドメインの違いによって精度が変わるため、可能であれば利用用途に近いデータで事前学習されたモデルを選ぶとよいでしょう。

精度検証のすすめ

導入前後で「なんとなく動いている」だけだと、ビジネス側とトラブルになりがちです。最低限、次のような簡易評価を行うと安心です。

  • 実際の運用に近い文章を20〜50件程度ピックアップする。
  • 人手で「正解ラベル」を付けた小さなデータセットを作る。
  • モデルの出力と比較して、Precision/Recall/F1を計算する。
  • 重要なエンティティタイプ(人名・組織名など)ごとに値を確認する。

この評価結果をチームで共有しておくと、モデル変更やアップデート時に「どれくらい良くなった/悪くなった」のかを定量的に把握できるようになります。

ライセンスとデータ保護

無料モデルであっても、商用利用や再配布に制限があることがあります。特に以下の点は必ずチェックしてください。

  • 商用利用が許可されているか。
  • クレジット表記やライセンス文書の同梱が必要か。
  • 再学習・再配布に制限がないか。

また、クラウドを使わないとはいえ、個人情報を含む文章を扱う場合は、ログ/監査/アクセス制御など、通常のWebアプリと同様のセキュリティ対策が必須です。

実運用を見据えた設計パターン

ASP.NETにNERを組み込む場合、アーキテクチャは大きく2パターンに分かれます。

パターン概要適したケース
モノリシックASP.NETアプリ内にNerServiceを直接組み込む単一システム内で完結、小規模システム
マイクロサービスNER専用のAPIを別コンテナ(別プロセス)として立てる複数システムから共通でNERを利用したい場合

モノリシック構成のポイント

  • ASP.NETアプリと同じプロセス内でOnnxRuntimeを扱う。
  • デプロイが1つで済むため、小中規模の社内システムでは十分。
  • 負荷が増えたらWebサーバごと水平分割(スケールアウト)し、負荷分散する。

マイクロサービス構成のポイント

  • NER機能を独立したAPIとして構築し、他のサービスからHTTPで呼び出す。
  • Python版とC#版の両方を試したい場合など、技術選択の自由度が上がる。
  • コンテナ化(Docker)しておくとスケールや更新が楽になる。

どの方式を選ぶべきかの判断基準

最後に、ここまで紹介した各方式を、「要件別」に選択しやすくするための一覧を示します。

要件おすすめ案理由
.NETだけで完結させたいA(ONNX Runtime) or B(ML.NET+ONNX)Pythonランタイム不要、デプロイがシンプル
とにかく早く高精度なNERを試したいC(spaCy API化)トークナイザや後処理を自前で実装しなくてよい
金額や日付など定型情報が中心D(ルールベース)+A/Cで補完パターンが固定なら正規表現の方が安定・高速
複数システムから共通で使いたいA or Cをマイクロサービスとして提供共通API化することで保守性を高められる

まとめ:C#/ASP.NETでクラウド不要のNERを実現する

本記事では、C#/ASP.NET環境で、人名・企業名・地名・数値などの固有表現抽出(NER)を「無料&ローカル」で実現するためのアプローチを整理しました。

  • ONNX Runtime+事前学習済みNERモデル:クラウド不要で.NET完結。パフォーマンスも良く、長期運用を見据えた王道パターン。
  • ML.NET+ONNXモデル:既存のML.NETパイプラインと統合しやすく、型安全な推論コードが書きやすい。
  • spaCyなどをローカルAPI化:Pythonエコシステムの豊富なNLP資産を活用でき、短期間で高品質なNERを導入しやすい。
  • 正規表現・辞書によるルールベース補完:金額・日付・メール・URLなどパターンが明確なものはルールで確定し、モデルと役割分担させる。

いずれの方式でも、有料クラウドサービスに依存せず、社内サーバやオンプレミス環境上で固有表現抽出を完結させることができます。まずは小さな文章セットからPoCを行い、自社のデータやワークフローに合わせてモデルやルールをチューニングしていくとよいでしょう。

C#/ASP.NETでのNERは、もはや「一部の機械学習エンジニアだけのもの」ではありません。既存のWebアプリに少しコードを追加するだけで、問い合わせ文の自動タグ付けや、契約書・ログの情報抽出など、多くの業務を効率化できます。この記事をきっかけに、ぜひ自分のプロジェクトでも固有表現抽出を試してみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次