适用场景
本地有一些公司的文档,需要一个智能体,帮助新同事快速熟悉工作
硬件要求
没啥要求
设计
1. 开发
- 开发环境:Visual Studio 2022 + .NET 8 + 64位 windows (其实用python更方便,俺是因为单位用C#的多,所以这次用了C#)
- 运行方式:Windows 桌面应用 (WinForms) + 原生 Minimal API 服务,不包含聊天的UI部分,聊天的UI部分由另外的网站提供。
- 核心目标 :构建一个免依赖的本地服务,融合 BAAI/bge-m3 语义模型与检索引擎 和 Lucene.Net的关键字查询,引入 PanGuAnalyzer,用 FSDirectory(磁盘持久化)开启 RAMBuffer` 提升速度。"Lucene.Net 做关键词匹配 + 向量库"的混合检索。
- 结合 ADO.NET 业务数据查询,通过 DeepSeek 实现支持多轮对话 且回答严谨可溯源的智能问答系统。
- 文档库: 向量嵌入的持久化存储,支持向量索引的数据库。HNSW.Net支持二进制序列化。存储包含元数据 包含文档名称,页码,段落等 , 使用HNSW.Net 进行检索。可以保存索引后的内容,避免每次都启动都重建索引和向量。
2. 桌面应用界面 (UI) 与交互
- 程序启动时加载bge-m3模型。程序需提供一个简洁的桌面端界面
- 启动/停止服务按钮:控制 Minimal API 的生命周期,状态切换时更新文本和颜色。
- 重建文档库按钮 :
- 逻辑 :点击后直接在 UI 层触发后台索引构建任务(Task.Run),支持多个文件并发处理。重建期间 停掉 http服务。
- 反馈:配合进度条显示"扫描"、"向量化"、"完成"等状态,期间禁用按钮。
- 退出按钮:优雅关闭监听器,释放资源。
- 日志/状态显示区:实时显示系统日志。
- 服务核心:使用 Minimal API。
- 运行模式:独立运行 。
3. HTTP 服务架构
- API 路由设计 :
- POST /api/chat:核心接口。接收 JSON(包含 session_id, message 等),返回 AI 回答及引用来源。
- GET /api/health:健康检查。
4. 核心功能模块
- 本地知识库 (RAG)
- 文件解析 :全自动解析 txt, md, doc, docx, pdf, excel。
- 语义模型 :集成 BAAI/bge-m3,将文本转化为向量。
- 检索引擎:存储向量索引,实现持久化和秒级启动。
- 元数据保留 :索引时必须保留文档的文件名 、页码 和原文片段。
- DeepSeek 融合引擎 (支持多轮对话与严谨引用)
- 上下文缓存:内存中维护 MemoryCache存储 session_id 的历史对话。缓存中的内容 定期清理,防止占用太多内存。
- 严谨性控制 (防幻觉) :
- 强制约束:在 Prompt 中明确指令------"必须严格基于提供的【参考信息】回答,若参考信息中不包含答案,请直接回答'知识库中未找到相关信息',严禁编造事实。"
- 引用标注机制 :
- 要求 DeepSeek 在回答中通过脚注(如 ``)标记来源1。
- API 返回的 JSON 中需包含 citations 列表,明确列出:文档名、页码/位置、原文片段。
- 智能体技能 (Skills)
- 数据库技能 :使用 ADO.NET 调用 SQL Server 存储过程 。
- 办公技能:生成 PPT 大纲 、查找资料等、查找文档等
5. 开发约束与注意事项
- 线程安全:UI 操作使用 Invoke,缓存字典使用 MemoryCache。
- 权限管理:启动时检查管理员权限。
- 模型部署: bge-m3 的本地加载 (model.onnx、model.onnx_data等文件)。
代码
包

初始化
LuceneIndex._indexPath = DirLuceneIndex;
BgeM3.Init(PathModels);
HnswVectorIndex.SetPath(PathHnsw) ;
启动
cs
Log("正在启动服务...");
await HybridSearch.SearchAsync("预热");
var builder = WebApplication.CreateBuilder();
builder.Services.AddCors();
builder.Services.AddMemoryCache();
_app = builder.Build();
_app.UseCors(x => x.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
_app.MapPost("/api/chat", async (ChatRequest req) =>
{
var hist = new List<ChatMessage>();// _cache.GetOrCreate(req.SessionId, _ => new List<ChatMessage>());
var refs = await HybridSearch.SearchAsync(req.Message);
var resp = await DeepSeekChatService.ChatAsync(hist, refs, req.Message);
//hist.Add(new ChatMessage { Role = "user", Content = req.Message });
//hist.Add(new ChatMessage { Role = "assistant", Content = resp.Answer });
if (resp.Citations != null)
{
foreach (CitationItem i in resp.Citations)
{
i.ContentFragment = "";
}
}
return Results.Ok(resp);
});
_app.Urls.Add(ListenUrl);
await _app.StartAsync();
Log("✅ 服务启动成功:" + ListenUrl);
HybridSearch
cs
public static class HybridSearch
{
public static async Task<List<DocumentChunk>> SearchAsync(string query, int topN = 7)
{
if (string.IsNullOrWhiteSpace(query))
return new List<DocumentChunk>();
float[] queryVector = BgeM3.Encode(query);
// 并行检索
var luceneTask = Task.Run(() => LuceneIndex.Search(query, topN));
var vectorTask = Task.Run(() => HnswVectorIndex.Search(queryVector, topN));
await Task.WhenAll(luceneTask, vectorTask);
return RrfFusion.Merge(
vectorTask.Result,
luceneTask.Result,
topN);
}
// 按比例取:向量60% + Lucene40%,高分优先
}
Chat
cs
public static async Task<ChatResponse> ChatAsync(
List<ChatMessage> history,
List<DocumentChunk> references,
string question)
{
try
{
var uniqueRefs = references.DistinctBy(r => r.ChunkId).Take(10).ToList();
var refDict = BuildNumberedRefs(uniqueRefs);
string ragContext = BuildRagPrompt(refDict);
// 最优提示词(确保调用工具)
string systemPrompt = $@"
你是智能办公助手。用户会询问你如何处理给出的操作。回答用户问题时,优先利用提供的RAG知识库信息,必要时调用工具函数获取答案。请严格遵守以下
规则:
1. 优先使用RAG知识库回答,必须标注[数字]来源。
2. 知识库无答案时,必须直接调用工具函数,不解释、不反问。
3. 禁止闲聊、禁止编造、禁止补充话术。
4. 无知识库、无工具可用时,仅回复:知识库中未找到相关信息。
【RAG知识库参考】
{ragContext}";
var messages = new List<object>
{
new { role = "system", content = systemPrompt }
};
if (history != null && history.Any())
{
messages.AddRange(history.TakeLast(5)
.Select(h => new { role = h.Role.ToLower(), content = h.Content }));
}
messages.Add(new { role = "user", content = question });
var tools = GetSkillTools();
using var client = CreateClient();
var request = new
{
model = "deepseek-reasoner",
messages = messages,
tools = tools,
tool_choice = "auto",
temperature = 0.1,
stream = false,
max_tokens = 8192,
max_prompt_tokens = 12000,
top_p = 0.7
};
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var reqMsg = new HttpRequestMessage(HttpMethod.Post, API_URL);
reqMsg.Headers.Add("Authorization", $"Bearer {API_KEY}");
reqMsg.Content = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await client.SendAsync(reqMsg);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
return new ChatResponse { Answer = $"API请求异常:{err}", Citations = new List<CitationItem>() };
}
var respJson = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;
var choice = root.GetProperty("choices")[0];
var message = choice.GetProperty("message");
string answer = message.GetProperty("content").GetString() ?? "";
string toolResult = "";
if (choice.TryGetProperty("finish_reason", out var reason) && reason.GetString() == "tool_calls")
{
if (message.TryGetProperty("tool_calls", out var toolCalls) && toolCalls.GetArrayLength() > 0)
{
var tool = toolCalls[0];
string funcName = tool.GetProperty("function").GetProperty("name").GetString() ?? "";
string argsJson = tool.GetProperty("function").GetProperty("arguments").GetString() ?? "{}";
var args = JsonSerializer.Deserialize<Dictionary<string, object>>(argsJson);
toolResult = ExecuteSkill(funcName, args);
}
}
if (!string.IsNullOrEmpty(toolResult))
{
answer = toolResult;
}
var citations = ParseCitations(answer, refDict);
return new ChatResponse
{
Answer = answer,
Citations = citations
};
}
catch (Exception ex)
{
return new ChatResponse
{
Answer = $"服务异常:{ex.Message}",
Citations = new List<CitationItem>()
};
}
}
其他
cs
public static class FileTextExtractor
{
public static string GetText(string filePath)
{
try
{
var ext = System.IO.Path.GetExtension(filePath).ToLower();
return ext switch
{
".txt" => File.ReadAllText(filePath, Encoding.UTF8),
".pdf" => ExtractPdf(filePath),
".docx" => ExtractDocx(filePath),
".doc" => ExtractDoc(filePath),
".xlsx" or ".xls" or ".csv" => ExtractExcel(filePath),
_ => ""
};
}
catch { return ""; }
}
private static string ExtractPdf(string path)
{
var sb = new StringBuilder();
using (UglyToad.PdfPig.PdfDocument document = UglyToad.PdfPig.PdfDocument.Open(path))
{
foreach (Page page in document.GetPages())
{
var words = page.GetWords();
double lastBottom = -1;
foreach (var word in words)
{
// 判断是否换行(Y坐标变化 = 新行)
if (lastBottom != -1 && Math.Abs(word.BoundingBox.Bottom - lastBottom) > 5)
{
sb.AppendLine(); // 换行
}
sb.Append(word.Text);
sb.Append(" ");
lastBottom = word.BoundingBox.Bottom;
}
sb.AppendLine();
}
}
string text = sb.ToString();
return text;
//using var reader = new iTextSharp.text.pdf.PdfReader(path);
//var sb = new StringBuilder();
//for (int i = 1; i <= reader.NumberOfPages; i++)
//{
// var strategy = new SmartWordExtractionStrategy();
// string pageText = PdfTextExtractor.GetTextFromPage(reader, i, strategy);
// sb.AppendLine(pageText);
//}
//return System.Text.RegularExpressions.Regex.Replace(sb.ToString(), @" {2,}", " ").Trim();
}
public static string ExtractDocx(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
throw new FileNotFoundException("文件未找到", path);
}
using (WordprocessingDocument doc = WordprocessingDocument.Open(path, false))
{
if (doc.MainDocumentPart == null)
return string.Empty;
var body = doc.MainDocumentPart.Document.Body;
if (body == null)
return string.Empty;
StringBuilder sb = new StringBuilder();
foreach (var element in body.ChildElements)
{
// 1. 处理段落 (Paragraph)
if (element is DocumentFormat.OpenXml.Wordprocessing.Paragraph para)
{
string paraText = GetParagraphText(para);
if (!string.IsNullOrWhiteSpace(paraText))
{
sb.AppendLine(paraText);
}
}
else if (element is DocumentFormat.OpenXml.Wordprocessing.Table table)
{
string tableText = ExtractTableText(table);
if (!string.IsNullOrWhiteSpace(tableText))
{
sb.AppendLine(tableText);
sb.AppendLine(); // 表格后加空行分隔
}
}
}
return sb.ToString().Trim();
}
}
/// <summary>
/// 提取段落中的纯文本
/// </summary>
private static string GetParagraphText(DocumentFormat.OpenXml.Wordprocessing.Paragraph para)
{
if (para == null) return string.Empty;
var texts = para.Descendants<DocumentFormat.OpenXml.Wordprocessing.Text>();
if (!texts.Any())
return string.Empty;
StringBuilder sb = new StringBuilder();
foreach (var text in texts)
{
if (text.Text != null)
{
sb.Append(text.Text);
}
}
return CleanText(sb.ToString());
}
/// <summary>
/// 提取表格内容
/// </summary>
private static string ExtractTableText(DocumentFormat.OpenXml.Wordprocessing.Table table)
{
if (table == null) return string.Empty;
StringBuilder sb = new StringBuilder();
foreach (var row in table.Elements<DocumentFormat.OpenXml.Wordprocessing.TableRow>())
{
List<string> cellValues = new List<string>();
foreach (var cell in row.Elements<DocumentFormat.OpenXml.Wordprocessing.TableCell>())
{
StringBuilder cellSb = new StringBuilder();
foreach (var para in cell.Elements<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
string pText = GetParagraphText(para);
if (!string.IsNullOrEmpty(pText))
{
if (cellSb.Length > 0) cellSb.Append(" ");
cellSb.Append(pText);
}
}
cellValues.Add(cellSb.ToString().Trim());
}
if (cellValues.Any())
{
sb.AppendLine(string.Join(" | ", cellValues));
}
}
return sb.ToString().Trim();
}
/// <summary>
/// 清理文本中的特殊字符和多余空白
/// </summary>
private static string CleanText(string text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
text = text.Replace("\t", " ").Replace("\r", " ").Replace("\n", " ");
while (text.Contains(" "))
{
text = text.Replace(" ", " ");
}
return text.Trim();
}
private static string ExtractDoc(string path)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
var doc = new XWPFDocument(fs);
var sb = new StringBuilder();
foreach (var p in doc.Paragraphs) sb.AppendLine(p.Text);
return sb.ToString();
}
private static string ExtractExcel(string path)
{
IWorkbook wb = System.IO.Path.GetExtension(path).ToLower() switch
{
".xlsx" => new XSSFWorkbook(File.OpenRead(path)),
".xls" => new HSSFWorkbook(File.OpenRead(path)),
_ => null
};
var sb = new StringBuilder();
for (int i = 0; i < wb.NumberOfSheets; i++)
{
var s = wb.GetSheetAt(i);
if (s == null) continue;
for (int r = 0; r <= s.LastRowNum; r++)
{
var row = s.GetRow(r);
if (row == null) continue;
for (int c = 0; c < row.LastCellNum; c++)
{
var cell = row.GetCell(c);
if (cell != null) sb.Append(cell + " ");
}
sb.AppendLine();
}
}
return sb.ToString();
}
}
public class SmartWordExtractionStrategy : ITextExtractionStrategy
{
private readonly StringBuilder _sb = new StringBuilder();
private Vector _lastEnd;
private bool _isNewLine = true;
private const float WORD_SPACE_FACTOR = 0.5f;
public void RenderText(TextRenderInfo renderInfo)
{
string text = renderInfo.GetText();
if (string.IsNullOrWhiteSpace(text)) return;
LineSegment baseline = renderInfo.GetBaseline();
Vector start = baseline.GetStartPoint();
Vector end = baseline.GetEndPoint();
if (!_isNewLine && _lastEnd != null)
{
float distance = start[Vector.I1] - _lastEnd[Vector.I1];
float spaceWidth = renderInfo.GetSingleSpaceWidth() * WORD_SPACE_FACTOR;
// 只有距离够大,才判定为【新单词】→ 加空格
if (distance > spaceWidth && distance < 50) // 过滤超大间距(防乱空格)
{
_sb.Append(' ');
}
}
_sb.Append(text);
_lastEnd = end;
_isNewLine = false;
}
public void RenderImage(ImageRenderInfo renderInfo) { }
public void BeginTextBlock() { }
public void EndTextBlock() => _isNewLine = true;
public string GetResultantText() => _sb.ToString().Trim();
}
模型 BgeM3
