Net优雅实现AI知识库基于Ollama模型,Qdrant作为向量数据库实现RAG流程AI检索增强

实现架构设计

使用Ollama作为本地LLM推理引擎,Qdrant作为向量数据库,结合Semantic Kernel实现RAG流程。架构分为文档处理、向量存储、检索增强三个核心模块。

具体实现可参考NetCoreKevin的Kevin.RAG模块

基于.NET构建的企业级SaaSAI智能体应用架构,采用前后端分离设计,具备以下核心特性:
前端技术:

核心NuGet包配置

<ItemGroup>
  <!-- 文档处理 -->
  <PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
  <PackageReference Include="Microsoft.KernelMemory" Version="0.98.250508.3" />
  
  <!-- 向量数据库 -->
  <PackageReference Include="Qdrant.Client" Version="1.16.1" />
  
  <!-- 语义内核 -->
  <PackageReference Include="Microsoft.SemanticKernel" Version="1.57.0" />
  <PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.57.0"/>
  
  <!-- 辅助工具 -->
  <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
  <PackageReference Include="SharpToken" Version="2.0.0" />
</ItemGroup>

Ollama集成配置

创建自定义Ollama连接器创建OllamaApiService

    public class OllamaApiService : IOllamaApiService
    {
        private readonly string Url;
        private readonly string DefaultModel;
        private readonly OllamaApiClient ollamaApiClient;

        public OllamaApiService(IOptionsMonitor<OllamaApiSetting> config)
        {
            try
            {
                Url = config.CurrentValue.Url;
                DefaultModel = config.CurrentValue.DefaultModel;
                if (!string.IsNullOrEmpty(Url) && !string.IsNullOrEmpty(DefaultModel))
                {
                    ollamaApiClient = new OllamaApiClient(new Uri(Url), DefaultModel);
                }
            }
            catch (Exception)
            {

                Console.WriteLine("Kevin.RAG请检查OllamaApi配置是否正确");
            }

        }
        public async Task<Embedding<float>> GetEmbedding(string text)
        {
            if (ollamaApiClient == default)
            {
                throw new ArgumentException($"请检查OllamaApi配置是否正确");
            }
            return await Microsoft.Extensions.AI.EmbeddingGeneratorExtensions.GenerateAsync<string, Embedding<float>>(ollamaApiClient, text, options: null, cancellationToken: default).ConfigureAwait(false);
        }
    }

Qdrant向量存储实现

创建向量存储服务封装QdrantClientService :

      public class QdrantClientService : IQdrantClientService
    {
        private readonly string Url;
        private readonly string ApiKey;
        private readonly string CertificateThumbprint;
        private readonly QdrantClient QdrantClient;
        private IOllamaApiService OllamaApiService { get; set; }
        public QdrantClientService(IOptionsMonitor<QdrantClientSetting> config, IOllamaApiService ollamaApiService)
        {
            try
            {
                Url = config.CurrentValue.Url;
                ApiKey = config.CurrentValue.ApiKey;
                CertificateThumbprint = config.CurrentValue.CertificateThumbprint;
                OllamaApiService = ollamaApiService;
                if (!string.IsNullOrEmpty(Url))
                {
                    if (!string.IsNullOrEmpty(ApiKey))
                    {
                        var channel = QdrantChannel.ForAddress(Url, new ClientConfiguration
                        {
                            ApiKey = ApiKey,
                            CertificateThumbprint = CertificateThumbprint
                        });
                        var grpcClient = new QdrantGrpcClient(channel);
                        QdrantClient = new QdrantClient(grpcClient);
                    }
                    else
                    {
                        QdrantClient = new QdrantClient(Url);
                    }
                }
            }
            catch (Exception)
            { 
                Console.WriteLine("Kevin.RAG请检查Qdrant配置是否正确");
            }
           
        }
        public async Task<bool> AddData(string collectionName, List<DocumentChunkDto> data)
        {
            if (QdrantClient == default)
            {
                throw new ArgumentException($"请检查OllamaApi配置是否正确");
            }
            if (QdrantClient != null)
            {
                foreach (var item in data)
                {
                    item.ContentVector = await OllamaApiService.GetEmbedding(item.Content);
                }
                var points = data.Select(i => new PointStruct
                {
                    Id = (ulong)i.Id,
                    Vectors = i.ContentVector.Vector.ToArray(),
                    /*# 与 collection 的 vector size 对应*/
                    Payload =
                     {
                       ["Content"]   = i.Content,
                       ["SourceFile"] = i.SourceFile,
                       ["Title"] = i.Title,
                       ["Category"] = i.Category,
                       ["ChunkIndex"] = i.ChunkIndex,
                       ["CreatedAt"]=i.CreatedAt.ToString()
                     }
                }).ToList();  
                if (!(await IsValidateCollectionExists(collectionName)))
                {
                    await QdrantClient.CreateCollectionAsync(collectionName, new VectorParams { Size = 1024, Distance = Distance.Cosine });
                } 
                var result = await QdrantClient.UpsertAsync(collectionName, points);
                return true;
            }
            return false;
        }

        // 验证集合是否存在
        public async Task<bool> IsValidateCollectionExists(string collectionName)
        {
            if (QdrantClient == default)
            {
                throw new ArgumentException($"请检查OllamaApi配置是否正确");
            }
            try
            {
                var collectionNameInfo = await QdrantClient.GetCollectionInfoAsync(collectionName); 
            }
            catch (Exception)
            {

                return false;
            }
            return true;
         
        }

        public async Task<List<DocumentChunkDto>> Search(string collectionName,
            String query, ulong limit = 10, double? Score = null)
        {
            if (QdrantClient == default)
            {
                throw new ArgumentException($"请检查OllamaApi配置是否正确");
            }
            var data = await QdrantClient.SearchAsync(collectionName, (await OllamaApiService.GetEmbedding(query)).Vector, limit: limit);
            var relust = data.Select(i =>
            {
                var payload = i.Payload;
                return new DocumentChunkDto
                {
                    Id = long.Parse(i.Id.Num.ToString()),
                    Content = payload["Content"].StringValue,
                    SourceFile = payload["SourceFile"].StringValue,
                    Title = payload["Title"].StringValue,
                    Category = payload["Category"].StringValue,
                    ChunkIndex = Convert.ToInt32(payload["ChunkIndex"].IntegerValue),
                    CreatedAt = DateTimeOffset.Parse(payload["CreatedAt"].StringValue ?? string.Empty),
                    Score = i.Score
                };
            }).ToList();
            if (Score != default)
            {
                relust = relust.Where(i => i.Score >= Score).ToList();
            }
            return relust;
        }

        public void Dispose()
        {
            QdrantClient.Dispose();
        }
    }

RAG管道构建

整合组件实现完整RAGService 流程:

 public class RAGService : IRAGService
 {
     private IQdrantClientService QdrantClientService { get; set; }
     public RAGService(IQdrantClientService qdrantClientService)
     {
         QdrantClientService = qdrantClientService;
     }
     public async Task<(bool, string)> GetSystemPrompt(string collectionName, string question, int topK = 3, double? Score = null)
     {
         Console.WriteLine($"\n问题:{question}");
         Console.WriteLine("正在检索相关文档...");
         var documents = await QdrantClientService.Search(collectionName, question, (ulong)topK);
         if (documents.Count == 0)
         {
             return (false, "抱歉,我没有找到相关的文档来回答您的问题。");
         }
         Console.WriteLine($"找到 {documents.Count} 个相关文档");
         // 3. 构建上下文
         var context = string.Join("\n\n---\n\n", documents.Select((doc, index) =>
             $"文档 {index + 1}(来源:{doc.SourceFile}):\n{doc.Content}"));
         // 4. 构建提示词
         var systemPrompt = @" 
                             重要规则:
                             1. 只使用文档中的信息来回答
                             2. 如果文档中没有相关信息,请明确告知用户
                             3. 不要编造或推测文档中没有的信息
                             4. 回答要清晰、准确、有条理
                             5. 可以引用文档来源";

         var userPrompt = $@"文档内容:
                             {context} 
                             用户问题:
                             {question} 
                             请基于以上文档内容回答问题。"; 
         return (true, systemPrompt + "\n" + userPrompt);
     }
 }

文档预处理流水线

使用DocumentProcessor处理原始文档:

    /// <summary>
    /// 文档处理服务,负责清理和分块
    /// </summary>
    public class DocumentProcessor
    {
        private readonly int _chunkSize;
        private readonly int _chunkOverlap;

        public DocumentProcessor(int chunkSize = 500, int chunkOverlap = 50)
        {
            _chunkSize = chunkSize;
            _chunkOverlap = chunkOverlap;
        }

        /// <summary>
        /// 清理文档内容
        /// </summary>
        public string CleanDocument(string content)
        {
            if (string.IsNullOrWhiteSpace(content))
                return string.Empty;

            // 1. 统一换行符
            content = content.Replace("\r\n", "\n").Replace("\r", "\n");

            // 2. 移除多余的空白字符(但保留单个空格和换行)
            content = Regex.Replace(content, @"[ \t]+", " ");
            content = Regex.Replace(content, @"\n{3,}", "\n\n");

            // 3. 移除首尾空白
            content = content.Trim();

            return content;
        }

        /// <summary>
        /// 按段落分块
        /// </summary>
        public List<string> ChunkByParagraph(string content)
        {
            var chunks = new List<string>();

            // 按双换行符分割段落
            var paragraphs = content.Split(new[] { "\n\n" }, StringSplitOptions.RemoveEmptyEntries);

            var currentChunk = new List<string>();
            var currentLength = 0;

            foreach (var paragraph in paragraphs)
            {
                var paragraphLength = paragraph.Length;

                // 如果当前块加上新段落超过限制,保存当前块
                if (currentLength + paragraphLength > _chunkSize && currentChunk.Count > 0)
                {
                    chunks.Add(string.Join("\n\n", currentChunk));

                    // 保留最后一个段落作为重叠
                    if (_chunkOverlap > 0 && currentChunk.Count > 0)
                    {
                        currentChunk = new List<string> { currentChunk[^1] };
                        currentLength = currentChunk[0].Length;
                    }
                    else
                    {
                        currentChunk.Clear();
                        currentLength = 0;
                    }
                }

                currentChunk.Add(paragraph);
                currentLength += paragraphLength;
            }

            // 添加最后一个块
            if (currentChunk.Count > 0)
            {
                chunks.Add(string.Join("\n\n", currentChunk));
            }

            return chunks;
        }

        /// <summary>
        /// 按固定大小分块
        /// </summary>
        public List<string> ChunkBySize(string content)
        {
            var chunks = new List<string>();
            var start = 0;

            while (start < content.Length)
            {
                var length = Math.Min(_chunkSize, content.Length - start);

                // 尝试在句子边界处切分
                if (start + length < content.Length)
                {
                    var lastPeriod = content.LastIndexOfAny(new[] { '。', '!', '?', '.', '!', '?' },
                                                            start + length, length);
                    if (lastPeriod > start)
                    {
                        length = lastPeriod - start + 1;
                    }
                }

                chunks.Add(content.Substring(start, length).Trim());
                start += length - _chunkOverlap;
            }

            return chunks;
        }
    }

部署配置示例

appsettings.json配置示例:

  "QdrantClientSetting": {
    "Url": "localhost"
  },
  "OllamaApiSetting": {
    "Url": "http://****:11434/v1"
  }

性能优化建议

启用批量插入模式提升Qdrant写入性能,配置BatchSize参数:

await _client.UpsertAsync(
    _collectionName,
    points,
    batchSize: 100);

为Ollama调用添加重试策略:

var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .WaitAndRetryAsync(3, retryAttempt => 
        TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));

实现混合检索策略,结合关键词和向量搜索:

var hybridResults = await _client.SearchAsync(
    _collectionName,
    queryVector: embedding,
    queryText: question,
    scoreThreshold: 0.5);

使用实例

TestRag()
        {
            Console.WriteLine("=== RAG 系统演示 ===\n");
            // 4. 准备示例文档
            var sampleDocuments = new Dictionary<string, string>
            {
                ["产品手册.txt"] = @"
我们的智能音箱支持多种功能:

语音控制:
- 唤醒词是'你好小智'
- 可以控制音乐播放、查询天气、设置闹钟等
- 支持中文和英文语音识别

音乐播放:
- 支持 QQ 音乐、网易云音乐、酷狗音乐
- 可以通过语音控制播放、暂停、切歌
- 支持歌单、专辑、歌手搜索

智能家居:
- 可以控制智能灯泡、插座、空调、窗帘
- 支持场景模式(如回家模式、睡眠模式)
- 可以设置定时任务",

                ["FAQ.txt"] = @"
常见问题:

Q: 如何连接 WiFi?
A: 首次使用时,打开手机 App,选择'添加设备',按照提示连接 WiFi。

Q: 如何重置设备?
A: 长按设备顶部的重置按钮 10 秒,直到指示灯闪烁。

Q: 支持哪些音乐服务?
A: 目前支持 QQ 音乐、网易云音乐和酷狗音乐。

Q: 如何更新固件?
A: 设备会自动检查更新,也可以在 App 中手动检查更新。",

                ["技术规格.txt"] = @"
技术规格:

硬件:
- 处理器:四核 ARM Cortex-A53
- 内存:1GB RAM
- 存储:8GB Flash
- 扬声器:5W 全频扬声器
- 麦克风:4 麦克风阵列

连接:
- WiFi:2.4GHz/5GHz 双频
- 蓝牙:5.0
- 接口:USB-C 电源接口

尺寸和重量:
- 尺寸:100mm × 100mm × 50mm
- 重量:300g
- 电源:12V/1.5A"
            };

            // 5. 处理和上传文档
            Console.WriteLine("\n正在处理文档...");
            var allChunks = new List<DocumentChunkDto>();
            // 2. 初始化服务
            var documentProcessor = new DocumentProcessor(chunkSize: 500, chunkOverlap: 50);
            foreach (var (fileName, content) in sampleDocuments)
            {
                // 清理文档
                var cleanedContent = documentProcessor.CleanDocument(content);

                // 分块
                var chunks = documentProcessor.ChunkByParagraph(cleanedContent);

                Console.WriteLine($"文档 '{fileName}' 分成了 {chunks.Count} 个块");


                // 创建文档块对象
                for (int i = 0; i < chunks.Count; i++)
                {
                    allChunks.Add(new DocumentChunkDto
                    {
                        Content = chunks[i],
                        SourceFile = fileName,
                        Id = _snowflakeIdService.GetNextId(),
                        CreatedAt = DateTime.Now,
                        Title = fileName.Replace(".txt", ""),
                        Category = "产品文档",
                        ChunkIndex = i
                    });
                }
            }
            Console.WriteLine($"\n正在上传 {allChunks.Count} 个文档块到向量数据库...");
            // 上传到向量数据库
            await _qdrantClientService.AddData("RAG_Documents", allChunks);
            // 6. 测试 RAG 查询
            Console.WriteLine("\n=== 开始测试 RAG 查询 ===\n");
            var testQuestions = new[]
            {
                "这个音箱支持哪些音乐服务?",
                "如何连接 WiFi?",
                "音箱的重量是多少?",
                "可以控制哪些智能家居设备?"
            };
            foreach (var question in testQuestions)
            {
                var answer = await _qdrantClientService.Search("RAG_Documents", question);
                Console.WriteLine($"答案:{answer.ToJson()}");
                Console.WriteLine(new string('-', 80));
                Console.WriteLine( await _rAGServicevice.GetSystemPrompt("RAG_Documents",question));
            }
            Console.WriteLine("\n感谢使用 RAG 系统!");
            
            return true;
        }
posted @ 2026-01-15 17:31  NetCoreKevin  阅读(0)  评论(0)    收藏  举报