我的目標是希望可以做出一個QA聊天機器人,而上一篇Fine-tuning訓練出來的模型對我來說有下列幾個缺點:

  1. 微調的數據要做一些處理
  2. 微調的數據可能要多種提示和範例
  3. 微調的訓練和使用的費用稍微貴了點
    💡 Davinci的訓練和使用的費用都非常昂貴XD
  4. 微調後的數據是儲存在Open AI的伺服器上
    💡 這項既是優點也是缺點。優點是不用自己準備管理數據的伺服器,缺點就是私人數據必須擺在Open AI的伺服器上。
基於上述的4項缺點,這篇我們使用Embedding這個模型來實現QA聊天機器人看看吧!

Embedding跟Fine-tuning是完全不同的技術和實現:
  1. 技術:
    • Fine-tuning:依賴GPT3模型。在大量語料上進行預訓練,能夠理解語義、上下文等語言特徵。
    • Embedding:詞嵌入方法將單詞或短語轉換為向量表示。
  2. 實現:
    • Fine-tuning:使用GPT3訓練模型作為基礎,在特定的QA數據集上進行微調。
    • Embedding:基於詞嵌入的方法通常使用向量相似性度量來找到最相似的問題和答案。這需要構建問答數據庫,將問題和答案都轉換為向量表示,然後計算查詢和數據庫中問題的相似性。
  3. 性能:
    • Fine-tuning:由於直接使用訓練過模型,因此在回答的速度和性能較高。
    • Embedding:詞嵌入方法在某些情況下可能性能較差,尤其是在處理長文本、數據庫資料量較大或伺服器性能不足時。但對於簡單的QA任務或者知識庫查詢,詞嵌入方法可能足夠應對。

簡單的描述一下用Embedding搭配ChatGPT的整個流程和做法,下面會有更詳細的說明:
  1. 首先需要將「文本(答案)」轉成一個「向量」並且寫入資料庫
  2. 根據輸入的QA問題找到文本(答案)相符的向量,將其對應的文本(答案)添加到ChatGPT的上下文當作答題參考並搭配咒語(提示工程)
  3. ChatGPT就會依據以上流程回覆出有質量的答案
💡 要做出QA聊天機器人,單獨使用Embedding是不行的,還需要搭配GPT3.5或GPT4來使用



準備訓練文本

一開始當然是先準備目前(現在是2023/4/10日)ChatGPT問不出來答案的問題,這樣方便我們後面比對Embedding後的結果,由於ChatGPT的資料都是2021年前的,因此我現在如果問ChatGPT「Angular Standalone Component是什麼?怎麼用?」,它基本上會產生幻覺胡亂回答
💡 Angular Standalone Component是Angular14推出的新功能

問ChatGPT「Angular Standalone Component如何使用」也是給我胡亂湊答案
💡 ChatGPT把Angular Standalone Component的用法講的頭頭是道但實際上是錯誤百出XD


要示範QA聊天機器人可以回答我設定好的文本,我就用Angular Standalone Component相關的知識來做範例。我直接拿Angular官方的文件並且截取前幾個段落作為Embedding的訓練文本,截取的內容可以參考以下圖片。文本的來源和官方文件【點我前往】參考。





建立新增Embedding資料的API

首先需要準備一個DB用於存放Embedding的向量數據,以下是資料表內容
CREATE TABLE TB_Embeddings
(
   ID INT PRIMARY KEY IDENTITY(1,1),
   Word NVARCHAR(MAX),
   Vector NVARCHAR(MAX)
)
💡 接下來都會使用「ASP Dot Net 6 Web API」搭配「EFCore」來建置,DB反向工程和Program設定和一些服務細節就跳過不說明囉!

我們使用Open AI提供的Embedding API【點我前往】建立一個方法以取得文本的向量,可以使用別人寫好的Nuget套件呼叫Open AI的Embedding API,或是使用HttpClient都行
private async Task<float[]> GetTextEmbeddingVectorAsync(string inputText)
{
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
    _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    var requestData = new EmbeddingRequestModel()
    {
        input = inputText,
        model = "text-embedding-ada-002"
    };
    var content = new StringContent(JsonConvert.SerializeObject(requestData), Encoding.UTF8, "application/json");
    var response = await _httpClient.PostAsync(RequestURL + "embeddings", content);

    if (response.IsSuccessStatusCode)
    {
        var httpResponseData = await response.Content.ReadAsStringAsync();
        var jsonResponse = JObject.Parse(httpResponseData);
        var embeddings = jsonResponse["data"].First["embedding"].ToObject<float[]>();
        return embeddings;
    }
    else
    {
        throw new Exception($"TextEmbeddingVector錯誤 : {response.ReasonPhrase}");
    }
}
💡 apiKey記得要帶入Open AI的API Key,另外也建議使用密碼環境變數的方式

建立一個方法用來寫入文本和文本向量資料
private async Task AddQAEmbedding(string inputText)
{
    var inputVector = await _openAIHttpService.GetTextEmbeddingVectorAsync(inputText);

    var newTB_Embedding = new TB_Embeddings
    {
        Word = inputText,
        Vector = string.Join(',', inputVector.Select(x => x.ToString(CultureInfo.InvariantCulture)))
    };

    _dbContext.TB_Embeddings.Add(newTB_Embedding);
    await _dbContext.SaveChangesAsync();
}

最後建立Controller使用方法即可
/// <summary>
/// 添加Embedding文本到資料庫
/// </summary> 
[HttpPost("AddQAEmbedding", Name = nameof(AddQAEmbedding))]
public async Task<IActionResult> AddQAEmbedding([FromBody] InputTextModel inputText)
{
    //可以自行添加其餘的判斷
    await AddQAEmbedding(inputText.text);
    return Ok(new { message = "QAEmbedding資料寫入完成" });
}


接著將上方準備好的Angular訓練文本餵給我們寫好的「AddQAEmbedding」 API,執行成功後也可以到資料庫查看你的文本和向量資料
💡 從資料表可以清楚看到文本和其對應的向量資料



建立QA問答API

首先來瞭解一下這個API整個運作方式:

使用者會先輸入他的QA問題,API收到後會把QA問題利用Embedding轉成「QA的向量資料」,接著拿這個QA的向量資料和系統資料庫內所有的向量做比對,並且找出「最相似的文本」作為ChatGPT答題時的參考答案,最後將參考答案配合咒語(提示工程)來替使用者解答他的問題。

以上就是整個的運作過程,其中我覺得比較困難的就是如何找出「最相似的文本」,還好Open AI的官方文件有提到可以使用「餘弦相似度」來計算,加上ChatGPT的幫忙很快就可以產生出餘弦相似度的計算式,程式碼可以參考以下:
private double CalculateCosineSimilarity(float[] vectorA, float[] vectorB)
{
    double dotProduct = 0.0;
    double normA = 0.0;
    double normB = 0.0;

    for (int i = 0; i < vectorA.Length; i++)
    {
        dotProduct += vectorA[i] * vectorB[i];
        normA += vectorA[i] * vectorA[i];
        normB += vectorB[i] * vectorB[i];
    }

    normA = Math.Sqrt(normA);
    normB = Math.Sqrt(normB);

    return dotProduct / (normA * normB);
}

接下來還要建立一個咒語(提示工程)產生方法,以下是我參考官方並且加以修改後的咒語:
Use Traditional Chinese, Answer the question as truthfully as possible using the provided context. If there is code, please wrap it in markdown syntax, and if the answer is not contained within the text below, say \"抱歉,我不知道\"\n\n
💡 這段咒語(提示工程)主要讓ChatGPT可以依據我設定好的方式來回答問題。
💡 如果只希望讓你的QA聊天機器人回答你設定好的文本,其餘的問題一律回覆不知道,就在咒語加上:「如果問題和文本上下文不相關就回答"抱歉,我不知道"」,可以發揮你的創意限制它或是讓它回答更多不同的答案哦!
private string UseEmbeddingGenerateText(string[] vectors, string inputText)
{
    StringBuilder sb = new StringBuilder();
    sb.Append("Use Traditional Chinese, Answer the question as truthfully as possible using the provided context. If there is code, please wrap it in markdown syntax, and if the answer is not contained within the text below, say \"抱歉,我不知道\"\n\n");
    for (int i = 0; i < vectors.Length; i++)
    {
        sb.Append($"{vectors[i]}\n");
    }
    sb.Append($"\n\nQ: {inputText} \nA:");

    return sb.ToString();
}

接下來需要一個GetSimilarWordsByEmbedding的方法,用於取得QA的Embedding向量並且做餘弦相似度後取得最相似的前兩筆資料後產生出咒語(提示工程),程式碼直接參考以下:
private async Task<string> GetSimilarWordsByEmbedding(string inputText)
{   
    // 取得QA的Embedding向量
    var inputVector = await _openAIHttpService.GetTextEmbeddingVectorAsync(inputText);

    // 使用並行處理
    var allEmbeddings = await _dbContext.TB_Embeddings.ToListAsync();
    var cosineSimilarities = new ConcurrentDictionary<string, double>();

    // 將所有嵌入向量保存在內存中,並將其轉換為float[]
    var embeddingVectors = allEmbeddings.Select(e => new
    {
        Word = e.Word,
        Vector = e.Vector.Split(',').Select(float.Parse).ToArray()
    }).ToList();

    // 使用資料分批查詢
    int batchSize = 100;
    int batchCount = (int)Math.Ceiling(embeddingVectors.Count / (double)batchSize);

    Parallel.ForEach(Partitioner.Create(0, batchCount), range =>
    {
        for (int i = range.Item1; i < range.Item2; i++)
        {
            int from = i * batchSize;
            int to = Math.Min(from + batchSize, embeddingVectors.Count);

            for (int j = from; j < to; j++)
            {
                var e = embeddingVectors[j];
                var similarity = CalculateCosineSimilarity(inputVector, e.Vector);
                cosineSimilarities.TryAdd(e.Word, similarity);
            }
        }
    });
	
    // 取得最相似的前兩筆資料
    var sortedSimilarities = cosineSimilarities.OrderByDescending(x => x.Value).Take(2).ToDictionary(x => x.Key, x => x.Value);
    var top2Vectors = sortedSimilarities.Select(x => x.Key).ToArray();
    
    // 建立提示工程prompt文字
    return UseEmbeddingGenerateText(top2Vectors, inputText);
}
💡 這裡使用Parallel並行來加快搜尋的速度

再來準備一個呼叫ChatGPT API的方法【點我前往】,一樣可以使用別人寫好的Nuget套件呼叫Open AI的ChatGPT API,或是使用HttpClient。程式碼請參考:
private async Task<string> GetGPTTextResponseAsync(string inputText)
{
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
    _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    List<ChatGPTMessageModel> messages = new List<ChatGPTMessageModel>();
    ChatGPTMessageModel message = new ChatGPTMessageModel()
    {
        role = "user",
        content = inputText
    };            
    messages.Add(message);
    var requestData = new ChatGPTRequestModel()
    {
        model = chatGPTModel,
        messages = messages,
        temperature = 0.2,
        max_tokens = gptMaxTokenSize,
        top_p = 1,
        stream = false
    };
    var content = new StringContent(JsonConvert.SerializeObject(requestData), Encoding.UTF8, "application/json");

    var response = await _httpClient.PostAsync(RequestURL + "chat/completions", content);

    if (response.IsSuccessStatusCode)
    {
        var jsonResponse = await response.Content.ReadAsStringAsync();
        dynamic data = JsonConvert.DeserializeObject(jsonResponse);
        return data.choices[0].message.content.ToString();
    }
    else
    {
        throw new Exception($"GPTTextResponseAsybc錯誤 : {response.ReasonPhrase}");
    }
}
💡 調整temperature的數值可以來讓回答更具多樣性(調高)或是讓回答比較固定制式化(調低),怎麼使用看大家的需求而定。

最後建立Controller並且呼叫上方寫好的方法就完成了!
/// <summary>
/// QA聊天機器人
/// </summary> 
[HttpPost("QA", Name = nameof(GetQA))]
public async Task<IActionResult> GetQA([FromBody] QAModel qa)
{
    var embeddingText = await GetSimilarWordsByEmbedding(qa.question);
    var responseData = await GetGPTTextResponseAsync(embeddingText);
    return Ok(responseData);
}



使用QA聊天機器人

完成API建置後,我們重新問一開始問的問題「Angular Standalone Component是什麼?怎麼用?」來看看這次ChatGPT會如何回答
回答正確!且幾乎跟Angular官方文件一致!


再問一次「Angular Standalone Component如何使用」
這一次明確的指出standalone的關鍵用法了!


最後附上搭配自己寫好的QA聊天機器人前端,是不是跟ChatGPT有87分像呢XD
💡 由於temperature設定0.2,因此幾乎可以確保每次的回答都相同!



總結

最後我們分析一下產生出來的「embeddingText」的內容


看到embeddingText後終於恍然大悟,整個過程其實就只是到資料庫找到「最接近問題的答案的文本」再搭配「咒語(提示工程)」,組合之後丟給ChatGPT請它回答我們的QA問題而已,原來ChatGPT之所以突然會回答Standalone的問題只是因為我們提供解答,看到這後瞬間有想罵髒話的感覺對吧XD。



以上透過Embedding技術,可以將文字轉換成密集的向量表示,並且儲存在自己信任的伺服器,搭配ChatGPT有助於提升QA聊天機器人的表現,同時避免Fine Tuning可能帶來的高費用和複雜度。
Embedding的實際的費用可以【點我參考】。


綜合考慮Fine Tuning和Embedding在實現QA聊天機器人的表現,我會更傾向於後者搭配ChatGPT實現更高質量的QA聊天機器人哦!


最後也提供大家我自己完整實作的範例給大家參考(Bug很多哦XD)
💡 Github上的程式是已經經過多次調整的版本,所以可能會跟本篇範例有些微不同哦!