「組み込みの経験がある人は?」──こう聞かれて、社員名簿から答えを返せる検索を作ります。

この記事では、利用者が入力した検索文をLLM(文章を理解・生成するAIモデル)に検索条件へ変換し、データベースの部分一致で候補を絞る仕組みを、C#の実コードで確かめます。

この記事は、次のような方に向いています。

  • 社内の文書や履歴を「自然文で探せる」ようにしたい方
  • RAG(検索で集めた情報をLLMに渡して答えを作る仕組み)を作ろうとして、検索の精度で詰まっている方
  • C#の基本が分かり、AI連携を業務データに使ってみたい方
  • ローコードでスキル検索を作ろうとして、うまく当たらない方
    (おおまかな考え方の部分は参考になると思います)

全3回の連載です。AI人材検索を題材として、同じ10名のデータと同じ4つの依頼で比べます。

  • 第1回:キーワード検索
  • 第2回:ベクトル検索
  • 第3回:RAG(検索拡張生成)
プロ太

順に試すことで、AIを使った自然文による業務データ検索と、RAGの基本を学べます!

使用したコードと実測ログは第1~3回分をまとめて、GitHubで公開しています。

課題 ── 自然な言葉で業務データを探すと、検索条件が決まらない

社内には、人材の経歴、問い合わせ履歴、手順書、案件実績など、多くの業務データが蓄積されています。

それでも探せないのは、データに書かれた言葉と、利用者が尋ねる言葉が違うからです。

利用者はデータベースの語彙で質問しない

利用者は、テーブルの列名や登録済みの技術名を知りません。自分の仕事の言葉で、次のように尋ねます。

「組み込みの経験がある人は?」

と聞かれたとき、データベースに入っているのが「組込み」なのか「組み込み」なのかは、質問する人にとってはどうでもいいことです。

ところが検索する側の都合では、この違いが決定的です。「組み込み」で探すと「組込み」に当たりません。

「クラウド」で探しても、紹介文に「Azure」としか書いていない人は見つかりません。書き手が自由に書いた文章を相手にすると、この食い違いが起きやすくなります。

これは、タレントマネジメント(人材情報を配置や育成に生かす取り組み)の定番機能でもあります。人事システムのスキル検索がうまく当たらない、という相談はよく聞きます。

人材検索は、業務検索の縮小モデルである

この記事の題材は人材検索ですが、扱う問題はもっと広く、利用者の検索文に合う情報を、既存の業務データからどう探すかです。

同じ問題は、次のような検索でも起こります。以下は代表例であり、検索用途を網羅した分類ではありません。

検索の用途利用者の検索文検索する業務データ
スキル・人材検索車載ソフトの経験者を探すプロフィール、職務経歴
社内ナレッジ検索障害時の切り戻し手順を探す手順書、過去の障害報告
問い合わせ履歴検索同じ請求トラブルの対応を探すチケット、メール要約
案件検索クラウド未経験でも担当できる案件を探す案件説明、募集条件
製品検索オフラインでも使える製品を探す製品仕様、カタログ

いずれも、利用者が仕事の言葉で入力した検索文と、書き手や詳しさの異なる業務データを照合します。検索文と業務データで使われる言葉が一致しないことが共通の難しさです。

人材検索を選んだのは、表記ゆれ・該当なし・否定・語彙のずれという4つの難しさを、10名という小さなデータで一通り再現できるからです。

今回使う10名と4つの依頼

検索対象は、次の10名の紹介文です。全件を先に示すことで、どの言葉が検索に当たるのかを具体的に確認できます。

氏名紹介文
佐藤 健一10年以上C#でバックエンド開発を担当。ASP.NET CoreとAzureを用いた大規模Webサービスの設計・運用経験が豊富。
鈴木 美咲フロントエンドエンジニア。ReactとTypeScriptが得意で、デザインシステムの構築やアクセシビリティ改善の実績あり。
松本 涼バックエンド担当。社内の勉強会を毎週開いていて、入ったばかりのメンバーのコードレビューを引き受けることが多い。
高橋 大輔データサイエンティスト。Pythonによる機械学習モデルの開発、需要予測や異常検知プロジェクトをリード。
伊藤 翔太インフラエンジニア。KubernetesとTerraformによるクラウド基盤構築が専門。SREとして可用性改善に従事。
井上 拓海オンプレのサーバー運用が長い。監視とバックアップ設計、深夜の障害対応まで一通りやってきた。
岡田 里奈社内システムのサーバ移行を担当。オンプレからAzureへ、止められない業務を止めずに運んだ。
加藤 修平組込みエンジニア。C/C++での車載ソフトウェア開発が専門。リアルタイムOSと通信プロトコルに精通。
村上 早紀組み込み機器のユーザーインターフェース設計。小さい画面と少ないボタンで迷わせない工夫を考えてきた。
中村 亮セキュリティエンジニア。脆弱性診断とペネトレーションテストを担当。CISSP保持。

「サーバ/サーバー」「組込み/組み込み」の表記ゆれは意図的です。元データはData.csで確認できます。

この連載では次の4つの依頼を、全3回を通して使い続けます。

依頼この連載で確かめること
A 組み込みの経験がある人は?表記ゆれを吸収できるか
B Salesforceの導入案件が来た。経験者はいる?該当者がいないと判断できるか
C 経験の浅いメンバーを指導できる人は?字面が違っても意味で探せるか
D クラウドの経験がないと思われる人は?否定を検索条件として扱えるか

同じ4つの検索文を使い続け、3つの方式で結果がどう変わるかを比べます。

検索文の語と、業務データ内の語は一致するとは限りません。そのため、両者の言葉のずれを埋める仕組みが必要です。

解決アプローチ ── 照合の基準を何にするか

では、何を基準に照合すればよいでしょうか。検索方法はこの3つだけではなく、メタデータ検索や複数方式の組み合わせもあります。

本連載では比較対象として、文字列・意味ベクトル・AIの読解を基準にする3方式を扱います。

本連載で比べる3つの方式 ── 字面で探す・意味で探す・読んで選ぶ

違いは、何を基準に候補を決めるかです。

キーワード検索・ベクトル検索・RAGの3方式を横に並べ、それぞれの照合の基準を示した図
図1:自由文で人を探す3つの方式

①は文字列、②は意味を数値にしたベクトル、③はAIの読解を基準にします。①と②は条件に合う候補を検索し、③は集めた候補をAIが読んで選びます。

本記事が扱うのは①です。③の答えは、どの候補を渡すかに左右されます。そこで①、②の順に候補を集める方法を確かめ、最後に③へ進みます。

方式① キーワード検索 ── 条件を作る側と、照合する側に分ける

方式①では、LLMが検索条件を作り、データベースがその条件で照合します。

自由文をLLMが検索条件へ翻訳し、データベースが部分一致で候補を決める流れの図
図2:方式①の中身 ── 自由文を検索条件へ分解する

ここで大事なのは、AIは候補者を選んでいないということです。AIがするのは「組み込みの経験がある人は?」という文を、照合できる語へ翻訳することだけ。

どの紹介文に当たるかを決めるのは、データベース側の文字列照合です。

検索条件は2つのリストでできています。含める語は、そのうち1つでも紹介文に含まれていれば候補になります。除く語は、1つでも含まれていれば候補から外れます。

この2つだけで、表記ゆれも否定も扱えます。

プロ美

AIに直接「誰が該当しますか」と聞いたほうが早くない?

プロ太

10名ならそれで動きます。でも1万名の紹介文はプロンプトに入りません。だから絞る必要があって、その絞り方の話が今回なんです。

実装 ── 方式①を、C#とSQL Serverで組み立てる

ここから、方式①を図3の2つの役割に分けて実装します。

LLM側とDB側に分けた実装構成の図
図3:方式①の実装構成 ── 検索文から候補まで

LLMを動かすプラットフォームはMicrosoft Foundryです。ここへ gpt-5.6-luna をデプロイし、C#の Azure.AI.OpenAI 2.1.0 から呼び出します。(参考

モデルの呼び出し方と、JSONで応答させる方法は次の2記事で扱っています。本記事では検索方式の実装に絞ります。

【C#/WinForms実践入門編(10)】AIアシスタントアプリ ~Azure OpenAI Serviceを使う!~ WinForms編です。今回のテーマは「AIアシスタントアプリの開発」です。Azure OpenAIを使って、テキスト生成やデータ分析...
【C#/WinForms実践入門編(13)】AIアシスタントアプリ ~JSONフォーマットでトピック分析機能を追加しよう!~【システムプロンプトをどう作る?】 WinForms編です。今回のテーマは「JSON形式でAIチャット応答を構造化する」です。 前回までで作成してきたWinForm...

APIキーはuser-secrets(開発用の秘密情報ストア)に置き、appsettings.jsonには書きません。

Foundryリソースとモデルは、リポジトリのBicep(Azureの構成をコードで定義する仕組み)で作成できます。

モデルは、キーワード生成の品質を実データで確かめたうえで選びます。今回は品質が足りていたので、コストの低い gpt-5.6-luna にしました。

Standard Global(short context)の料金は、100万トークンあたり入力0.20ドル、出力1.20ドルです(公式発表・2026年8月30日時点)。

最新の価格はAzure OpenAI Serviceの価格ページで確認してください。

実行は2行です。seed でデータベースとサンプル10名を用意し、keyword で検索します。

dotnet run --project src/HrMatchingSearch -- seed
dotnet run --project src/HrMatchingSearch -- keyword "組み込みの経験がある人は?"

検索対象はData.csに定義した10名の紹介文(Bio 列)だけです。表記ゆれは、検索の難しさを再現するために意図的に残しています。

LLMに作らせる ── 部分一致の癖から、6つの規則を決める

プロンプトを書く前に、照合する側の性質を先に見ておきます。どんな語を出させるべきかは、SQLの部分一致がどう当たるかで決まるからです。

長音のゆれと語中の送り仮名のゆれを比べ、必要な検索語の数が変わることを示した図
図4:共通部分が意味を保つかで、必要な語の数が変わる

同じ「表記ゆれ」でも、打ち手が違います。末尾の長音は、短いほうの「サーバ」を1語出せば「サーバー」にも当たります。共通部分がそのまま語として通用するからです。

ところが語の途中で送り仮名がずれると、この手が使えません。「組込み」と「組み込み」の共通部分は「込み」だけで、これは「申し込み」「振り込み」にも当たってしまいます。

語としての意味を失うので、検索語には使えません。だから今回の部分一致方式では、両方の表記を出します。

英数字1文字も検索語にできません。「C」は C#、C++、CISSPのすべてに当たります。短すぎる文字列は、語の意味を保てないからです。

ここまでの性質を、LLMへの規則に書き写します。部分一致で取りこぼしと誤検出を抑えるために採用した6つです。

規則根拠代表例
長音は最短形にする短い語が両表記に含まれるサーバ
語中の送り仮名は両方出す共通部分が語の意味を失う組込み / 組み込み
略語と正式名は両方出す字面が異なるDB / データベース
一般語を出さない候補が広がりすぎる経験 を除く
英数字1文字を出さない他の語の一部に当たるC を除く
否定の依頼は除く語に置き換える「〜がない人」は照合できないが、「〜がある人を除く」なら照合できるクラウド未経験 → クラウド を除く語に出す

出力の形式はJSONに固定します。ふつうの文章で返させると、含める語と除く語をC#側で安定して取り出せないためです。プロンプト全文はkeyword.txtにあります。

C#側は、この規則を書いたプロンプトと検索文を渡し、返ってきたJSONを読むだけです。

次はKeywordSearch.csからの抜粋です。JSON配列を取り出す補助メソッドは省略しています。

public static async Task<Keywords> GenerateKeywordsAsync(SearchEnv env, string query, string? variant = null)
{
    var completion = await env.Chat.CompleteChatAsync(
        [
            new SystemChatMessage(Prompts.Keyword(variant)),  // 6つの規則を書いたプロンプト
            new UserChatMessage(query),                       // 利用者の検索文
        ],
        // JSONオブジェクトで返すよう指定する
        new ChatCompletionOptions { ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat() });

    using var doc = JsonDocument.Parse(completion.Value.Content[0].Text.Trim());
    return new Keywords(ReadStrings(doc.RootElement, "include"), ReadStrings(doc.RootElement, "exclude"));
}

DBで照合する ── 返ってきた条件を、機械的に当てる

LLMが返すのは、含める語と除く語を分けた次のJSONです。

{"include": ["組み込み", "組込み"], "exclude": []}

これを、2つのリストを一組にして持つ Keywords 型に受け取ります。

public record Keywords(List<string> Include, List<string> Exclude);

あとは、この2つのリストを2つの Where へ順に渡すだけです。

照合の実装は、同じ KeywordSearch.cs にあります。次の抜粋では、説明と対応させるためにコメントへ (1)(2) の番号だけを加えています。

public static async Task<List<Candidate>> SearchByKeywordsAsync(SearchEnv env, Keywords keywords)
{
    var (include, exclude) = (keywords.Include, keywords.Exclude);
    await using var db = env.OpenDb();

    return await db.People
        // (1) include が空なら全員が対象。空でなければ、どれか1語を含む人だけ
        .Where(p => include.Count == 0 || include.Any(kw => p.Bio.Contains(kw)))
        // (2) exclude のどれかを含む人を落とす
        .Where(p => !exclude.Any(kw => p.Bio.Contains(kw)))
        .Select(p => new Candidate(p.Name, p.Bio))
        .ToListAsync();
}

(1) は、含める語が空なら全員を通し、空でなければどれか1語を含む人だけを残します。この「空なら全員」が、あとで「〜がない人」を探すときに効いてきます。

(2) は、除く語を1つでも含む人を落とします。

この2つの照合に、意味の判断は入っていません。候補を決めているのは、紹介文にその文字列が含まれるかどうかだけです。

なお Contains は、EF Core 10のSQL Serverプロバイダーが LIKE N'%...%' へ翻訳します(公式の関数マッピング)。

プロ美

最終的にはこのコードはSQLクエリになって、DB検索した結果が返ってくるってことだね!

実験結果 ── 方式①に、4つの依頼を投げる

できあがった検索に、4つの依頼を投げます。以下はすべて2026年9月1日に各3回ずつ実行した結果で、3回とも同じ件数でした(実測ログ)。

取り上げる順はA→D→B→Cです。規則が効いた2件を先に見て、同じ0件でも意味が正反対のBとCを並べて対比します。

依頼結果判定
A 組み込みの経験がある人は?2件◎ 表記ゆれを越えた
D クラウドの経験がないと思われる人は?7件◎ 否定を除く語に変えられた
B Salesforceの導入案件が来た。経験者はいる?0件◎ 記載がないので正しい
C 経験の浅いメンバーを指導できる人は?0件✕ 該当者がいるのに落とした

A 組み込み ── 表記ゆれを越えて2名

「組み込み」と「組込み」の両方が出て、送り仮名の違う2人に当たりました。次は1回目の出力から、紹介文だけを短くした抜粋です。

検索文: 組み込みの経験がある人は?
含めるキーワード: 組み込み, 組込み, 組み込み開発

--- キーワード検索結果: 2件 ---
・加藤 修平: 組込みエンジニア。C/C++での車載ソフトウェア開発が専門。…
・村上 早紀: 組み込み機器のユーザーインターフェース設計。…

なぜ2語必要なのかは図4のとおりです。「組み込み開発」は誰にも当たらなかったため、この実測では結果に影響しませんでした。

今回は無害でしたが、余分な語が別の人の紹介文に当たれば誤検出になります。一般語や英数字1文字を出さないという規則は、この広がりを抑えるためのものです。

D クラウド未経験 ── 否定を除く語に変えて7名

「〜がない人」は、探す語ではなく除く語に変換します。含める語は空になり、除く語だけが働きます。次は1回目の出力から、紹介文だけを短くした抜粋です。

含める語が空なので全員が母集団になり、除く語が3名を落として7名が残ることを示した図
図5:否定の依頼は、2段のふるいで候補を決める
検索文: クラウドの経験がないと思われる人は?
含めるキーワード: (指定なし)
除くキーワード: クラウド, AWS, Amazon Web Services, Azure, Microsoft Azure, GCP,
            Google Cloud Platform, Google Cloud, IBM Cloud, Oracle Cloud, Alibaba Cloud

--- キーワード検索結果: 7件 ---
・鈴木 美咲: フロントエンドエンジニア。ReactとTypeScriptが得意で、…
・松本 涼: バックエンド担当。社内の勉強会を毎週開いていて、…
・高橋 大輔: データサイエンティスト。Pythonによる機械学習モデルの開発、…
・井上 拓海: オンプレのサーバー運用が長い。監視とバックアップ設計、…
・加藤 修平: 組込みエンジニア。C/C++での車載ソフトウェア開発が専門。…
・村上 早紀: 組み込み機器のユーザーインターフェース設計。…
・中村 亮: セキュリティエンジニア。脆弱性診断とペネトレーションテストを担当。…

含める語が空なので、まず10名全員が対象になり、そこから除く語が佐藤・伊藤・岡田を落として7名になりました。

B Salesforce ── 該当なしを、正しく返す

Salesforce ほか関連語で探して0件。10名の紹介文に該当する記載がないため、この検索実験では正しい答えです。

C 指導できる人 ── 0件。だが適任者はいる

今回、これだけうまくいきませんでした。

以下のように、生成された語は妥当でした。

含めるキーワード: メンター, 新人教育, OJT, 育成

--- キーワード検索結果: 0件 ---
該当なし

ところが適任者はいます。松本さんの紹介文です。

バックエンド担当。社内の勉強会を毎週開いていて、入ったばかりのメンバーのコードレビューを引き受けることが多い。

「メンター」「新人教育」「OJT」「育成」のいずれも書かれていません。やっていることは指導そのものなのに、生成されたキーワードは1つも一致しません。

表記ゆれとは種類の違う問題です。紹介文にその語がない以上、どんな検索語を作っても当たりません。

同じ0件でも、BとCでは意味が正反対です。

依頼結果判定
B Salesforceの導入案件が来た。経験者はいる?0件正しい。10名の紹介文に記載がない
C 経験の浅いメンバーを指導できる人は?0件誤り。適任の松本さんを落とした

「勉強会」「コードレビュー」を辞書に足せば(プロンプトに追加すれば)、松本さんは拾えます。ただし別の言い回しが来るたびに足すことになり、解決にはなりません。

そこで第2回では、文字列ではなく、紹介文と依頼の意味を距離として測る方法へ進みます。

考察 ── 規則は、照合する側の性質から決まる

実装の章で並べた6つの規則は、思いつきで足したものではありません。規則を外した版で何が起きるかを、実際に測ってあります。

規則を減らした版もリポジトリにあり、引数で切り替えて再現できます。

規則を何も書かず「キーワードを作ってください」とだけ頼んだ素の版で、依頼Aを実行します。

$ dotnet run --project src/HrMatchingSearch -- keyword "組み込みの経験がある人は?" --prompt naive
含めるキーワード: 組み込み

--- キーワード検索結果: 1件 ---
・村上 早紀: 組み込み機器のユーザーインターフェース設計。…

質問文の「組み込み」をそのまま返してきました。「組込み」と書いている加藤さんには届かず、結果は1件です。

6つの規則を与えた完成版と並べます。どちらも3回ずつ実行しました。

生成された語結果
素の版(規則なし)3回とも1語 ── 組み込み1件 ×3(村上のみ)
完成版(6規則)毎回3〜5語 ── 例)組み込み, 組込み, 組み込み開発2件 ×3(加藤・村上)

素の版では一度も正しい結果を得られず、完成版では3回とも正解でした。完成版が3回に出した語です。

1回目: 組み込み, 組込み, 組み込み開発
2回目: 組み込み, 組込み, 組み込み開発, 組込み開発, Embedded
3回目: 組み込み, 組込み, 組み込み開発, 組込み開発

語は回ごとに増えたり減ったりします。それでも当たったのは3回とも加藤・村上の2名でした。

プロ太

揺れないことを目指すんじゃなくて、揺れが答えに影響しにくいようにするんですね。

そのとおりです。生成される語は固定できません。規則が抑えているのは、揺れが検索結果へ与える影響のほうです。

依頼Dでも同じ形です。除く語を「クラウド」だけにした版と、完成版を並べます。

生成された除く語結果
展開なしの版(概念を展開しない)3回とも1語 ── クラウド9名 ×3(佐藤・岡田が残る)
完成版毎回11〜12語 ── 例)クラウド, AWS, Azure, GCP …7名 ×3(正解)

字面で照合する以上、概念も語として列挙しないと届きません。

プロンプトの規則は、LLMの側ではなく照合する側から決まります。部分一致が何に当たって何に当たらないか。6つの規則は、すべてそこから導いたものです。

まとめ

4つの依頼のうち3つに、正しく答えられました。

  • 役割を分ける:LLMが検索文を語へ翻訳し、データベースが文字列で照合する
  • 規則は照合する側から決まる:部分一致が何に当たるかで、出すべき語が決まる
  • 根拠が残る:照合に使った語と除外条件を確認でき、「該当なし」も返せる

この形が使えるのは、利用者の言葉を、データに実際に書かれている語へ言い換えられる場合です。

ナレッジ検索や問い合わせ履歴の検索も、同じ形に当てはまります。

残った宿題は依頼Cです。

依頼C経験の浅いメンバーを指導できる人は?
松本さんの紹介文バックエンド担当。社内の勉強会を毎週開いていて、入ったばかりのメンバーのコードレビューを引き受けることが多い。

依頼にある「指導」も、生成された「メンター」「OJT」も、紹介文には1語も出てきません。

やっていることは指導そのものなのに、字面が一つも重なりません。検索語を正しく作っても、紹介文にその語がなければ一致しません。

次回は紹介文と依頼から埋め込みベクトル(文章の意味を表す数値の並び)を作り、文字列の一致ではなく意味の近さで候補を探します。図1でいう方式②です。

プロ太

引き続き、AIを使った自然文による業務データ検索について、一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。