RAG进阶:使用C#实现语义搜索的核心功能

云信安装大师
90
AI 质量分
10 5 月, 2025
4 分钟阅读
0 阅读

RAG进阶:使用C#实现语义搜索的核心功能

引言

语义搜索是RAG(检索增强生成)系统的核心组件,它能够理解查询的语义而不仅仅是关键词匹配。本文将带你使用C#和.NET生态中的工具,从零开始构建一个语义搜索系统。我们将使用开源的嵌入模型和向量数据库,无需依赖昂贵的商业API。

准备工作

环境要求

  • .NET 6或更高版本
  • Visual Studio 2022或VS Code
  • 推荐配置:8GB以上内存(运行嵌入模型需要)

NuGet包准备

代码片段
dotnet add package Microsoft.ML.OnnxRuntime
dotnet add package Microsoft.ML.OnnxRuntime.Managed
dotnet add package Microsoft.ML.Tokenizers
dotnet add package Weaviate.Client

第一步:加载嵌入模型

我们将使用MiniLM-L6-v2模型,这是一个轻量级但效果不错的嵌入模型。

代码片段
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;

public class EmbeddingService : IDisposable
{
    private readonly InferenceSession _session;
    private readonly Tokenizer _tokenizer;

    public EmbeddingService(string modelPath)
    {
        // 初始化ONNX运行时和分词器
        _session = new InferenceSession(modelPath);
        _tokenizer = Tokenizer.CreateFromFiles(
            Path.Combine(modelPath, "vocab.txt"),
            Path.Combine(modelPath, "merges.txt")
        );
    }

    public float[] GenerateEmbedding(string text)
    {
        // 分词处理
        var tokens = _tokenizer.Encode(text).Tokens;

        // 准备输入张量
        var inputIds = new DenseTensor<long>(new[] { 1, tokens.Length });
        for (int i = 0; i < tokens.Length; i++)
        {
            inputIds[0, i] = tokens[i];
        }

        // 创建输入容器
        var inputs = new List<NamedOnnxValue>
        {
            NamedOnnxValue.CreateFromTensor("input_ids", inputIds)
        };

        // 运行推理
        using var results = _session.Run(inputs);

        // 获取嵌入向量并归一化(重要步骤)
        var embedding = results.First().AsTensor<float>().ToArray();
        return NormalizeVector(embedding);
    }

    private float[] NormalizeVector(float[] vector)
    {
        var norm = Math.Sqrt(vector.Sum(x => x * x));
        return vector.Select(x => (float)(x / norm)).ToArray();
    }

    public void Dispose()
    {
        _session?.Dispose();
    }
}

关键点解释:
1. InferenceSession用于加载ONNX格式的嵌入模型
2. Tokenizer将文本转换为模型可理解的token序列
3. NormalizeVector确保所有向量都在单位球面上,这对余弦相似度计算很重要

注意事项:
– 模型文件可以从HuggingFace下载并转换为ONNX格式
– 首次运行时可能需要较长时间加载模型

第二步:实现向量数据库存储

我们将使用Weaviate作为向量数据库,它是一个开源的向量搜索引擎。

代码片段
using Weaviate.Client;

public class VectorDatabaseService
{
    private readonly WeaviateClient _client;

    public VectorDatabaseService(string endpoint)
    {
        _client = new WeaviateClient(new Uri(endpoint));
    }

    public async Task CreateSchemaAsync()
    {
        var schema = new SchemaCreateModel
        {
            Classes = new List<Class>
            {
                new Class
                {
                    ClassName = "Document",
                    Properties = new List<Property>
                    {
                        new Property { Name = "title", DataType = new[] { "string" } },
                        new Property { Name = "content", DataType = new[] { "text" } }
                    },
                    Vectorizer = "none" // 我们自行计算向量
                }
            }
        };

        await _client.Schema.Create(schema);
    }

    public async Task IndexDocumentAsync(string id, string title, string content, float[] embedding)
    {
        var document = new 
        {
            title,
            content,
            additionalProperties = new Dictionary<string, object>()
        };

        await _client.Data.Create(
            className: "Document",
            dataObject: document,
            id: id,
            vector: embedding.ToList()
        );
    }

    public async Task<List<(string Id, string Title, float Score)>> SearchAsync(float[] embedding, int limit = 5)
    {
         var nearVectorParams = new NearVectorParameters 
         { 
             Vector = embedding.ToList(),
             Certainty = null,
             Distance = null 
         };

         var result = await _client.GraphQL.Get()
             .WithClassName("Document")
             .WithFields("title content _additional { id certainty }")
             .WithNearVector(nearVectorParams)
             .WithLimit(limit)
             .Run();

         return result.GetResult().Objects.Select(obj => (
             obj.Id.ToString(),
             obj.Properties["title"].ToString(),
             (float)obj.Additional["certainty"]
         )).ToList();
     }
}

关键点解释:
1. CreateSchemaAsync定义我们的文档数据结构
2. IndexDocumentAsync存储文档及其向量表示
3. SearchAsync执行近似最近邻搜索(ANN)

实践经验:
– Weaviate默认使用余弦相似度计算分数(certainty)
– 生产环境应考虑分片和复制配置

第三步:整合语义搜索流程

将前面两个组件整合成完整的语义搜索流水线:

代码片段
public class SemanticSearchService : IDisposable
{
    private readonly EmbeddingService _embedder;
    private readonly VectorDatabaseService _vectorDb;

    public SemanticSearchService(string modelPath, string weaviateEndpoint)
    {
         _embedder = new EmbeddingService(modelPath);
         _vectorDb = new VectorDatabaseService(weaviateEndpoint);
     }

     public async Task InitializeAsync()
     {
         try 
         {
             await _vectorDb.CreateSchemaAsync();
         }
         catch (Exception ex) 
         {
             Console.WriteLine($"Schema may already exist: {ex.Message}");
         }
     }

     public async Task IndexDocumentAsync(string id, string title, string content)
     {
         var embedding = _embedder.GenerateEmbedding(content);
         await _vectorDb.IndexDocumentAsync(id, title, content, embedding);
     }

     public async Task<List<(string Id, string Title, float Score)>> SearchAsync(string query)
     { 
          var queryEmbedding = _embedder.GenerateEmbedding(query);
          return await _vectorDb.SearchAsync(queryEmbedding);
      }

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

第四步:实际应用示例

让我们看一个完整的控制台应用示例:

代码片段
class Program 
{
     static async Task Main(string[] args) 
     { 
          const string modelPath = "./model/minilm-l6-v2";
          const string weaviateEndpoint = "http://localhost:8080";

          using var searchService = new SemanticSearchService(modelPath, weaviateEndpoint);

          // 初始化数据库架构(只需运行一次)
          await searchService.InitializeAsync();

          // 索引一些示例文档(实际应用中可能是从数据库读取)
          await searchService.IndexDocumentAsync(
               "1", 
               ".NET Core介绍", 
               ".NET Core是微软开发的跨平台开源框架"
          );

          await searchService.IndexDocumentAsync(
               "2",
               "C#编程语言",
               "C#是一种面向对象的强类型编程语言"
          );

          Console.WriteLine("请输入搜索查询:");
          while (true) 
          { 
              var queryTextsToCompare =
                  Console.ReadLine()?.Trim() ?? "";
              if (string.IsNullOrEmpty(queryTextsToCompare)) break;

              var results =
                  await searchService.SearchAsync(queryTextsToCompare);  

              Console.WriteLine($"找到{results.Count}个结果:");
              foreach (var (id,
                          title,
                          score) in results.OrderByDescending(r => r.Score))
              {  
                   Console.WriteLine($"[相似度:{score:P0}] {title} (ID:{id})");
              }  
           }  
       }  
   }  

性能优化建议

  1. 批量处理:对大量文档进行批量嵌入生成和索引:

    代码片段
    public async Task IndexDocumentsBatch(IEnumerable<(string Id, string Title, string Content)> documents)
    {
        const int batchSize =
            32; //根据内存调整批次大小
    
        foreach (var batch in documents.Batch(batchSize))
        {  
            var tasks =
                batch.Select(d =>
                    IndexDocumentAsync(d.Id,
                                      d.Title,
                                      d.Content));
            await Task.WhenAll(tasks);  
        }  
    }  
    
  2. 缓存层:对常见查询结果添加缓存:

    代码片段
    private readonly MemoryCache _cache =
        new MemoryCache(new MemoryCacheOptions());
    
    public async Task<List<(string Id,
                           string Title,
                           float Score)>> SearchWithCacheAsync(string query,
                                                              TimeSpan? expiry =
                                                                  null)
    {   
        if (_cache.TryGetValue(query,
                             out List<(string Id,
                                      string Title,
                                      float Score)>? cachedResults))
        {   
            return cachedResults!;  
        }   
    
        var results =
            await SearchAsync(query);  
    
        _cache.Set(query,
                  results,
                  expiry ?? TimeSpan.FromMinutes(5));  
    
        return results;  
    }   
    
  3. 预过滤:结合传统关键词过滤缩小搜索范围:

    代码片段
    public async Task<List<(string Id,
                           string Title,
                           float Score)>> HybridSearchAsync(string queryTextsToCompare,
                                                          IEnumerable<string>? keywords =
                                                              null)
    {   
        IQuery whereFilter =
            null!;  
    
        if (keywords != null &&
            keywords.Any())
        {   
            whereFilter =
                Query.Where(
                    Query.ContainsAny("content",
                                    keywords.ToArray()));  
        }   
    
        var queryEmbedding =
            _embedder.GenerateEmbedding(queryTextsToCompare);  
    
        return await _vectorDb.SearchWithFilterAsync(queryEmbedding,
                                                   whereFilter);  
    }   
    

常见问题解决

  1. 模型加载失败

    • ✅检查ONNX模型文件路径是否正确
    • ✅确认模型文件未被其他进程占用
  2. Weaviate连接问题

    • ✅验证Weaviate服务是否运行:docker ps
    • ✅检查防火墙设置是否阻止了8080端口
  3. 搜索结果不相关

    • ✅确保输入文本经过适当清理(去除特殊字符)
    • ✅尝试不同的嵌入模型或微调现有模型
  4. 性能瓶颈

    • ✅对于大型数据集,考虑分片策略
    • ✅在GPU环境下运行嵌入模型推理

总结

通过本文我们实现了:
1️⃣ C#中加载和使用ONNX格式的嵌入模型
2️⃣ Weaviate向量数据库的集成与操作
3️⃣ 完整的语义搜索流水线构建

进阶方向建议
– 🚀尝试更大的嵌入模型如all-MiniLM-L12-v2提高质量
– 🔍实现多模态搜索(结合文本和图像)
– ⚡探索量化技术减小模型体积

希望这篇指南能帮助你构建强大的语义搜索功能!如果有任何问题,欢迎在评论区讨论。

原创 高质量