• ChatGTP Embeddings结合Redis实现向量相似度搜索
  • 发布于 2个月前
  • 279 热度
    0 评论
要实现文本相似度搜索,我们需要解决两个问题:一是如何计算文本之间的相似度,二是如何快速地从大量文本中找出最相似的文本。在本文中,我们来探索如何利用ChatGTP Embeddings功能,将文本转换为向量,并存储到Redis中,实现向量相似度搜索。

什么是ChatGPT的Embeddings?
ChatGPT是一种基于深度学习的自然语言处理模型,它可以生成流畅、有逻辑、有情感和有创意的对话文本。ChatGPT使用了一种称为BERT的预训练模型来生成Embeddings。

Embeddings是一种将文本转换为数值向量的技术,它可以让计算机更好地理解和处理自然语言。Embeddings可以将每个单词或者每个句子映射到一个高维空间中的一个点,这个点的坐标就是该单词或句子的向量。

Embeddings可以保留文本中的语义、语法和情感信息,使得具有相似含义或相似用法的单词或句子在空间中距离较近,而具有不同含义或不同用法的单词或句子在空间中距离较远,从而生成更加丰富和准确的向量。

如何使用ChatGPT的Embeddings?

要使用ChatGTP Embeddings功能,需要调用 v1/embeddings 接口,它有两个重要参数:
model是一个必填参数,表示要使用的模型的ID,
input是一个必填的字符串或数组参数,表示输入要嵌入的文本,即待提取向量的原始数据。
//文档地址
https://platform.openai.com/docs/api-reference/embeddings/create
//官网示例
https://github.com/openai/openai-cookbook/
例如:
//堆代码 duidaima.com
 //Request:
 curl https://api.openai.com/v1/embeddings \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "The food was delicious and the waiter...",
    "model": "text-embedding-ada-002"
  }'
  
  //Response
  {
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "embedding": [
        0.0023064255,
        -0.009327292,
        .... (1536 floats total for ada-002)
        -0.0028842222,
      ],
      "index": 0
    }
  ],
  "model": "text-embedding-ada-002",
  "usage": {
    "prompt_tokens": 8,
    "total_tokens": 8
  }
}
在理解ChatGTP Embeddings功能后,进入我们今天的主题,我将创建一个新的项目ChatGPT.Demo6,代码和ChatGPT.Demo5相同,但已经将Betalgo.OpenAI库更新到了7.1.3版本。

Redis向量存储与搜索
一、创建RedisService服务
此服务的作用就是创建全局唯一的Redis连接对象,发挥StackExchange.Redis库的优势。它使用高性能多路复用技术,允许高效使用来自多个调用线程的共享连接,从而避免了频繁的连接和断开连接的操作,提高了数据库的访问性能,同时支持异步编程,提高了数据库操作的性能和效率。

在前面文章《四、ChatGPT多KEY动态轮询,自动删除无效KEY》中,我们已使用过Redis,今天我们还将继续使用Redis,为避免重复创建连接对象,我们全局创建一个单一的Redis连接对象,并封装在RedisService服务中。

这样,我们就可以在需要时快速访问Redis,并实现多路复用连接,从而提高性能和效率。也有助于简化代码结构,提高代码的可维护性和可重用性。

1、创建IRedisService接口
在Extensions文件夹中创建一个名为IRedisService.cs的接口文件,并定义了一个获取操作缓存数据库对象的方法,代码如下:
public interface IRedisService
{
    Task<IDatabase> GetDatabaseAsync();
}
2、创建RedisService服务
在Extensions文件夹中创建一个名为RedisService.cs的服务文件,并在文件中写入以下代码:
public class RedisService : IDisposable, IRedisService
{
    private volatile ConnectionMultiplexer _connection;
    private IDatabase _cache;
    private readonly string _configuration;
    private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(initialCount: 1, maxCount: 1);
    // 堆代码 duidaima.com
    public RedisService(string configuration)
    {
        _configuration = configuration;
    }

    public async Task<IDatabase> GetDatabaseAsync()
    {
        await ConnectAsync();
        return _cache;
    }

    private async Task ConnectAsync(CancellationToken token = default)
    {
        if (_cache != null) return;
        await _connectionLock.WaitAsync(token);
        try
        {
            if (_cache == null)
            {
                token.ThrowIfCancellationRequested();
                _connection = await ConnectionMultiplexer.ConnectAsync(_configuration);
                _cache = _connection.GetDatabase();
            }
        }
        finally
        {
            _connectionLock.Release();
        }
    }

    public void Dispose() => _connection?.Close();
}
RedisService类实现了IDisposable接口和IRedisService接口,以下是该服务的功能解释:
声明了一个私有变量_connection,它是一个ConnectionMultiplexer类型的对象,用于与Redis服务器建立连接;
声明了一个私有变量_cache,它是一个IDatabase类型的对象,用于缓存数据库操作;
声明了一个私有变量_configuration,用于设置缓存的地址、密码、端口等;

在Connectasync方法内部,使用了 SemaphoreSlim(信号量)来保证线程安全,然后检查_cache是否为null,如果为null,则等待连接建立完成,然后获取数据库操作对象_cache;

Dispose方法用于释放_connection对象。

3、RedisService服务注册
在Program.cs文件中,将RedisService服务进行注册,并使用单例模式:
//注册RedisService服务
builder.Services.AddSingleton<IRedisService>(new RedisService("localhost"));

二、创建RedisVectorSearchService服务
此服务的作用就是调用Redis实现向量的存储与检索。

1、创建IRedisVectorSearchService接口
在Extensions文件夹中创建一个名为IRedisVectorSearchService.cs的接口文件,并在文件中写入以下代码:
public interface IRedisVectorSearchService
{
    //创建索引
    Task CreateIndexAsync(string indexName, string indexPrefix, int vectorSize);
    //删除索引
    Task DropIndexAsync(string indexName);
    //查看索引信息
    Task<InfoResult> InfoAsync(string indexName);
    //向量搜索
    IAsyncEnumerable<(string Content, double Score)> SearchAsync(string indexName, float[] vector, int limit);

    //删除索引数据
    Task DeleteAsync(string indexPrefix, string docId);
    //添加或修改索引数据
    Task SetAsync(string indexPrefix, string docId, string content, float[] vector);
}
2、创建RedisVectorSearchService服务
在Extensions文件夹中创建一个名为RedisVectorSearchService.cs的服务文件,并在文件中写入以下代码:
public class RedisVectorSearchService : IRedisVectorSearchService
{
    private readonly IRedisService _redisService;
    private SearchCommands _searchCommands;
    public RedisVectorSearchService(IRedisService redisService)
    {
        _redisService = redisService;
    }
    //获取Redis向量搜索对象
    private async Task<SearchCommands> GetSearchCommandsAsync()
    {
        if (_searchCommands != null) return _searchCommands;

        var db = await _redisService.GetDatabaseAsync();
        _searchCommands = new SearchCommands(db, null);
        return _searchCommands;
    }


    public async Task CreateIndexAsync(string indexName, string indexPrefix, int vectorSize)
    {
        var ft = await GetSearchCommandsAsync();
        await ft.CreateAsync(indexName,
            new FTCreateParams()
                        .On(IndexDataType.HASH)
                        .Prefix(indexPrefix),
            new Schema()
                        .AddTextField("content")
                        .AddVectorField("vector",
                            VectorField.VectorAlgo.HNSW,
                            new Dictionary<string, object>()
                            {
                                ["TYPE"] = "FLOAT32",
                                ["DIM"] = vectorSize,
                                ["DISTANCE_METRIC"] = "COSINE"
                            }));
    }

    public async Task SetAsync(string indexPrefix, string docId, string content, float[] vector)
    {
        var db = await _redisService.GetDatabaseAsync();
        await db.HashSetAsync($"{indexPrefix}{docId}", new HashEntry[] {
            new HashEntry ("content", content),
            new HashEntry ("vector", vector.SelectMany(BitConverter.GetBytes).ToArray())
        });
    }

    public async Task DeleteAsync(string indexPrefix, string docId)
    {
        var db = await _redisService.GetDatabaseAsync();
        await db.KeyDeleteAsync($"{indexPrefix}{docId}");
    }

    public async Task DropIndexAsync(string indexName)
    {
        var ft = await GetSearchCommandsAsync();
        await ft.DropIndexAsync(indexName, true);
    }

    public async Task<InfoResult> InfoAsync(string indexName)
    {
        var ft = await GetSearchCommandsAsync();
        return await ft.InfoAsync(indexName);
    }

    public async IAsyncEnumerable<(string Content, double Score)> SearchAsync(string indexName, float[] vector, int limit)
    {
        var query = new Query($"*=>[KNN {limit} @vector $vector AS score]");

        query = query.AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray())
            .SetSortBy("score")
            .ReturnFields("content", "score")
            .Limit(0, limit)
            .Dialect(2);

        var ft = await GetSearchCommandsAsync();
        var result = await ft.SearchAsync(indexName, query).ConfigureAwait(false);
        foreach (var document in result.Documents)
        {
            yield return (document["content"], Convert.ToDouble(document["score"]));
        }
    }
}
RedisVectorSearchService类实现了IRedisVectorSearchService接口定义的方法,它通过构造函数注入IRedisService服务来与Redis进行交互,并在内部使用它来获取Redis连接和执行向量的相关操作。

3、RedisVectorSearchService服务注册
在Program.cs文件中,将RedisVectorSearchService服务进行注册,并使用单例模式:
//注册Redis向量搜索服务
builder.Services.AddSingleton<IRedisVectorSearchService, RedisVectorSearchService>();

ChatGTP Embeddings文本向量提取
右键Controllers文件夹新建一个SearchController.cs文件,用于调用ChatGTP Embeddings服务和RedisVectorSearchService服务,实现向量提取、存储和搜索功能。

1、引入命名空间
using OpenAI.Interfaces;
using OpenAI.ObjectModels.RequestModels;
using StackExchange.Redis;
2、注入服务
private readonly IOpenAIService _openAIService;
private readonly IRedisVectorSearchService _redisVectorSearchService;
//定义索引库名字
private readonly string _indexName = "VectorSearchIndex";
//定义索引数据前缀
private readonly string _indexPrefix = "VectorSearchItem";
public SearchController(IOpenAIService openAIService, IRedisVectorSearchService redisVectorSearchService)
{
    _openAIService = openAIService;
    _redisVectorSearchService = redisVectorSearchService;
}
3、添加一个IndexAsync方法
    [HttpGet]
    public async Task<IActionResult> IndexAsync(string message, CancellationToken cancellationToken)
    {
        var embeddingResult = await _openAIService.Embeddings.CreateEmbedding(new EmbeddingCreateRequest()
        {
            Input = message,
            Model = OpenAI.ObjectModels.Models.TextSearchAdaDocV1
        }, cancellationToken);


        if (!embeddingResult.Successful)
        {
            if (embeddingResult.Error == null)
                throw new Exception("Unknown Error");
            return Content($"{embeddingResult.Error.Code}: {embeddingResult.Error.Message}");
        }

        var embeddingResponse = embeddingResult.Data.FirstOrDefault();

        int size = 10;
        var searchResponse = _redisVectorSearchService.SearchAsync(
            _indexName, embeddingResponse.Embedding.Select(m => Convert.ToSingle(m)).ToArray(), size);

        var searchResult = new List<object>(size);
        await foreach ((string Content, double Score) in searchResponse)
        {
            searchResult.Add(new
            {
                Content,
                Score
            });
        }
        return Ok(searchResult);
    }
IndexAsync方法用于实现向量提取及相似度搜索功能,它接受message和cancellationToken两个参数,message表示要搜索的文本内容,cancellationToken用于接收取消信号,中断后续操作,整个方法的执行步骤为:
首先,通过调用ChatGPT的v1/embeddings接口,将文本信息转换为向量表示。CreateEmbedding方法用于创建嵌入对象,它接受一个EmbeddingCreateRequest对象作为参数,该对象包含输入文本Input和要使用模型Model;

然后,判断嵌入对象的结果,如果结果不成功,就会抛出一个异常或者返回一个包含错误信息的内容,如果嵌入结果成功,就会提取嵌入对象的数据(Double数组);

然后,将嵌入结果转换为浮点型数组(Redis只支持Float类型数组),并传递给SearchAsync方法进行搜索。这个方法返回一个IEnumerable<(string, double)>类型的结果,其中每个元素都是一个包含内容(Content)和得分的(Score)的元组;

最后,使用await foreach遍历搜索结果,并将每个结果转换为一个包含内容和得分的匿名对象,输出到客户端。

4、添加一个InitAsync方法
为了演示,我们需要一个初始化方法,用于创建索引和添加测试数据。代码如下:
public async Task<string> InitAsync()
{
    var inputAsList = new List<string> { "我喜欢吃苹果", "我讨厌吃香蕉", "我爱吃瓜", "我喜欢喝茶", "我不爱喝咖啡", "我讨厌喝饮料", "我爱喝酒" };
    var embeddingResult = await _openAIService.Embeddings.CreateEmbedding(new EmbeddingCreateRequest()
    {
        InputAsList = inputAsList,
        Model = OpenAI.ObjectModels.Models.TextSearchAdaDocV1
    });

    if (!embeddingResult.Successful) return $"{embeddingResult.Error.Code}: {embeddingResult.Error.Message}";

    try
    {
        await _redisVectorSearchService.InfoAsync(_indexName).ConfigureAwait(false);
        await _redisVectorSearchService.DropIndexAsync(_indexName);
    }
    catch (RedisServerException ex) when (ex.Message == "Unknown Index name")
    {
        //索引不存在
    }

    await _redisVectorSearchService.CreateIndexAsync(_indexName, _indexPrefix, 1024);

    int i = 0;
    foreach (var item in inputAsList)
    {
        await _redisVectorSearchService.SetAsync(_indexPrefix, i.ToString(), item, embeddingResult.Data[i].Embedding.Select(m => Convert.ToSingle(m)).ToArray());
        ++i;
    }
    return "初始化成功";
}
InitAsync方法的执行步骤为:
首先,通过调用CreateEmbedding方法批量提取文本集的向量结果;
然后,调用InfoAsync方法读取索引信息,如果读取成功,说明索引已创建,并进行删除,如果读取发生指定异常,说明索引不存在;
然后,调用CreateIndexAsync方法创建索引;
最后,调用SetAsync方法将嵌入对象的结果保存到Redis中。

我们看一下效果:

从上图可以看出,得分越小,相似度越高,“我爱吃苹果”与“我喜欢吃苹果”在生活中是同一个意思,这种搜索方式保留了文本中的语义信息,因此可以将它应于很多场景中,如问答系统、推荐系统等。

源码地址:https://github.com/ynanech/ChatGPT.Demo
用户评论