【Semantic Kernel】4、记忆(Memory)

发布时间 2023-07-08 18:19:58作者: .Neterr

为什么需要Memory

LLM对自然语言的理解和掌握在知识内容的解读和总结方面提供了强大的能力。
但是由于训练数据本身来自于公共领域,也就注定了无法在一些小众或者私有的领域能够足够的好的应答。
因此如何给LLM 提供足够多的信息上下文,就是如今的LLM AI应用可以充分发挥能力的地方了。

我们默认可以想到的是在提示词中提供足够的上下文信息,然而像OpenAI的模型总是有一个Max Tokens 的限制,也就意味着不可能一次性将所有的相关信息都可以放在提示词中,即便是最大的gpt-4-32k,目前也只有32,768 tokens,虽然看起来挺多的,但是相比较动则成百上千页的内部文档,或者专业资料,也不大可能将所有的内容都塞进prompt

即便说不远未来MaxTokens的上限提升到了可以轻轻松松塞下一本书了,还需要考虑的就是成本问题,以目前GPT4 的价格,0.06美元/1K tokens(32K context),光是把Prompt 塞满,不指望做出回复,一次调用成本就 1.97美元了。所以在Prompt中放置大量的信息怎么算都是不划算的。

通常情况下,我们回答一个问题,并不总是需要采用所有的信息的,例如讲某本书的某个知识点,基本不会说要一次性将全书翻一遍,然后才回答问题。除非已经将书中的内容记得滚瓜烂熟了,否则通常都是根据书中关于这个知识点相关的章节或者段落,就可以得到对应的答案了。

这种方法也常常应用于搜索领域,人们所需要的答案往往仅仅在问题所涉及的很小的范围之内。搜索引擎一直在做的事情就是找到最符合你预期的相关结果。对应的结果就是,我们总是能在搜索结果的前两页,甚至前两个条目中获得答案。

所以解决LLM有限Prompt下的上下文的基本方法也是这样,提前根据问题搜索找到相关的内容信息,再将内容信息和问题都是提供给LLM,让LLM做出对应的总结和回答。

如何找到有用的信息(Embedding)

借助于 Native Function的功能,我们可以通过一些简单的方法,例如关键词等,匹配到一些相关信息,也可以对接搜索引擎,获取一些的相关的讯息。
但是传统的方法还是传统的问题,就比如搜索引擎所采用的索引方法,也都是基于关键词,能匹配上的自然找得到,匹配不上的就很难说了。尤其是有些专用词汇无法描述清楚的时候,还有一些比较多的同义词的时候,都很难得到合适的答案。
这里就需要应用到LLM另外一个神器,Embedding
简单地说,Embedding可以将文本进行一些转化高维向量,作为向量就有了计算的可能性,就可以的进行相似性和差异性的判断。只需要计算一下两段文本之间的距离,就可以判断是否具有相似性,这种相似性是基于语义的,也就完全突破了字面上的相似性。如此以来,将所有的信息分段或者创建摘要进行转化,将问题和所有信息进行匹配,找到距离最近的或者符合距离需求的,就都是相关的信息了。这样就可以无须关心关键词是否匹配,不用煞费苦心的提取相关关键词了。
不过也有一个问题需要注意的,那就是这种向量的映射方式决定了相关内容查找的准确性是由LLM决定的,也并不是所有的时候都能找到最合适的内容。了解LLM的脾性也是使用它的重要一环。
Semantic Kernelembedding的功能封装到了Memory中,用来存储上下文信息,就好像电脑的内存一样,而LLM就像是CPU一样,我们所需要做的就是从内存中取出相关的信息交给CPU处理就好了。
了解了基本原理之后,后面就可以看看Semantic Kernel在这方面做了什么。

Memory配置

使用Memory需要注册 embedding模型,目前使用的就是 text-embedding-ada-002。同时需要为Kernel添加MemoryStore,用于存储更多的信息,这里Semantic Kernel提供了一个 VolatileMemoryStore,就是一个普通的内存存储的MemoryStore

var myKernel = Kernel.Builder
    .WithOpenAITextEmbeddingGenerationService("text-embedding-ada-002", key)
    .WithOpenAITextCompletionService("text-davinci-003", key, serviceId: "LearnEnglish")
    .WithMemoryStorage(new VolatileMemoryStore())
    .Build();

信息存储

完成了基础信息的注册后,就可以往Memroy中存储信息了。

    const string MemoryCollectionName = "aboutMe";

    await myKernel.Memory.SaveInformationAsync(MemoryCollectionName, id: "info1", text: "My name is Andrea");
    await myKernel.Memory.SaveInformationAsync(MemoryCollectionName, id: "info2", text: "I currently work as a tourist operator");
    await myKernel.Memory.SaveInformationAsync(MemoryCollectionName, id: "info3", text: "I currently live in Seattle and have been living there since 2005");
    await myKernel.Memory.SaveInformationAsync(MemoryCollectionName, id: "info4", text: "I visited France and Italy five times since 2015");
    await myKernel.Memory.SaveInformationAsync(MemoryCollectionName, id: "info5", text: "My family is from New York");

SaveInformationAsync 会将text的内容通过 embedding 模型转化为对应的文本向量,存放在的MemoryStore中。其中CollectionName如同数据库的表名,Id就是Id。

语义搜索

完成信息的存储之后,就可以用来语义搜索了。
直接使用Memory.SearchAsync方法,指定对应的Collection,同时提供相应的查询问题,查询问题也会被转化为embedding,再在MemoryStore中计算查找最相似的信息。

var questions = new[]
{
    "what is my name?",
    "where do I live?",
    "where is my family from?",
    "where have I travelled?",
    "what do I do for work?",
};
    foreach (var q in questions)
    {
        var results = myKernel.Memory.SearchAsync(MemoryCollectionName, q, 1);
        await foreach (var result in results)
        {
            Console.WriteLine(q + " " + result?.Metadata.Text);
        }
    }
 
// output
/*
what is my name? My name is Andrea
where do I live? I currently live in Seattle and have been living there since 2005
where is my family from? My family is from New York
where have I travelled? I visited France and Italy five times since 2015
what do I do for work? I currently work as a tourist operator
*/

引用存储

除了添加信息以外,还可以添加引用,像是非常有用的参考链接之类的。

const string memoryCollectionName = "SKGitHub";

    var githubFiles = new Dictionary<string, string>()
    {
        ["https://github.com/microsoft/semantic-kernel/blob/main/README.md"]
        = "README: Installation, getting started, and how to contribute",
        ["https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/2-running-prompts-from-file.ipynb"]
        = "Jupyter notebook describing how to pass prompts from a file to a semantic skill or function",
        ["https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/Getting-Started-Notebook.ipynb"]
        = "Jupyter notebook describing how to get started with the Semantic Kernel",
        ["https://github.com/microsoft/semantic-kernel/tree/main/samples/skills/ChatSkill/ChatGPT"]
        = "Sample demonstrating how to create a chat skill interfacing with ChatGPT",
        ["https://github.com/microsoft/semantic-kernel/blob/main/dotnet/src/SemanticKernel/Memory/Volatile/VolatileMemoryStore.cs"]
        = "C# class that defines a volatile embedding store",
        ["https://github.com/microsoft/semantic-kernel/tree/main/samples/dotnet/KernelHttpServer/README.md"]
        = "README: How to set up a Semantic Kernel Service API using Azure Function Runtime v4",
        ["https://github.com/microsoft/semantic-kernel/tree/main/samples/apps/chat-summary-webapp-react/README.md"]
        = "README: README associated with a sample starter react-based chat summary webapp",
    };
    foreach (var entry in githubFiles)
    {
        await myKernel.Memory.SaveReferenceAsync(
            collection: memoryCollectionName,
            description: entry.Value,
            text: entry.Value,
            externalId: entry.Key,
            externalSourceName: "GitHub"
        );
    }

同样的,使用SearchAsync搜索就行。

string ask = "I love Jupyter notebooks, how should I get started?";
    Console.WriteLine("===========================\n" +
                        "Query: " + ask + "\n");

    var memories = myKernel.Memory.SearchAsync(memoryCollectionName, ask, limit: 5, minRelevanceScore: 0.77);
    var i = 0;
    await foreach (MemoryQueryResult memory in memories)
    {
        Console.WriteLine($"Result {++i}:");
        Console.WriteLine("  URL:     : " + memory.Metadata.Id);
        Console.WriteLine("  Title    : " + memory.Metadata.Description);
        Console.WriteLine("  ExternalSource: " + memory.Metadata.ExternalSourceName);
        Console.WriteLine("  Relevance: " + memory.Relevance);
        Console.WriteLine();
    }
//output
/*
===========================
Query: I love Jupyter notebooks, how should I get started?
 
Result 1:
  URL:     : https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/Getting-Started-Notebook.ipynb
  Title    : Jupyter notebook describing how to get started with the Semantic Kernel
  ExternalSource: GitHub
  Relevance: 0.8677381632778319
 
Result 2:
  URL:     : https://github.com/microsoft/semantic-kernel/blob/main/samples/notebooks/dotnet/2-running-prompts-from-file.ipynb
  Title    : Jupyter notebook describing how to pass prompts from a file to a semantic skill or function
  ExternalSource: GitHub
  Relevance: 0.8162989178955157
 
Result 3:
  URL:     : https://github.com/microsoft/semantic-kernel/blob/main/README.md
  Title    : README: Installation, getting started, and how to contribute
  ExternalSource: GitHub
  Relevance: 0.8083238591883483
*/

这里多使用了两个参数,一个是limit,用于限制返回信息的条数,只返回最相似的前几条数据,另外一个是minRelevanceScore,限制最小的相关度分数,这个取值范围在0.0 ~ 1.0 之间,1.0意味着完全匹配。

Memory持久化

VolatileMemoryStore本身也是易丢失的,往往使用到内存的场景,其中的信息都是有可能长期存储的,起码并不会即刻过期。那么将这些信息的 embedding 能够长期存储起来,也是比较划算的事情。毕竟每一次做 embedding的转化也是需要调接口,需要花钱的。Semantic Kernel库中包含了SQLite、Qdrant和CosmosDB的实现,自行扩展的话,也只需要实现 IMemoryStore 这个接口就可以了。使用sqllite持久化:
添加nuget:

	  <PackageReference Include="Microsoft.SemanticKernel.Connectors.Memory.Sqlite" Version="0.17.230629.1-preview" />

替换VolatileMemoryStore:

var store = Directory.GetCurrentDirectory() + "/MemoryDB.sqlite";
    var myKernel = Kernel.Builder
    .WithOpenAITextEmbeddingGenerationService("text-embedding-ada-002", key)
    .WithOpenAITextCompletionService("text-davinci-003", key, serviceId: "LearnEnglish")
    .WithMemoryStorage(await SqliteMemoryStore.ConnectAsync(store))
    .Build();

语义问答

将Memory的存储、搜索功能和语义技能相结合,就可以快速的打造一个实用的语义问答的应用了。
只需要将搜索到的相关信息内容填充到 prompt中,然后将内容和问题都抛给LLM,就可以等着得到一个满意的答案了。

var store = Directory.GetCurrentDirectory() + "/MemoryDB.sqlite";
    var myKernel = Kernel.Builder
    .WithOpenAITextEmbeddingGenerationService("text-embedding-ada-002", key)
    .WithOpenAITextCompletionService("text-davinci-003", key, serviceId: "LearnEnglish")
    .WithMemoryStorage(await SqliteMemoryStore.ConnectAsync(store))
    .Build();
    const string memoryCollectionName = "aboutMe";
    var collections = await myKernel.Memory.GetCollectionsAsync();
    if (!collections.Contains(memoryCollectionName))
    {
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info1", text: "My name is Andrea");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info2", text: "I currently work as a tourist operator");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info3", text: "I currently live in Seattle and have been living there since 2005");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info4", text: "I visited France and Italy five times since 2015");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info5", text: "My family is from New York");
    }
    var prompt =
                    """
                    你是我的客服,你的回答应该来自我的信息,以下是关于我的信息:
                    {{ $fact }}
                    如果你不知道我的信息就回答:“我不知道”,以下是我们的聊天记录:
                    [done]
                    {{ $history }}
                    [done]
                    ++++++++
                    User: {{ $ask }}
                    ChatBot:
                    """;
    StringBuilder history = new StringBuilder();
    Console.WriteLine("请提问:", Color.YellowGreen);
    while (true)
    {
        string ask = Console.ReadLine();
        if (ask=="quit")
        {
            break;
        }

        var fact = myKernel.Memory.SearchAsync(memoryCollectionName, ask, 1).ToBlockingEnumerable().FirstOrDefault();
        var context = myKernel.CreateNewContext();
        context["history"] = history.ToString();
        context["fact"] = fact?.Metadata?.Text;
        context["ask"] = ask;
        var skill = myKernel.CreateSemanticFunction(prompt);
        var answer = skill.InvokeAsync(context).Result.ToString();
        
        history.AppendLine(ask);
        history.AppendLine(answer);
        Console.WriteLine(answer,Color.YellowGreen);
    }

优化搜索过程

由于这种场景太常见了,所以Semantic Kernel中直接提供了一个技能TextMemorySkill,通过Function调用的方式简化了搜索的过程。


    var store = Directory.GetCurrentDirectory() + "/MemoryDB.sqlite";
    var myKernel = Kernel.Builder
    .WithOpenAITextEmbeddingGenerationService("text-embedding-ada-002", key)
    .WithOpenAITextCompletionService("text-davinci-003", key, serviceId: "LearnEnglish")
    .WithMemoryStorage(await SqliteMemoryStore.ConnectAsync(store))
    .Build();
    myKernel.ImportSkill(new TextMemorySkill());
    const string memoryCollectionName = "aboutMe";
    var collections = await myKernel.Memory.GetCollectionsAsync();
    if (!collections.Contains(memoryCollectionName))
    {
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info1", text: "My name is Andrea");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info2", text: "I currently work as a tourist operator");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info3", text: "I currently live in Seattle and have been living there since 2005");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info4", text: "I visited France and Italy five times since 2015");
        await myKernel.Memory.SaveInformationAsync(memoryCollectionName, id: "info5", text: "My family is from New York");
    }
    var prompt =
                    """
                    你是我的客服,你的回答应该来自我的信息,以下是关于我的信息:
                    {{ recall $ask }}
                    如果你不知道我的信息就回答:“我不知道”,以下是我们的聊天记录:
                    [done]
                    {{ $history }}
                    [done]
                    ++++++++
                    User: {{ $ask }}
                    ChatBot:
                    """;
    StringBuilder history = new StringBuilder();
    Console.WriteLine("请提问:", Color.YellowGreen);
    while (true)
    {
        string ask = Console.ReadLine();
        if (ask == "quit")
        {
            break;
        }

        //var fact = myKernel.Memory.SearchAsync(memoryCollectionName, ask, 1).ToBlockingEnumerable().FirstOrDefault();
        var context = myKernel.CreateNewContext();
        context["history"] = history.ToString();
        //context["fact"] = fact?.Metadata?.Text;
        context["ask"] = ask;
        context[TextMemorySkill.CollectionParam] = memoryCollectionName;
        var skill = myKernel.CreateSemanticFunction(prompt);
        var answer = skill.InvokeAsync(context).Result.ToString();

        history.AppendLine(ask);
        history.AppendLine(answer);
        Console.WriteLine(answer, Color.YellowGreen);
    }

这里直接使用 recall 方法,将问题传给了 TextMemorySkill,搜索对应得到结果,免去了手动搜索注入得过程。