在上一篇實作中我們已經做出一個有模有樣的QA聊天機器人【點我前往】,後來Embedding向量的資料量一多的時候QA機器人開始罷工或是秀逗(出事了阿伯)!於是花了更多的時間終於找到問題點。

首先說明一下來龍去脈,我是使用餘弦相似來找出Embedding向量最近的前兩筆資料,但是當Embedding向量資料量很多時,這種方式找出的前兩筆向量和其對應的文本內容不一定是跟我的QA提問有關聯(有可能有相關或是可用於回答QA提問的文本在餘弦排名下,只排在第四或第五甚至是更後面,因此前兩名的文本可能跟QA問題完全沒有關係),這導致QA機器人回答時的質量變得很低或是我明明有準備和訓練好QA文本但是怎麼問,QA機器人就是答不出來等等的問題發生。

那麼知道問題點後,我該如何解決這個問題?就在蹲廁所時靈光一閃,既然餘弦排名的結果不一定是我要的文本,那就由ChatGPT自己去推測和篩選出這些排名中,哪些文本可以用作回答QA提問,然後再組合起來當作參考文本不就好了嗎!Let's Go直接實作!


ChatGPT推測和篩選文本實作

首先開始前需要先建立兩個提示工程的方法:

QATextUserPrompt

這個方法內的提示跟上一篇的差不多,不過有稍做微調,大家可以參考著用或是自己調整也OK
public string QATextUserPrompt()
{
    return "Answer the question as truthfully as possible using the provided context. " +
        "If there is code, please wrap it in markdown syntax. If the answer is not contained within the text below or the text is empty " +
        "say \"抱歉,您的提問未納入QA問題集,因此我無法回答您的問題\".\n\nContext:\n";
}


QATextUseSourcePrompt

這個方法的提示主要告訴ChatGPT每個文本都有對應的TextGuid、TextName和TextContent,然後根據QA提問選出最適合用來回答問題的文本,依據所選的TextGuid和TextName輸出成我指定的JSON資料結構如以下:[{textGuid: "xxx", textName: "zzz"}]」
public string QATextUseSourcePrompt()
{
    return "Each context has a TextGuid and TextName and TextContent followed by the actual message. " +
        "Before providing an answer as accurately as possible, you must select multiple sources that can be used to answer the question and keep the TextGuid and TextName of the selected sources then output the result in the following format, " +
        "e.g. [{textGuid: \"xxx\", textName: \"zzz\"}], only output the format that I have specified. " +
        "If no sources are selected, please output an empty array.\n\nSources:\n";
}


接下來需要新增四個新的方法,可以使用服務注入的方式實作:

GetGuidsFromQAResultAsync

這個方法是Call ChatGPT API並且將回傳結果解析並輸出成List<Guid>物件

private async Task<List<Guid>> GetGuidsFromQAResultAsync(string prompt)
{
    var qaResult = await _openAIHttpService.GetCustomChatGPTResponse(prompt, 0.0, 200);
    var regex = new Regex(@"\[(.*?)\]");
    var match = regex.Match(qaResult);
    var guids = new List<Guid>();

    if (match.Success)
    {
        var idsList = JsonConvert.DeserializeObject<List<UsingTextModel>>(match.Value);
        guids.AddRange(idsList.Select(id => Guid.Parse(id.textGuid)));
    }

    return guids;
}


GetTextGuidsAsync

這個方法主要在組合所有向量對應的文本提示,以及對應的提示所需要的資料:TextGuid、TextName和TextContent,並且依據Token數量組成一個大的Prompt然後請ChatGPT選出最適合當參考文本的Guid,最後加入到List<Guid>物件並且回傳

private async Task<List<Guid>> GetTextGuidsAsync(Guid[] vectors, string inputText)
{
    var guids = new List<Guid>();
    var prompt = _openAIPrompt.QATextUseSourcePrompt();
    var tokenizer = _openAIGPTToken.GetGPT3Tokenizer(prompt);
    var qaPrompt = $"\n\nQuestion:{inputText}";

    foreach (var vector in vectors)
    {
        var tbTextData = await _dbContext.TB_Texts.FirstOrDefaultAsync(text => text.EmbeddingId == vector);
        if (tbTextData != null)
        {
            var textContent = FilterNewLines(tbTextData.TextContent);
            var tempPrompt = $"TextGuid:\"{tbTextData.Id}\"\nTextName:\"{tbTextData.Name}\"\nTextContent:\"{textContent}\"\n\n";
            var currentTextTokenizer = _openAIGPTToken.GetGPT3Tokenizer(tempPrompt);
            int tokensNeeded = tokenizer.tokens + currentTextTokenizer.tokens + 200;

            if (tokensNeeded > totalTokenSize)
            {
                guids.AddRange(await GetGuidsFromQAResultAsync(prompt + qaPrompt));
                prompt = _openAIPrompt.QATextUseSourcePrompt();
                tokenizer = _openAIGPTToken.GetGPT3Tokenizer(prompt + qaPrompt);
            }

            tokenizer.tokens += currentTextTokenizer.tokens;
            prompt += tempPrompt;
        }
    }

    guids.AddRange(await GetGuidsFromQAResultAsync(prompt + qaPrompt));
    return guids;
}
💡 這邊會有點抽象,如果看不懂沒關係,後面會有輸出後的結果可以看


GenerateQAPromptAsync

這個方法是根據文本和問題,生成一個最終用於問答的Prompt。在給定的Guid列表中循環逐一處理每個Guid對應的文本。它會檢查是否有足夠的Token數量可用於添加當前文本,並在超過總Token數時停止添加。如果Guid列表為空,則在Prompt中添加一個"empty"字串。最後把Prompt和使用到的文本列表(usingTextList)一併輸出成一個QAGenerateModel物件。
private async Task<QAGenerateModel> GenerateQAPromptAsync(List<Guid> guids, string inputText)
{
    var prompt = _openAIPrompt.QATextUserPrompt();
    var qaPrompt = $"\n\nQuestion:{inputText}";
    var tokenizer = _openAIGPTToken.GetGPT3Tokenizer(prompt + qaPrompt);
    var usingTextList = new List<UsingTextModel>();

    foreach (var guid in guids)
    {
        var tbTextData = await _dbContext.TB_Texts.FirstOrDefaultAsync(text => text.Id == guid);
        if (tbTextData != null)
        {
            var textContent = FilterNewLines(tbTextData.TextContent);
            var currentTextTokenizer = _openAIGPTToken.GetGPT3Tokenizer(textContent);
            int tokensNeeded = tokenizer.tokens + currentTextTokenizer.tokens + qaGPTCompletionMaxTokenSize;

            if (tokensNeeded > totalTokenSize)
            {
                break;
            }

            tokenizer.tokens += currentTextTokenizer.tokens;
            prompt += $"{textContent}\n\n";
            usingTextList.Add(new UsingTextModel
            {
                textGuid = tbTextData.Id.ToString(),
                textName = tbTextData.Name
            });
        }
    }

    if (guids.Count == 0)
    {
        prompt += "empty";
    }

    prompt += qaPrompt;

    return new QAGenerateModel
    {
        prompt = prompt,
        usingText = usingTextList
    };
}


UseTop5TextGeneratePrompt

這個方法就只是組合GetTextGuidsAsync和GenerateQAPromptAsync而已
private async Task<QAGenerateModel> UseTop5TextGeneratePrompt(Guid[] vectors, string inputText)
{
    var guids = await GetTextGuidsAsync(vectors, inputText);
    return await GenerateQAPromptAsync(guids, inputText);
}


GetTop5TextsByEmbedding

最後來修改上一篇的「GetSimilarWordsByEmbedding」將其名稱調整為「GetTop5TextsByEmbedding」,這裡我改成取得Top 5的向量資料,和調整使用的Function名稱
public async Task<QAGenerateModel> GetTop5TextsByEmbedding(string inputText)
{
    .
    .
    .
    .
    以上程式都沒變

    var sortedSimilarities = cosineSimilarities.OrderByDescending(x => x.Value).Take(5).ToDictionary(x => x.Key, x => x.Value);
    var top5Vectors = sortedSimilarities.Select(x => x.Key).ToArray();

    return await UseTop5TextGeneratePrompt(top5Vectors, inputText);
}
💡 不一定只選Top 5筆,可以自由決定。當然越多的文本當作篩選依據當然是更好,但相對的,API的費用自然也會增加哦!



執行API

完成後來執行API,在前面「GetTextGuidsAsync」方法我覺得很難解釋,所以直接下一個中斷點來看看提供給AI篩選文本的Prompt組合後的樣子

💡 為什麼有兩個是因為每一次請求時有Token的限制(我設定4096),因此才會變成兩次提問(第一次包含三個文本,第二次包含兩個)。

送出請求後可以在「GetGuidsFromQAResultAsync」方法看到該提示ChatGPT執行後,確實的幫我篩選出了TextGuid和TextName,並且輸出成我指定的格式
💡 如果想要增加ChatGPT回傳時的精準度,推薦使用GPT4哦!

「UseTop5TextGeneratePrompt」方法中輸出的List<Guid>物件
💡 可以看到ChatGPT篩選出2個文本作為參考答案


再看到「GenerateQAPromptAsync」方法中QAGenerateModel的Prompt就是拿篩選出來的兩個文本組合而成的最終Prompt的樣貌


最後就都一樣的流程,只需要執行ChatGPT API請它依據你經過AI篩選後的所有QA文本,作為回答的基準,就完成了QA聊天機器人的實作囉!
💡 因為讓ChatGPT事先幫我篩選可用的參考文本,我們在QAGenerateModel內把參考文本的資訊一併帶回去前端,讓前端QA機器人附帶了參考文本,是不是看起來更厲害的感覺XD



總結

由於餘弦做出計算後篩選出的文本不一定是我們要的,因此我們利用提示工程,請ChatGPT進一步篩選出可以當作參考的文本,最後再組合成最終的QA提示(等於先用餘弦找出相近的文本,再交由ChatGPT找出可以用來回答問題的文本)。利用ChatGPT進一步對篩選出來的文本進行篩選,以保證其內容和QA提問具有一定的關聯,讓QA機器人在回答上可以更準確和穩定,回答的品質也可以因此提升哦!


最後展示一下完整實作的範例圖片,程式碼都在Github上可以給大家參考(Bug還是很多哦XD)