【AI人材検索×C#②】ベクトル検索で「意味」の近い人を探す ~埋め込みモデルとしきい値の設計~【C#実践アプリ開発ラボ】
検索した言葉が、データにそのまま書かれているとは限りません。
「指導できる人」を探したいのに、紹介文には「新人のコードレビューを担当」としか書かれていない。そんな字面は違うけれど意味は近い文章を探すのが、ベクトル検索です。
この記事では、紹介文と検索文をどちらも埋め込みベクトル(文章の意味を表す数値の並び)に変え、その距離の近さで候補を探す仕組みをつくります。
この記事は、次のような方に役立つ内容になっています。
- 社内の文書や履歴を「自然文で探せる」ようにしたい方
- ベクトル検索を、C#の実コードで一度動かしてみたい方
- RAG(検索で集めた情報を生成AIに渡して答えを作る仕組み)を作ろうとして、検索の精度で詰まっている方
- SQL Server 2025の
vector型とEF Core 10の組み合わせを試したい方
全3回の連載の第2回です。AI人材検索を題材として、同じ10名のデータと同じ4つの依頼で比べます。
- 第1回:キーワード検索
- 第2回:ベクトル検索(本記事)
- 第3回:RAG(検索拡張生成)
第1回から続けてみてもらえると、より理解が深まるかと思います。
第1回(キーワード検索)でうまくいかなかった依頼が、第2回(今回)のベクトル検索で解けるようになります!
使用したコードと実測ログは第1〜3回分をまとめて、GitHubで公開しています。
課題 ── 書いてあるのに、字面が違うと見つからない
第1回では、LLM(文章を理解・生成するAIモデル)に検索キーワードを作らせ、データベースの部分一致で照合しました。(以下は前回記事の図を再掲)

4つの依頼のうち3つは正しく答えられましたが、1つだけ不正解でした。以下について順に振り返ります。
- 第1回(キーワード検索)でできたこと、できなかったこと
- 依頼C ── 指導しているのに「指導」と書いていない
第1回(キーワード検索)でできたこと、できなかったこと
この連載では、次の4つの依頼を全3回を通して使い続けます。
| 依頼 | この連載で確かめること |
|---|---|
| A 組み込みの経験がある人は? | 表記ゆれを吸収できるか |
| B Salesforceの導入案件が来た。経験者はいる? | 該当者がいないと判断できるか |
| C 経験の浅いメンバーを指導できる人は? | 字面が違っても意味で探せるか |
| D クラウドの経験がないと思われる人は? | 否定を検索条件として扱えるか |
全文は第1回の記事に載せてあり、元データはData.csで確認できます。
この実験では、紹介文に書かれた情報を基準に正解を決めます。第1回のキーワード検索の結果は次のとおりでした。
| 依頼 | 結果 | 判定 |
|---|---|---|
| A 組み込みの経験がある人は? | 2件 | ○ 表記ゆれを越えた |
| B Salesforceの導入案件が来た。経験者はいる? | 0件 | ○ 記載がないので正しい |
| C 経験の浅いメンバーを指導できる人は? | 0件 | × 該当者がいるのに落とした |
| D クラウドの経験がないと思われる人は? | 7件 | ○ 否定を除く語に変えられた |
正解できなかったのは依頼Cです。どの語が紹介文と一致しなかったのか、実際の文章で見てみましょう。
依頼C ── 指導しているのに「指導」と書いていない
依頼Cの適任者は松本さんです。紹介文はこうなっています。
バックエンド担当。社内の勉強会を毎週開いていて、入ったばかりのメンバーのコードレビューを引き受けることが多い。
第1回でLLMが作ったキーワードは「メンター」「新人教育」「OJT」「育成」で、依頼の言い換えとしては妥当なものでした。ところが、このうち1語も紹介文に出てきません。
「組込み」と「組み込み」のような表記ゆれなら、両方の表記を出せば見つけられます。一方、「指導」に近い言葉を並べても、紹介文に含まれなければ一致しません。
これは表記ゆれへの対策だけでは埋まらないズレです。そこで今回は、照合の基準を、文字列の一致から意味の近さへ変えます。
解決アプローチ ── 照合の基準を、字面から意味へ移す
「指導」の語がなくても、「入ったばかりのメンバーのコードレビューを引き受ける」という文章から、指導に関わる仕事を読み取れます。この意味のつながりを、検索に使います。
本連載で比べる3つの方式 ── 字面で探す・意味で探す・読んで選ぶ
図1では、候補を決める基準の違いで3つの方式を整理しています。

前回記事の①では文字列の一致で探していましたが、本記事の②は文章の意味を数値に変えて探します。
③は①・②などの方法で集めた候補をAIに読ませ、該当者と理由を答えさせるRAG(検索拡張生成)方式です。次回(最終回)で紹介します。
方式② ベクトル検索 ── 文章を数値の並びに変えて、距離で測る
方式②では、文章を数値の並び(ベクトル)へ変えてから比べます。意味の近い文章ほどベクトルも近くなる傾向を利用し、距離が小さい順に並べます。図2は、その比較の流れです。

図2で比べているのは検索文と紹介文であって、語と語ではありません。だから、共通する語が1つもなくても近さを計算できます。
このように意味の近さで探す方式は、セマンティック検索とも呼ばれます。
距離は小さいほど近いと読みます。「スコアが高いほど良い」ではない点に注意してください。
「どこまで近ければヒットにするか」を決める境界を、しきい値と呼びます。この境界で、距離から○×を付けます。
第1回は語が含まれるかどうかの2択でした。今回はどれくらい近いかという連続した量なので、切る位置を自分で決めることになります。
「意味が近い」って、AIが紹介文を読んで判断してるの?
AIは文章の意味を数値に変えるところを担当します。そのあと、候補を並べるのは距離の計算、○×を付けるのはしきい値の判定です。
候補ごとに「この人が条件に合う」と生成AIへ判定させる処理は、方式③で加わります。
実装 ── 方式②を、C#とSQL Serverで組み立てる
ここからは図3の3ステップを、C#とSQL Server、文章をベクトル化するEmbeddingモデルで実装します。紹介文の準備と、検索のたびに動く処理を分けるのがポイントです。

図3の【1】は登録時に、【2】と【3】は検索のたびに動きます。
- 【1】紹介文をベクトルにして保存する
- 【2】検索文をベクトルにする
- 【3】DBで距離を測り、アプリでヒットを判定する
距離の計算には、SQL Server 2025で追加されたvector型とVECTOR_DISTANCE関数を使います。どちらもAzure SQL Databaseなどでも使えます。
無償のLocalDBやExpress Editionでも動くので、有償エディションは要りません。
C#側は、DBをC#のオブジェクトとして操作するライブラリ、EF Core 10を使います。ベクトル対応が入ったのはEF Core 10.0からで、対象はSQL Server 2025以降です。
(公式ドキュメント)
文章をベクトルに変える埋め込みモデルはtext-embedding-3-small(1536次元)です。第1回のチャットモデルと同じMicrosoft Foundryのリソースへデプロイします。
紹介文は一度だけ変換(図3の【1】)して保存し、そのベクトルを再利用します。検索文は検索のたびに変換します(図3の【2】)。
埋め込みの費用は、入力した文章のトークン数(文章を分割した単位の数)に応じてかかります。詳しくは、Azure公式価格ページで確認できます。
.NET 10 SDK、SQL Server 2025、Azureのモデル配置と接続設定をREADMEに沿って準備し、20260902_HrSearchフォルダで次のコマンドを実行します。
seedでサンプル10名を入れ直し、vectorで検索します。
dotnet run --project src/HrMatchingSearch -- seed
dotnet run --project src/HrMatchingSearch -- vector "経験の浅いメンバーを指導できる人は?"【1】紹介文をベクトルにして保存する ── vector(1536) 列と埋め込み
まず、紹介文を持つPersonクラスでは、ベクトル用の列を用意します。以下はData.csの抜粋です。
using Microsoft.Data.SqlTypes;
sealed class Person
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Bio { get; set; }
public SqlVector<float> Embedding { get; set; }
}同じファイルのHrContext.OnModelCreatingで、Embeddingを1536次元の列に対応付けます。以下はメソッド内の一部を省略した抜粋です。
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var person = modelBuilder.Entity<Person>();
// ...
person.Property(p => p.Embedding).HasColumnType("vector(1536)");
}SqlVector<float>はMicrosoft.Data.SqlTypesの型で、そのままvector型の列に対応します。
vector(1536) の1536は、使用するEmbeddingモデルの出力次元数に合わせて指定します。
「1536」は、今回使うtext-embedding-3-smallのデフォルト値です。
モデルを変更する場合は、同じ次元数でも保存済みの紹介文から、ベクトルを作り直します。検索文も同じモデルで変換します(埋め込みの公式ガイド)。
文章をベクトルに変えているのは、次のメソッドです。VectorSearch.csにあります。
/// <summary>文章を埋め込みベクトルに変える。登録時と検索時の両方で使う。</summary>
public static async Task<SqlVector<float>> CreateEmbeddingAsync(SearchEnv env, string text)
{
OpenAIEmbedding embedding = await env.Embedding.GenerateEmbeddingAsync(text);
return new SqlVector<float>(embedding.ToFloats());
}引数のSearchEnvは、DBの接続とAIモデルのクライアントをまとめた自前のクラスです。連載の3つの方式で共通して使い、しきい値などの定数もここに置いています。
seedはこれを10名分呼び、紹介文と一緒に保存します。次は登録し直したときの出力の抜粋です。
$ dotnet run --project src/HrMatchingSearch -- seed
既存の10件を削除しました。
登録: 佐藤 健一
登録: 鈴木 美咲
登録: 松本 涼
…
10件 登録完了。登録後のDBを確認すると、図4のようになっています。

このように、紹介文のベクトルは登録時に作り、検索時に再利用します。(紹介文を変更した場合は再計算が必要です)
次の【2】では、同じメソッドで検索文を変換します。
【2】検索文をベクトルにする ── 紹介文と同じ埋め込みを使う
検索文は1件だけ、【1】と同じメソッドに渡します。
public static async Task RunAsync(SearchEnv env, string query)
{
// ...
var queryVector = await CreateEmbeddingAsync(env, query);
var results = await SearchByVectorAsync(env, queryVector);
PrintResults(results);
}紹介文と検索文で同じ埋め込みモデルを使うことが条件です。
検索文のベクトル化は、検索のたびに1回です。ここまでで検索文のベクトルはできましたが、誰が近いかはまだ決まっていません。
【3】DBで距離を測る ── VectorDistance と、しきい値0.60
先に定数を見ておきます。しきい値と、近い順に何件まで見せるか(TopN)は、【1】で出てきたSearchEnvに置いてあります(SearchEnv.cs)。
sealed class SearchEnv
{
// ...
public const double DistanceThreshold = 0.60;
public const int TopN = 5;
// ...
}距離の計算と並べ替えは、データベースで行います。次の抜粋では、元コードの処理を変えず、説明用のコメントと (1)〜(4) の番号を加えています。
(1)〜(3) はSQL Server側、(4) はアプリ側です。
public static async Task<List<ScoredCandidate>> SearchByVectorAsync(
SearchEnv env, SqlVector<float> queryVector)
{
await using var db = env.OpenDb();
var rows = await db.People
.Select(p => new
{
p.Name,
p.Bio,
// (1) VECTOR_DISTANCE('cosine', ...) に翻訳され、DB側で計算される
Distance = EF.Functions.VectorDistance("cosine", p.Embedding, queryVector),
})
.OrderBy(r => r.Distance) // (2) 並べ替えもDB側
.Take(SearchEnv.TopN) // (3) 近い順に5件だけ受け取る
.ToListAsync();
// (4) ここからアプリ側。受け取った5件をレコードに移す
return [.. rows.Select(r => new ScoredCandidate(r.Name, r.Bio, r.Distance))];
}(1) のEF.Functions.VectorDistanceは、T-SQLのVECTOR_DISTANCEへ翻訳されます。
第1引数は距離の測り方で、ここでは一般的に使われるコサイン距離(cosine)を指定しています。
cosineが返すのは「距離」なので、向きがまったく同じなら0、正反対なら2です。
アプリが10名分を取ってきて自分で比べているのではありません。(1)〜(3) はすべてSQL Server側で終わり、アプリが受け取るのは、その結果である上位5件だけです。
○×を付けるのはアプリ側です。ヒットかどうかの判定は、受け取ったレコードが持っています。
record ScoredCandidate(string Name, string Bio, double Distance)
{
public bool IsHit => Distance < SearchEnv.DistanceThreshold;
// ...
}受け取った5件を1件ずつ判定し、0.60未満だけをヒットにします。この判定も (4) と同じくアプリ側です。5件とも0.60以上ならヒットは0件、つまり「該当なし」になります。
しきい値の0.60は、このデータでうまく動くよう調整した値です。普遍的な定数ではありません。
今回は10名全員との距離を計算する構成です。大量のデータを扱うためのベクトルインデックスは使いません。
実験結果 ── 方式②に、4つの依頼を投げる
第1回と同じ4つの依頼を、各3回実行しました。ヒット件数は各依頼で3回とも同じです。以下の距離は1回目の値で、依頼Dには小数第4位の揺れがありました(実測ログ)。
取り上げる順はA→C→B→Dです。まずAで距離という尺度に慣れてから、第1回が落としたCを見ます。そのあと、同じ0件でも意味の違うBとDを並べます。
| 依頼 | 1位(距離) | ヒット | 判定 |
|---|---|---|---|
| A 組み込みの経験がある人は? | 加藤 0.4879 | 2件 | ○ 対策なしで第1回と同じ2名を拾えた |
| C 経験の浅いメンバーを指導できる人は? | 松本 0.5643 | 1件 | ○ 第1回が落とした宿題を拾った |
| B Salesforceの導入案件が来た。経験者はいる? | 佐藤 0.6068 | 0件 | ○ 記載がないので正しい |
| D クラウドの経験がないと思われる人は? | 伊藤 0.6095 | 0件 | × 該当者7名を1人も出せない |
A 組み込み ── 表記ゆれを、対策なしで越える
送り仮名の違う加藤さんと村上さんが、そのまま1位・2位に来ました。次は1回目の出力から、紹介文を短くした抜粋です。
検索文: 組み込みの経験がある人は?
--- 距離が小さい順(しきい値 0.60) ---
○ [0.4879] 加藤 修平: 組込みエンジニア。C/C++での車載ソフトウェア開発が専門。…
○ [0.5486] 村上 早紀: 組み込み機器のユーザーインターフェース設計。…
× [0.6516] 鈴木 美咲: フロントエンドエンジニア。ReactとTypeScriptが得意で、…
× [0.6606] 佐藤 健一: 10年以上C#でバックエンド開発を担当。ASP.NET CoreとAzureを…
× [0.6705] 松本 涼: バックエンド担当。社内の勉強会を毎週開いていて、…
ヒット: 2件第1回では、表記ゆれなどに対応する6つの要点をプロンプトへ書きました。今回は検索用のプロンプトを作らずに、同じ2名を拾えました。
3位の鈴木さんは0.6516で、2位との間が0.103空いています。線を引く位置が多少ずれても、この2名で切れます。
C 指導できる人 ── 「指導」の記載がなくても、1位で当てる
第1回の宿題です。
検索文: 経験の浅いメンバーを指導できる人は?
--- 距離が小さい順(しきい値 0.60) ---
○ [0.5643] 松本 涼: バックエンド担当。社内の勉強会を毎週開いていて、…
× [0.7310] 村上 早紀: 組み込み機器のユーザーインターフェース設計。…
× [0.7427] 中村 亮: セキュリティエンジニア。脆弱性診断とペネトレーションテストを担当。…
× [0.7438] 佐藤 健一: 10年以上C#でバックエンド開発を担当。…
× [0.7666] 加藤 修平: 組込みエンジニア。C/C++での車載ソフトウェア開発が専門。…
ヒット: 1件松本さんが1位で、距離は0.5643。2位の村上さんは0.7310なので、差は0.167あります。1位と2位の差は、今回の4依頼のなかで最大です。
松本さんの紹介文に「指導」「メンター」「OJT」「育成」は1語も出てきません。それでも1位になり、しきい値0.60未満に入ったのは松本さんだけでした。
「指導」という語を足さずに、紹介文をそのまま使って見つかりました。書き手に表現をそろえてもらわなくても探せる点が、うれしいですね。
B Salesforce ── 0件。ただし1位は 0.6068
10名の紹介文にSalesforceの記載はないので、0件が正しい答えです。
検索文: Salesforceの導入案件が来た。経験者はいる?
--- 距離が小さい順(しきい値 0.60) ---
× [0.6068] 佐藤 健一: 10年以上C#でバックエンド開発を担当。…
× [0.6366] 岡田 里奈: 社内システムのサーバ移行を担当。オンプレからAzureへ、…
× [0.6503] 鈴木 美咲: フロントエンドエンジニア。ReactとTypeScriptが得意で、…
× [0.6520] 伊藤 翔太: インフラエンジニア。KubernetesとTerraformによるクラウド基盤構築が専門。…
× [0.7036] 加藤 修平: 組込みエンジニア。C/C++での車載ソフトウェア開発が専門。…
ヒット: 0件
該当なし1位の佐藤さんは0.6068で、しきい値0.60との差は0.0068です。境界のすぐ外側にいます。
D クラウド未経験 ── 0件。しかも上位はクラウドの人
今回、これだけうまくいきませんでした。正解は7名ですが、結果は0件です。
検索文: クラウドの経験がないと思われる人は?
--- 距離が小さい順(しきい値 0.60) ---
× [0.6095] 伊藤 翔太: インフラエンジニア。KubernetesとTerraformによるクラウド基盤構築が専門。…
× [0.6342] 佐藤 健一: 10年以上C#でバックエンド開発を担当。ASP.NET CoreとAzureを…
× [0.6839] 岡田 里奈: 社内システムのサーバ移行を担当。オンプレからAzureへ、…
× [0.6936] 中村 亮: セキュリティエンジニア。脆弱性診断とペネトレーションテストを担当。…
× [0.7002] 井上 拓海: オンプレのサーバー運用が長い。監視とバックアップ設計、…
ヒット: 0件
該当なし問題は0件そのものより、並び順です。
1位の伊藤さんの紹介文には「クラウド基盤構築が専門」と書いてあります。佐藤さん・岡田さんを含めた上位3名は、第1回で除外された3名そのものです。
探したいのは「クラウドをやっていない人」なのに、上位はクラウドをやっている人から並びます。
考察 ── ①と②で、落とす依頼が違う
第1回でできなかった依頼Cができるようになり、代わりに依頼Dができなくなりました。第1回と第2回の答えを、4つの依頼で並べます。
| 依頼 | ① キーワード検索 | ② ベクトル検索 |
|---|---|---|
| A 組み込みの経験がある人は? | ○ 2件 | ○ 2件 |
| B Salesforceの経験者はいる? | ○ 0件(記載がないので正しい) | ○ 0件(記載がないので正しい) |
| C 経験の浅いメンバーを指導できる人は? | × 0件 | ○ 松本さんを1位で拾った |
| D クラウドの経験がないと思われる人は? | ○ 7件(クラウド Azure AWS … を除く語にした) | × 0件 |
①が落としたのは依頼C、②が落としたのは依頼Dで、穴が重なっていません。
違いは、何を見て候補を決めているかです。①は字面が含まれるかどうか、②は意味が近いかどうか。だから②は「指導」と書いていない松本さんに届きました(依頼C)。
逆に「クラウドの記載がない」という状態は、意味の近さでは測れません(依頼D)。
第1回の除く語(exclude)が依頼Dに答えられたのは、字面の有無という別の情報を見ていたからです。
「近い人を探したいのか、記載の有無を調べたいのか」── ここを先に確かめておくと、どちらの方式で作るかを選べます。
まとめ
AI人材検索を題材として、ベクトル検索の方式を使い、4つの依頼のうち3つに正しく答えられました。ポイントは以下です。
- 検索語が一致しなくても届く:依頼Cでは「指導」の記載がない松本さんを1位で拾えた
- 距離は「近さ」であって「有る/無い」ではない:依頼Dでは、除外したい経験者が上位に並んだ
- しきい値は自分のデータで決める:0.60はこのデータで調整した値で、普遍的な定数ではない
①②はどちらも候補を返すところまでなので、それぞれ単体ではRAG(検索拡張生成)ではありませんが、RAGの検索部分(候補を集める部分)にはそのまま使えます。
そこで次回は図1の方式③、RAGについて紹介します。①と②の両方が集めた候補をAIに読ませて、該当者と理由を返させます。
方式③RAGによって、方式①・②のいいとこどりをしつつ、検索文に対してより精度の高い結果を得ることを目指します。
引き続き、AIを使った自然文による業務データ検索について、一緒に学んでいきましょう!





