
1. 项目概述为什么选择.NET WebAPI与智谱清言构建本地知识库AI最近在做一个内部工具需要让团队能快速查询公司内部的技术文档、产品手册和过往的会议纪要。市面上的SaaS知识库产品要么太贵要么数据安全上不放心毕竟有些资料比较敏感。琢磨了一下决定自己动手用最熟悉的.NET技术栈结合现在挺火的智谱清言大模型搭一个轻量级的本地知识库问答AI。这个方案的核心思路很直接把散落在各处的文档Word、PDF、TXT先“喂”给系统系统理解并记住这些内容。当用户提问时系统不是去网上搜而是从“记住”的这些本地知识里找到最相关的信息再让大模型组织成通顺的答案回答出来。整个过程数据不出内网完全自主可控。.NET WebAPI在这里扮演了“大脑”和“调度中心”的角色负责处理文档、与向量数据库交互、调用大模型API并最终把答案返回给前端。智谱清言的GLM系列模型特别是其对话和文本理解能力非常适合做这种基于上下文的问答任务。如果你也在为团队知识管理头疼或者想尝试将大模型能力与自己的业务系统结合这个项目会是一个很好的起点。它不要求你有很深的大模型算法背景但需要你对.NET开发和API设计有一定了解。接下来我会拆解整个实现过程从环境搭建到核心代码再到避坑指南手把手带你走一遍。2. 核心架构与工具选型解析2.1 整体技术栈设计思路一个完整的本地知识库问答系统可以拆解成几个核心环节文档处理、知识存储、意图理解与答案生成。我的技术选型就是围绕这几个环节展开的。首先文档处理需要能把各种格式的文档转换成纯文本并切割成适合模型处理的小块Chunk。这里我选择了iTextSharp和Microsoft.Office.Interop.Word来处理PDF和Word对于TXT就直接读取。文本切割是个精细活不能简单地按字数切那样可能会把一个完整的句子或概念拦腰截断。我采用的是基于标点符号和换行符的语义切割尽量保证每个文本块在语义上的完整性。其次知识存储是核心。传统数据库存文本无法高效计算文本之间的语义相似度。所以必须引入向量数据库。我把处理好的文本块通过文本嵌入模型转换成高维向量可以理解成一段文字的“数学指纹”然后存入向量数据库。当用户提问时先把问题也转换成向量然后去数据库里快速找出“指纹”最相似的几个文本块这些就是最相关的知识片段。我选择了Milvus这款开源向量数据库它性能不错社区活跃而且有官方的.NET SDKMilvus.Client集成起来很方便。最后意图理解与答案生成交给了智谱清言GLM大模型。它的API接口清晰对于中文的理解和生成效果很出色特别适合我们这种中文知识库的场景。WebAPI作为中枢使用HttpClient调用智谱的API并将检索到的相关文本片段作为上下文Context连同用户问题一起发送过去要求模型基于给定的上下文回答问题。整个数据流是这样的上传文档 - 文本提取与分块 - 文本向量化 - 存入Milvus - 用户提问 - 问题向量化 - Milvus向量检索 - 获取相关文本片段 - 组合成Prompt发送给GLM - 接收并返回模型答案。2.2 关键组件版本与选型理由这里详细列出我用的核心库及其版本并解释为什么选它。后端框架.NET 8ASP.NET Core WebAPI.NET 8是LTS版本性能和功能都有保障。ASP.NET Core WebAPI轻量且高效非常适合构建这种服务接口。它的依赖注入、配置系统、中间件管道能让代码结构非常清晰。向量数据库Milvus 2.3.x与Milvus.Client 2.2.xMilvus是专为向量搜索设计的数据库相比用PostgreSQL的pgvector扩展它的专业性和性能更优。选择2.3.x这个相对稳定的版本避免最新版可能存在的未知问题。对应的.NET SDKMilvus.Client版本需要与之匹配。文本嵌入模型BGE-M3或智谱Embedding API这是将文本转为向量的模型。有两种选择本地部署比如BAAI/bge-m3模型用ONNX Runtime在本地加载和推理。好处是数据完全离线速度快缺点是消耗本地GPU/CPU资源初次加载慢。调用API直接使用智谱清言提供的Embedding API。好处是省事模型效果好且稳定缺点是会产生API调用费用且文本需要发送到智谱的服务器需评估数据敏感性。 对于内部敏感数据我最终选择了本地部署BGE-M3。虽然初期折腾一下但长远看更可控。我使用Microsoft.ML.OnnxRuntime库来加载转换好的ONNX模型文件进行推理。大模型API智谱清言开放平台GLM-4/GLM-3.5-Turbo智谱的API文档规范响应速度快且针对中文进行了深度优化。GLM-4能力更强但成本稍高GLM-3.5-Turbo性价比高。对于企业内部知识问答GLM-3.5-Turbo通常已经足够。需要在智谱开放平台创建应用以获取API Key。文档处理库iTextSharp.LGPLv2.Core(v4.1.6): 用于解析PDF文件。务必注意许可证LGPL版本可以免费用于商业项目。Microsoft.Office.Interop.Word通过COM组件操作Word仅适用于Windows服务器环境。如果是Linux需要考虑其他方案如DocX。辅助工具库Newtonsoft.Json或System.Text.Json用于序列化API请求和响应。Polly用于构建API调用的重试和熔断策略增强系统健壮性。Serilog用于记录日志方便排查问题。注意环境兼容性。如果你的生产环境是Linux需要特别注意Microsoft.Office.Interop.Word无法使用。可以考虑将文档预处理步骤独立成一个在Windows机器上运行的服务或者寻找跨平台的文档处理方案如OpenXML SDK对于.docx格式但后者对复杂格式的解析能力可能稍弱。3. 核心模块实现与代码拆解3.1 文档解析与文本分块服务这是知识库的“喂料”入口。我创建了一个DocumentProcessorService类核心方法是ProcessDocumentAsync。第一步格式判断与文本提取根据文件扩展名路由到不同的解析器。public async TaskListstring ExtractTextChunksAsync(string filePath, int chunkSize 500, int overlap 50) { string rawText ; string extension Path.GetExtension(filePath).ToLower(); switch (extension) { case .pdf: rawText await ExtractTextFromPdfAsync(filePath); break; case .docx: case .doc: rawText ExtractTextFromWord(filePath); // 注意此方法仅限Windows break; case .txt: rawText await File.ReadAllTextAsync(filePath, Encoding.UTF8); break; default: throw new NotSupportedException($不支持的文件格式: {extension}); } // 后续进行分块... }ExtractTextFromPdfAsync使用了iTextSharp核心是遍历每一页获取文本。ExtractTextFromWord则通过COM Interop调用Word应用程序打开文件并读取内容。这里有个大坑COM Interop非常消耗资源且不稳定务必在finally块中确保释放Word进程对象否则服务器内存会泄漏最终被无数个winword.exe进程拖垮。第二步语义分块Chunking简单的按字符数切割会破坏语义。我实现了一个基于句子和段落的递归分块算法。private Liststring SemanticSplit(string text, int chunkSize, int overlap) { var chunks new Liststring(); // 首先尝试按段落分割 var paragraphs text.Split(new[] { \r\n\r\n, \n\n }, StringSplitOptions.RemoveEmptyEntries); var currentChunk new StringBuilder(); foreach (var para in paragraphs) { // 如果当前段落加上已有内容仍然小于块大小就加入 if (currentChunk.Length para.Length chunkSize) { currentChunk.AppendLine(para); } else { // 否则保存当前块开始新块并带上重叠部分 if (currentChunk.Length 0) { chunks.Add(currentChunk.ToString()); // 重叠策略取当前块末尾的overlap个字符作为下一块的开头 var overlapText currentChunk.ToString().Substring(Math.Max(0, currentChunk.Length - overlap)); currentChunk.Clear().Append(overlapText).AppendLine(para); } else { // 如果单个段落就超长则按句子强行分割 var longChunks SplitLongParagraph(para, chunkSize); chunks.AddRange(longChunks); } } } if (currentChunk.Length 0) chunks.Add(currentChunk.ToString()); return chunks; }chunkSize我通常设为500-800overlap设为50-100。重叠部分能保证上下文连贯避免一个问题所需的答案恰好被切在两个块中间。对于超长段落SplitLongParagraph方法会进一步按句号、问号、感叹号等分割成句子再组合成块。3.2 向量化与Milvus存储服务文本分块后需要将其转换为向量并存储。我创建了VectorStoreService。第一步文本向量化Embedding我本地部署了BGE-M3模型并导出为ONNX格式。使用OnnxRuntime进行推理。public class LocalEmbeddingService : IEmbeddingService { private InferenceSession _session; private Tokenizer _tokenizer; // 需要使用对应的Tokenizer public LocalEmbeddingService(string modelPath) { // 初始化ONNX Runtime会话 var options new SessionOptions(); // 如果有GPU可以设置ExecutionProvider为CUDA // options.AppendExecutionProvider_CUDA(); _session new InferenceSession(modelPath, options); _tokenizer Tokenizer.FromPretrained(BAAI/bge-m3); // 示例实际需加载对应的tokenizer文件 } public async Taskfloat[] GenerateEmbeddingAsync(string text) { // 1. 使用tokenizer将文本转换为模型输入的IDs var inputs _tokenizer.Encode(text, maxLength: 512); // 假设最大长度512 var inputIds inputs.Ids.Select(id (long)id).ToArray(); var attentionMask inputs.AttentionMask.Select(m (long)m).ToArray(); // 2. 准备ONNX模型输入 var inputTensor new DenseTensorlong(inputIds, new[] { 1, inputIds.Length }); var maskTensor new DenseTensorlong(attentionMask, new[] { 1, attentionMask.Length }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input_ids, inputTensor), NamedOnnxValue.CreateFromTensor(attention_mask, maskTensor) }; // 3. 运行推理 using var results _session.Run(inputs); var outputTensor results.First().AsTensorfloat(); // 4. 通常取最后一层隐藏状态的平均值作为句子向量 var embeddings outputTensor.ToArray(); // ... 进行平均或池化操作得到固定长度的向量如1024维 var finalVector Pooling(embeddings); // 假设的池化函数 return finalVector; } }实操心得本地模型推理第一次调用会很慢加载计算图后续会快很多。务必做好服务的预热可以在应用启动后先对一个样例文本进行一次推理。向量维度如1024需要与Milvus中集合Collection的字段维度定义完全一致。第二步连接与操作Milvus使用Milvus.ClientSDK。public class MilvusService { private readonly MilvusClient _client; private readonly string _collectionName knowledge_base; private readonly string _partitionName default_partition; public MilvusService(string host, int port) { _client new MilvusClient(host, port); } public async Task EnsureCollectionExistsAsync(int vectorDimension) { // 检查集合是否存在 var hasCollection await _client.HasCollectionAsync(_collectionName); if (!hasCollection) { // 定义集合SchemaID、文本内容、文本向量、来源元数据 var schema new CollectionSchema(_collectionName, 知识库集合) { Fields { FieldSchema.Createlong(id, isPrimaryKey: true), FieldSchema.CreateVarchar(content, maxLength: 65535), FieldSchema.CreateFloatVector(vector, dimension: vectorDimension), FieldSchema.CreateVarchar(source, maxLength: 500), } }; await _client.CreateCollectionAsync(schema); // 创建索引以加速向量搜索 var indexParameters new IndexParameters { MetricType MetricType.IP, // 使用内积IP作为相似度度量余弦相似度通常用IP IndexType IndexType.IVF_FLAT // 平衡精度和速度的索引类型 }; await _client.CreateIndexAsync(_collectionName, vector, indexParameters); } // 确保分区存在 await _client.CreatePartitionAsync(_collectionName, _partitionName, ifNotExists: true); } public async Task InsertAsync(string content, float[] vector, string source) { var ids new Listlong { DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; var contents new Liststring { content }; var vectors new Listfloat[] { vector }; var sources new Liststring { source }; var insertData new InsertData(_collectionName, _partitionName) .WithField(id, ids) .WithField(content, contents) .WithField(vector, vectors) .WithField(source, sources); await _client.InsertAsync(insertData); // 插入后建议手动刷新使数据立即可搜索生产环境需权衡性能 await _client.FlushAsync(_collectionName); } }关键点解析相似度度量MetricType.IP内积。当我们对向量进行归一化使其长度为1后向量A和B的内积A·B就等于它们的余弦相似度cos(θ)。BGE-M3等现代嵌入模型输出的向量通常已经是归一化的所以直接用IP最方便高效。索引类型IVF_FLAT是通用选择。对于千万级以下的数据量它能提供很好的精度和速度平衡。如果数据量极大亿级可以考虑HNSW索引。分区使用分区可以方便后续按不同知识库来源进行管理。插入和查询时指定分区能提升效率。Flush操作插入数据后数据先进入内存缓冲区Flush会将其持久化并建立索引。在批量导入时可以在最后执行一次Flush避免频繁IO影响性能。3.3 智能问答服务检索与生成这是最核心的QuestionAnsweringService它串联起检索Retrieval和生成Generation即RAG流程。第一步向量检索用户提问时先将问题转换为向量然后在Milvus中搜索最相似的K个文本块。public async TaskListKnowledgeChunk SearchSimilarChunksAsync(string question, int topK 5) { // 1. 问题向量化 var questionVector await _embeddingService.GenerateEmbeddingAsync(question); // 2. 在Milvus中执行向量搜索 var searchParameters new SearchParameters { OutputFields { content, source }, // 指定需要返回的字段 ConsistencyLevel ConsistencyLevel.Strong }; var searchResult await _milvusClient.SearchAsync( collectionName: _collectionName, partitionNames: new[] { _partitionName }, vectors: new[] { questionVector }, topK: topK, searchParameters: searchParameters ); // 3. 解析结果 var chunks new ListKnowledgeChunk(); foreach (var hit in searchResult.Results[0]) // 第一个也是唯一一个查询的结果 { chunks.Add(new KnowledgeChunk { Content hit.GetFieldValuestring(content), Source hit.GetFieldValuestring(source), Score hit.Score // 相似度分数 }); } return chunks; }topK参数控制返回多少条相关片段。太少可能信息不全太多可能引入噪声并增加后续模型处理的负担和成本。通常3-7条是个不错的范围。第二步构造Prompt并调用智谱清言API将检索到的相关文本片段和用户问题按照特定格式组合成Prompt发送给GLM模型。public async Taskstring GenerateAnswerAsync(string question, ListKnowledgeChunk relevantChunks) { // 1. 构造Prompt系统指令 上下文 用户问题 var contextBuilder new StringBuilder(); contextBuilder.AppendLine(请严格根据以下提供的上下文信息来回答问题。如果上下文信息中没有明确答案请直接回答“根据已知信息无法回答该问题”不要编造信息。); contextBuilder.AppendLine(\n【上下文信息】); foreach (var chunk in relevantChunks) { contextBuilder.AppendLine($- {chunk.Content} (来源{chunk.Source})); } contextBuilder.AppendLine(\n【用户问题】); contextBuilder.AppendLine(question); var prompt contextBuilder.ToString(); // 2. 准备调用智谱API的请求数据 var requestData new { model glm-3-turbo, // 或 glm-4 messages new[] { new { role user, content prompt } }, temperature 0.1, // 低温度值让答案更确定、更基于事实 max_tokens 1024 }; // 3. 使用HttpClient发送请求 using var httpClient new HttpClient(); httpClient.DefaultRequestHeaders.Add(Authorization, $Bearer {_apiKey}); var response await httpClient.PostAsJsonAsync(https://open.bigmodel.cn/api/paas/v4/chat/completions, requestData); response.EnsureSuccessStatusCode(); var apiResponse await response.Content.ReadFromJsonAsyncZhipuResponse(); // 简单的响应解析实际需要更健壮的错误处理 return apiResponse?.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? 抱歉未能生成答案。; }Prompt工程要点系统指令要明确开头就强调“严格根据上下文”并指示模型在不知道时承认这是控制幻觉Hallucination的关键。上下文清晰分隔用明显的标记如【上下文信息】将检索到的文本与用户问题分开。提供来源在上下文中附带(来源xxx)方便后续追溯答案依据也便于模型理解不同片段的关系。温度参数temperature设为较低值如0.1让模型输出更确定性、更少“创造性”这对于事实性问答至关重要。4. WebAPI接口设计与系统集成4.1 核心API端点规划整个系统的功能通过几个清晰的API端点暴露出来我设计了三个主要接口知识库管理接口 (POST /api/knowledge/ingest) 用于上传和解析文档将其存入向量数据库。考虑到大文件上传需要支持流式处理和异步操作。请求体采用multipart/form-data格式。智能问答接口 (POST /api/knowledge/ask) 这是核心的问答入口。接收用户问题返回AI生成的答案。为了提高可解释性响应里最好同时返回模型答案和它所参考的原文片段来源。知识库维护接口 (DELETE /api/knowledge/{source}) 用于删除特定来源如某个文件名的所有知识片段。这是必要的管理功能避免错误导入的数据污染知识库。4.2 问答接口实现详解以最核心的问答接口为例展示完整的控制器代码和最佳实践。[ApiController] [Route(api/[controller])] public class KnowledgeController : ControllerBase { private readonly IQuestionAnsweringService _qaService; private readonly ILoggerKnowledgeController _logger; public KnowledgeController(IQuestionAnsweringService qaService, ILoggerKnowledgeController logger) { _qaService qaService; _logger logger; } [HttpPost(ask)] public async TaskActionResultAskResponse AskQuestion([FromBody] AskRequest request) { if (string.IsNullOrWhiteSpace(request.Question)) { return BadRequest(问题不能为空。); } try { _logger.LogInformation(开始处理问题{Question}, request.Question); // 1. 检索相关文本片段 var relevantChunks await _qaService.SearchSimilarChunksAsync(request.Question, request.TopK ?? 5); if (!relevantChunks.Any()) { return Ok(new AskResponse { Answer 知识库中未找到相关信息。, References new ListReference() }); } // 2. 调用大模型生成答案 var answer await _qaService.GenerateAnswerAsync(request.Question, relevantChunks); // 3. 构造响应包含答案和参考来源 var response new AskResponse { Answer answer, References relevantChunks.Select(c new Reference { ContentSnippet c.Content.Length 100 ? c.Content.Substring(0, 100) ... : c.Content, Source c.Source, RelevanceScore c.Score }).ToList() }; _logger.LogInformation(问题处理完成。); return Ok(response); } catch (Exception ex) { _logger.LogError(ex, 处理问题{Question}时发生错误。, request.Question); // 返回一个通用的错误信息避免泄露内部细节 return StatusCode(500, 系统处理问题时发生错误请稍后重试。); } } } // 请求和响应模型 public class AskRequest { [Required] public string Question { get; set; } public int? TopK { get; set; } // 可选控制返回的参考片段数量 } public class AskResponse { public string Answer { get; set; } public ListReference References { get; set; } } public class Reference { public string ContentSnippet { get; set; } // 原文片段预览 public string Source { get; set; } // 来源文件 public float RelevanceScore { get; set; } // 相似度分数 }设计考量异步处理所有涉及IO数据库、网络API的操作都使用async/await避免阻塞线程池提高WebAPI的并发能力。结构化日志使用ILogger并传递参数如{Question}便于后续通过日志系统如ELK进行查询和分析。友好的响应格式不仅返回答案还返回参考来源和相关性分数。这增加了系统的透明度和可信度前端可以展示“答案依据”。全面的异常处理使用try-catch包裹核心逻辑记录详细错误日志供排查但向客户端返回友好的错误信息避免暴露堆栈跟踪等敏感信息。输入验证使用[Required]等数据注解进行基础验证并在方法开始处进行业务逻辑验证。4.3 应用配置与依赖注入在Program.cs或Startup.cs中需要将所有服务配置好。var builder WebApplication.CreateBuilder(args); // 1. 读取配置 builder.Configuration.AddJsonFile(appsettings.json, optional: false, reloadOnChange: true); var zhipuApiKey builder.Configuration[ZhipuAI:ApiKey]; var milvusConnection builder.Configuration.GetConnectionString(Milvus); var embeddingModelPath builder.Configuration[EmbeddingModel:LocalModelPath]; // 2. 注册服务 builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 开发阶段可用 // 注册自定义服务 builder.Services.AddSingletonIMilvusService(sp new MilvusService(milvusConnection.Host, milvusConnection.Port)); builder.Services.AddSingletonIEmbeddingService(sp new LocalEmbeddingService(embeddingModelPath)); builder.Services.AddScopedIDocumentProcessorService, DocumentProcessorService(); builder.Services.AddScopedIVectorStoreService, VectorStoreService(); builder.Services.AddScopedIQuestionAnsweringService, QuestionAnsweringService(); // 3. 配置HttpClient用于调用智谱API并加入重试策略 builder.Services.AddHttpClient(ZhipuAI, client { client.BaseAddress new Uri(https://open.bigmodel.cn/); client.DefaultRequestHeaders.Add(Authorization, $Bearer {zhipuApiKey}); }).AddTransientHttpErrorPolicy(policyBuilder policyBuilder.WaitAndRetryAsync(3, retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))) ); // 使用Polly实现指数退避重试 // 4. 构建并运行应用 var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); // 5. 应用启动时初始化Milvus集合可选 using (var scope app.Services.CreateScope()) { var milvusService scope.ServiceProvider.GetRequiredServiceIMilvusService(); await milvusService.EnsureCollectionExistsAsync(1024); // 假设向量维度是1024 } app.Run();配置管理要点所有敏感信息API Key、数据库连接串必须放在appsettings.json或更安全的配置源如Azure Key Vault、环境变量中绝不要硬编码在代码里。5. 部署、优化与问题排查实录5.1 本地开发与生产部署要点开发环境使用Docker快速启动一个Milvus单机版实例docker run -d --name milvus -p 19530:19530 -p 9091:9091 milvusdb/milvus:v2.3.3。这比本地编译安装省事太多。智谱清言的API Key在开发环境可以直接配在appsettings.Development.json里。本地运行WebAPI项目用Swagger UI测试接口非常方便。生产环境部署服务器选择如果使用本地嵌入模型需要一台带GPU的服务器以获得可接受的推理速度CPU也可以但慢很多。如果只用智谱的Embedding API则对服务器算力要求不高。Milvus集群生产环境建议使用Milvus集群至少2个节点以保证高可用。可以使用helm在K8s上部署或者使用Milvus官方提供的Zilliz Cloud托管服务。应用发布将.NET WebAPI项目发布为自包含Self-Contained的可执行文件或打包成Docker镜像。Docker化是更推荐的方式环境一致性好。反向代理与SSL使用Nginx或Apache作为反向代理处理SSL证书、负载均衡和静态文件服务。进程管理使用systemd(Linux) 或 Windows Service来守护进程确保应用崩溃后能自动重启。5.2 性能优化与效果提升技巧项目跑起来后我通过以下几个方向进行了优化1. 检索精度优化效果提升的关键分块策略调优这是最影响效果的一环。我尝试了不同的chunkSize和overlap。对于技术文档chunkSize600, overlap80效果较好。对于会议纪要段落短chunkSize400更合适。没有银弹需要用自己的数据做AB测试。检索后重排序Re-ranking向量检索返回的TopK结果有时最相关的并不一定排第一。可以引入一个轻量级的重排序模型如BGE-Reranker对TopK结果进行二次精排让最相关的片段排在前面再送给大模型。这能显著提升最终答案的准确性。元数据过滤在向量搜索时可以加入元数据过滤条件。例如如果知识库有多个部门的数据用户提问时可以指定“仅搜索技术部的文档”Milvus支持在搜索时通过布尔表达式过滤source字段能极大提升检索的精准度。2. 系统性能优化嵌入向量缓存对于常见、标准的问题可通过问题聚类得到可以将其向量和对应的答案缓存起来如用Redis。下次遇到相同或高度相似的问题时直接返回缓存答案绕过检索和生成极大降低延迟和成本。异步批量处理在知识库批量导入文档时将文本分块、向量化、数据库插入做成异步流水线并行处理充分利用服务器资源。Milvus索引优化随着数据量增长需要调整索引参数。例如对于IVF_FLAT索引可以增加nlist聚类中心数来提升搜索精度但会降低搜索速度。定期使用milvus.client.GetCollectionStats查看数据分布必要时重建索引。3. 提示工程Prompt Engineering优化迭代Prompt最初的Prompt可能效果一般。我通过分析错误案例来迭代Prompt。例如发现模型有时会“自由发挥”就在系统指令里加重语气“必须”、“禁止”。发现模型忽略上下文中的数字细节就要求它“仔细核对上下文中的具体数值和日期”。提供示例Few-Shot在Prompt里给一两个“问题-上下文-答案”的示例能引导模型更好地遵循我们想要的格式和推理方式。分步思考Chain-of-Thought对于复杂问题可以要求模型“先一步步推理再给出最终答案”。虽然这会增加输出token但能提高复杂逻辑问题的正确率。5.3 常见问题与排查手册在实际开发和运维中我遇到了不少坑这里总结一下问题1上传PDF后检索到的内容乱码或缺失。排查首先检查DocumentProcessorService的日志看提取出的原始文本是否正确。很多PDF是扫描版图片iTextSharp无法提取文字。也有可能是PDF内嵌了特殊字体。解决对于扫描版PDF需要先进行OCR识别。可以集成Tesseract这样的OCR库但会大幅增加处理时间和复杂度。对于字体问题尝试在iTextSharp解析时指定编码策略。终极方案在文档上传环节就要求用户提供可复制文本的PDF或者提供Word/TXT格式。在需求层面解决问题往往更简单。问题2问答响应速度很慢尤其是第一次提问时。排查用Stopwatch在代码关键节点打点看耗时主要在哪个环节文本向量化Milvus搜索还是调用GLM API检查服务器资源CPU/内存/GPU使用情况。解决向量化慢如果是本地模型首次加载慢是正常的。实现一个“预热”接口在应用启动后或空闲时调用。确保模型推理用的是GPU如果可用。Milvus搜索慢检查集合的索引是否创建成功。数据量大了之后确保nlist、nprobe等搜索参数设置合理。nprobe值越大搜索越慢但越准需要在速度和精度间权衡。GLM API慢检查网络延迟。智谱API一般响应很快慢可能是本地网络问题。考虑在API调用上设置合理的超时如30秒并使用Polly配置重试策略。问题3答案看起来“一本正经地胡说八道”幻觉。排查检查返回的References。如果参考片段本身就不相关那是检索出了问题。如果参考片段是相关的但答案还是胡编乱造那就是Prompt或模型的问题。解决检索不相关回到5.2节优化分块和检索策略。尝试降低topK值只取最相关的一两条片段减少噪声。Prompt问题强化系统指令。我后来用的一个有效指令是“你是一个严谨的助手。请仅使用以下提供的上下文片段来回答问题。上下文片段外的一切知识对你而言都是未知的。如果上下文中没有足够信息来完全回答问题请直接说‘根据提供的信息无法完整回答此问题’。禁止推断禁止使用上下文未提及的信息。”模型问题尝试换用更强大的模型如从glm-3-turbo切换到glm-4。虽然成本更高但遵循指令和抑制幻觉的能力通常更强。问题4Milvus连接失败或插入数据报错。排查错误信息通常很明确Connection refused是Milvus服务没启动或端口不对Collection not found是集合不存在Invalid dimension是向量维度不匹配。运行docker logs milvus查看Milvus容器日志。解决确保Milvus服务健康curl http://localhost:9091/healthz应返回OK。在应用启动的EnsureCollectionExistsAsync方法中加入更完善的日志记录集合创建、索引创建的每一步结果。检查向量维度。本地嵌入模型输出的维度必须与创建集合时定义的dimension完全一致。问题5处理大量文档时内存占用飙升然后应用崩溃。原因一次性将整个大文件读入内存进行分块和向量化文件多了自然内存不足。解决实现流式处理和分批次处理。对于单个大文件边读取边分块处理完一个块就立即释放内存。对于批量上传不要并行处理太多文件。使用生产者-消费者模式用一个固定大小的任务队列来控制并发度。在DocumentProcessorService和VectorStoreService中对IDisposable对象如文件流、COM对象严格使用using语句确保及时释放。这个项目从构想到上线踩遍了几乎所有的坑。但最终看到团队成员能通过一个简单的聊天框快速找到散落在几百份文档里的信息时感觉所有的折腾都值了。这套基于.NET和智谱清言的方案在可控性、成本和安全之间取得了很好的平衡希望我的这些经验能帮你少走些弯路。