使用RAG完成一个基于本地的知识库的问答

适用场景

本地有一些公司的文档,需要一个智能体,帮助新同事快速熟悉工作

硬件要求

没啥要求

设计

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

相关推荐
魔法阵维护师9 小时前
从零开发游戏需要学习的c#模块,第十八章(2D 碰撞检测与金币收集)
学习·游戏·c#
魔法阵维护师9 小时前
从零开发游戏需要学习的c#模块,第十二章(rpg小游戏入门,中篇,金币收集与ui显示)
学习·游戏·c#
魔法阵维护师10 小时前
从零开发游戏需要学习的c#模块,第十九章(在游戏画面里显示文字 —— FontStashSharp)
学习·游戏·c#
sinat_3671045610 小时前
WPF 常用控件
c#·xaml·控件·wfp
Artech11 小时前
[对比学习LangChain和MAF-03]完全不同的Agent设计哲学
python·ai·langchain·c#·agent·maf
xiaoshuaishuai811 小时前
C# CUDA 到 OpenCL 迁移
开发语言·windows·c#
richard_yuu11 小时前
C#开发全景概述:从零读懂C#的定位、优势与完整技术体系
开发语言·c#
Xin_ye1008611 小时前
C# 零基础到精通教程 - 第十二章:异常处理与调试——让程序更健壮
开发语言·c#
楼田莉子11 小时前
C#学习之C#入门学习
开发语言·后端·学习·c#