1. 为什么 .NET 开发者需要把向量搜索搬进 EF Core
如果你正在用 .NET 做企业级应用,大概率已经遇到过这样的需求:用户想在知识库里搜“忘记登录凭证怎么办”,而文档里写的是“如何重置密码”。传统LIKE '%密码%'完全匹配不上,全文检索也只能靠关键词碰运气。向量搜索解决的就是这个问题——把文本转成高维数值向量,在向量空间里算余弦距离,语义相近的内容自然靠得近。
EF Core 从 9 开始实验性支持向量,到 .NET 10 内置SqlVector<T>类型和EF.Functions.VectorDistance()函数,意味着你不需要引入额外的向量数据库,直接在 SQL Server 2025 里就能完成相似度查询。这对已经用 EF Core 管理业务数据的团队来说,迁移成本几乎为零。
但真正落地时,卡点往往不在 LINQ 写法,而在模型调用通道:Embedding 生成要调 API,RAG 问答要调 Chat 模型,MCP 工具链里可能还要调 Claude 或 Codex。每个服务一套 Key、一套 Base URL,环境变量散落在appsettings.json、.env、CI 配置里,换模型时改到怀疑人生。我试过在一个项目里同时维护 OpenAI、Azure OpenAI 和本地 Ollama 三套配置,光是对齐维度就花了半天。
这篇内容聚焦一条完整链路:用 TaoToken 统一 Key 和 API 通道,在 EF Core 里落地向量列映射与相似度查询,再把检索能力包装成 MCP 工具,最后跑一次端到端验证。适合已经会用 EF Core、想快速把 RAG 检索层搭起来的 .NET 开发者。你不需要提前了解向量数据库,跟着配置走就行。
2. TaoToken 统一 Key 的前置准备与模型选型
TaoToken 在这里扮演的角色是模型调用的统一入口。你不需要为 Embedding 和 Chat 分别申请不同厂商的 Key,也不用在代码里写多套HttpClient分支。一个 API Key、一个 Base URL,就能覆盖 Embedding 生成、对话补全、以及后续 MCP 工具链里的模型调用。
先明确你要用到的两个模型 ID。Embedding 模型决定向量维度,必须和数据库列定义严格一致。以常见的text-embedding-3-small为例,输出 1536 维;如果你选text-embedding-3-large,就是 3072 维。Chat 模型用于 RAG 最后一步的答案生成,选你习惯的即可。这两个模型 ID 在 TaoToken 的模型列表里都能查到,调用方式与 OpenAI 兼容接口一致。
拿到 Key 之后,建议用环境变量管理,不要硬编码进appsettings.json提交到仓库。本地开发可以放在launchSettings.json的environmentVariables里,生产环境走密钥管理服务。下面这段配置我实测下来最省事:
{ "TaoToken": { "BaseUrl": "https://taotoken.net/api", "ApiKey": "sk-your-taotoken-key", "EmbeddingModel": "text-embedding-3-small", "ChatModel": "gpt-4o-mini" } }注意 Base URL 用https://taotoken.net/api,不要在后面多加/v1,OpenAI 兼容客户端通常会自动拼接路径。如果你用的是Microsoft.Extensions.AI里的OpenAIClient,构造时传入这个 Base URL 即可。
模型选型上有个容易踩的坑:Embedding 模型一旦确定,数据库里所有已存储向量的维度就固定了。中途换模型意味着全量重新生成嵌入。所以建议在项目初期就把维度写进迁移脚本,比如vector(1536),后续换模型时新建列而不是改原列。
另外,MCP 工具链里如果要用到 Claude Code 或 Codex 这类编码 Agent,它们的认证配置和普通 API Key 不同。Claude Code 走的是 Anthropic 的 OAuth 流程,Codex 用auth.json。这部分在第五节排障时会展开,这里先记住:TaoToken 的 API Key 管的是 Embedding 和 Chat 调用,编码 Agent 的认证是另一套。
3. 可复制的 DbContext 配置与向量列映射
这一节给出可以直接粘贴的代码。假设你已经建好了一个 ASP.NET Core 项目,装了Microsoft.EntityFrameworkCore.SqlServer和Microsoft.Extensions.AI.OpenAI两个包。
先定义实体。关键点是[Column(TypeName = "vector(1536)")]和SqlVector<float>类型,两者必须匹配:
using Microsoft.Data.SqlTypes; using System.ComponentModel.DataAnnotations.Schema; public class DocumentChunk { public int Id { get; set; } public string Content { get; set; } = string.Empty; public string Source { get; set; } = string.Empty; [Column(TypeName = "vector(1536)")] public SqlVector<float> Embedding { get; set; } }DbContext 里正常注册DbSet,并在OnModelCreating里确认列类型。如果你用的是 EF Core 10 及以上,SqlVector<T>已经内置,不需要额外扩展包:
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<DocumentChunk> DocumentChunks => Set<DocumentChunk>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<DocumentChunk>() .Property(d => d.Embedding) .HasColumnType("vector(1536)"); } }接下来是 Embedding 生成器的注册。用Microsoft.Extensions.AI的IEmbeddingGenerator抽象,底层指向 TaoToken 的兼容端点:
using Microsoft.Extensions.AI; using OpenAI; var builder = WebApplication.CreateBuilder(args); var taoTokenKey = builder.Configuration["TaoToken:ApiKey"]; var taoTokenUrl = builder.Configuration["TaoToken:BaseUrl"]; builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>( new OpenAIClient(new ApiKeyCredential(taoTokenKey), new OpenAIClientOptions { Endpoint = new Uri(taoTokenUrl) }) .GetEmbeddingClient("text-embedding-3-small") .AsIEmbeddingGenerator());写入文档时,先生成向量再存:
var embedding = await embeddingGenerator.GenerateVectorAsync(chunk.Content); dbContext.DocumentChunks.Add(new DocumentChunk { Content = chunk.Content, Source = chunk.Source, Embedding = new SqlVector<float>(embedding) }); await dbContext.SaveChangesAsync();查询时用EF.Functions.VectorDistance,第一个参数指定距离度量。cosine适合大多数语义检索场景,euclidean适合归一化后的向量,dot适合已经做过内积优化的场景:
var queryVector = await embeddingGenerator.GenerateVectorAsync(userQuery); var sqlVector = new SqlVector<float>(queryVector); var results = await dbContext.DocumentChunks .OrderBy(d => EF.Functions.VectorDistance("cosine", d.Embedding, sqlVector)) .Take(5) .Select(d => new { d.Content, d.Source }) .ToListAsync();如果你需要同时返回相似度分数,可以在Select里再算一次距离,或者用VectorDistance的返回值排序后手动计算。注意OrderBy里的距离函数会被翻译成 SQL 的VECTOR_DISTANCE,不要在它外面套Math.Abs之类的 C# 方法,否则会退化成客户端计算。
4. 验证请求:一次端到端检索跑通
配置写完之后,必须验证整条链路真的通了。我习惯用一个最小控制台动作来测,不依赖 Web 层。
第一步,确认数据库里已经有向量数据。执行一条简单查询,看Embedding列是否非空:
var count = await dbContext.DocumentChunks .Where(d => d.Embedding != null) .CountAsync(); Console.WriteLine($"已存储向量文档数:{count}");如果这里是 0,说明写入环节没成功,先回去检查 Embedding 生成是否返回了非空数组。
第二步,构造一个语义查询,观察返回结果是否相关。比如数据库里存的是“如何重置密码”的文档,你输入“忘记登录凭证”,看 Top 1 是否命中:
var query = "忘记登录凭证怎么办"; var queryVector = await embeddingGenerator.GenerateVectorAsync(query); var sqlVector = new SqlVector<float>(queryVector); var top = await dbContext.DocumentChunks .OrderBy(d => EF.Functions.VectorDistance("cosine", d.Embedding, sqlVector)) .Take(3) .ToListAsync(); foreach (var item in top) { Console.WriteLine($"[{item.Source}] {item.Content[..Math.Min(80, item.Content.Length)]}"); }实测下来,如果 Embedding 模型和维度都对,余弦距离排序会在 200ms 内返回结果。如果返回顺序明显不合理,先检查两件事:一是写入和查询是否用了同一个 Embedding 模型,二是向量是否做了归一化。text-embedding-3-small默认输出已归一化,不需要额外处理。
第三步,把检索结果拼进 Chat 请求,验证 RAG 闭环:
var context = string.Join("\n---\n", top.Select(t => t.Content)); var chatClient = new OpenAIClient(new ApiKeyCredential(taoTokenKey), new OpenAIClientOptions { Endpoint = new Uri(taoTokenUrl) }) .GetChatClient("gpt-4o-mini"); var response = await chatClient.CompleteChatAsync(new[] { new SystemChatMessage("根据以下上下文回答用户问题,不要编造。\n" + context), new UserChatMessage(query) }); Console.WriteLine(response.Value.Content[0].Text);到这里,Embedding 生成、向量存储、相似度查询、RAG 生成四步全部跑通。整个过程只用了 TaoToken 一个 Key 和一个 Base URL,没有为 Embedding 和 Chat 分别配置。
5. 常见报错排查:401、local proxy failed 与 reading choices
这一节对照真实报错,给出排查路径。这些错误我在不同项目里都遇到过,按出现频率排序。
401 Unauthorized最常见。先确认ApiKey是否以sk-开头且没有多余空格。如果你把 Key 放在appsettings.json里,注意 JSON 转义。另一个隐蔽原因是 Base URL 写成了https://taotoken.net/api/v1,导致客户端拼出/v1/v1/embeddings。正确写法是https://taotoken.net/api,让 OpenAI 客户端自己补/v1。
local proxy failed通常出现在你本地设置了 HTTP 代理环境变量,但代理进程没启动。检查HTTP_PROXY和HTTPS_PROXY是否指向了一个不可用的地址。在 .NET 里,HttpClient默认会读取这两个环境变量。临时解决办法是在OpenAIClientOptions里显式设置Transport = new HttpClientTransport(new HttpClient(new HttpClientHandler { UseProxy = false })),但生产环境还是建议把代理配置理清楚。
reading choices 返回空说明请求发出去了,但响应体里没有choices字段。先打印原始响应内容确认。常见原因是模型 ID 写错,比如把gpt-4o-mini写成了gpt-4o-mini-2024,TaoToken 侧找不到对应模型会返回错误结构。另一个原因是请求体里messages为空数组,Chat 接口会拒绝。
OAuth 相关报错如果你在 MCP 工具链里接的是 Claude Code,它不走 API Key,而是走 Anthropic 的 OAuth。报错信息里会出现invalid_grant或token expired。这时候需要重新执行 Claude Code 的登录流程,和 TaoToken 的 Key 是两套体系。Codex 的auth.json同理,路径通常在~/.codex/auth.json,里面的 token 过期后要重新生成。
维度不匹配报错信息类似The vector dimension 1536 does not match the column dimension 3072。这说明你写入时用的 Embedding 模型和建表时的维度定义不一致。解决办法是新建一个列,重新生成全部嵌入,或者换回原来的模型。
MCP 工具注册后 AI 不调用先确认工具描述是否清晰。[McpToolParameter(Description = "...")]里的描述会直接影响模型是否决定调用。描述太模糊,模型可能选择不调用。另外确认 MCP 客户端已经通过ListToolsAsync拿到了工具列表,并且UseFunctionInvocation()已启用。
6. 把检索能力接入 MCP 工具链与统一 Key 管理
最后一步,把上面验证过的检索逻辑包装成 MCP 工具,让 AI 助手能直接调用。这里用 C# MCP SDK 的注解方式,代码结构和第三节的查询逻辑一致,只是套了一层工具定义:
using ModelContextProtocol.Server; [McpServerToolType] public class DocumentSearchTool { private readonly AppDbContext _db; private readonly IEmbeddingGenerator<string, Embedding<float>> _embedding; public DocumentSearchTool(AppDbContext db, IEmbeddingGenerator<string, Embedding<float>> embedding) { _db = db; _embedding = embedding; } [McpServerTool(Name = "search_documents")] [Description("根据用户问题检索内部文档,返回最相关的片段")] public async Task<string> SearchAsync( [Description("用户的问题或查询语句")] string query) { var queryVector = await _embedding.GenerateVectorAsync(query); var sqlVector = new SqlVector<float>(queryVector); var results = await _db.DocumentChunks .OrderBy(d => EF.Functions.VectorDistance("cosine", d.Embedding, sqlVector)) .Take(5) .Select(d => d.Content) .ToListAsync(); return string.Join("\n---\n", results); } }注册 MCP 服务时,把AppDbContext和IEmbeddingGenerator一起注入。注意 MCP 工具的执行生命周期和 Web 请求不同,DbContext要用AddDbContext注册为 Scoped,MCP 框架会为每次工具调用创建作用域。
如果你用的是 Claude Code 或 Cline 这类支持 MCP 的客户端,配置里需要填三件套:Base URL、Key、Model ID。以 Cline 的 MCP 配置为例:
{ "mcpServers": { "dotnet-rag": { "command": "dotnet", "args": ["run", "--project", "./MyRagMcpServer"], "env": { "TaoToken__BaseUrl": "https://taotoken.net/api", "TaoToken__ApiKey": "sk-your-taotoken-key", "TaoToken__EmbeddingModel": "text-embedding-3-small" } } } }这样配置之后,Cline 启动时会拉起你的 .NET MCP Server,AI 在需要检索文档时自动调用search_documents工具。整个链路里,Embedding 和 Chat 都走 TaoToken 的同一个 Key,MCP 工具本身不直接持有 Key,而是通过环境变量注入。
如果你需要长期跑编码 Agent 或更复杂的 Agent 工作流,可以考虑用 Coding Plan 来管理调用配额和模型切换。对于只是验证模型效果的场景,模型对话页面可以直接测试 Embedding 和 Chat 的返回是否符合预期。接入文档里有完整的参数说明和错误码对照表,排障时比翻日志快。
最后提醒一个实际部署时的细节:MCP Server 如果用 stdio 传输,标准输出会被协议占用,所有日志必须走标准错误。如果你在工具方法里用了Console.WriteLine调试,会导致 MCP 客户端解析失败。把日志改成Console.Error.WriteLine或者用ILogger输出到文件。这个坑我在第一次部署时踩过,排查了半天才发现是调试输出污染了协议通道。