C# - OpenAI를 사용해 PDF 데이터를 대상으로 OpenAI 챗봇 작성
이번 글도 ^^ .NET Conf 2023에 있었던 동영상을 그대로 베끼겠습니다.
Build an Azure OpenAI powered .NET 8 Chat Bot on your data from scratch | .NET Conf 2023
; https://youtu.be/fYJuokUnucE
지난 글에서,
C# - Azure OpenAI API를 이용해 사용자가 제공하는 정보를 대상으로 검색하는 방법
; https://www.sysnet.pe.kr/2/0/13452
C# - Qdrant Vector DB를 이용한 Embedding 벡터 값 보관/조회 (Azure OpenAI)
; https://www.sysnet.pe.kr/2/0/13454
대략 다음과 같은 처리 순서를 설명했습니다.
- 사용자가 제공하는 문서를 embedding 시켜 벡터로 보관 (대개의 경우 DB에 보관)
- 사용자 입력한 쿼리를 embedding 시키고, 1번 과정에서 저장한 것과 비교해 적절한 문서를 선택(혹은 DB로부터 조회)
- 조회한 문서와 함께 사용자가 입력한 쿼리를 ChatGPT에 전달
저 의미에서 보면, PDF 역시 사용자가 제공하는 문서에 불과하므로, 1번 과정을 거쳐 embedding 시켜 벡터로 보관해 두는 작업을 거쳐야 합니다. 자, 그럼 당연히 PDF 문서를 읽는 라이브러리가 필요하겠죠. ^^
Install-Package itext7
그다음 적절한 PDF 예제 문서가 있어야 하는데, 테스트를 위해 너무 큰 PDF 문서를 지정하면 OpenAI/Azure 사용료만 부과되므로 적절하게 10개 페이지 이하 분량의 PDF를 하나 선택해 주고,
Shared Files PRO - A WordPress plugin by Tammersoft
- Sample PDF
; https://www.sharedfilespro.com/shared-files/38/sample.pdf
다음의 코드를 이용해 페이지 하나 당 Embedding 시킨 벡터 값들을 구해
Qdrant에 저장해 둡니다.
string qdrantHost = "localhost";
QdrantClient qdrantClient = new QdrantClient(qdrantHost, 6334, false);
string collectionName = "pdf_docs";
await EmbedPdfFilesAsync(qdrantClient, collectionName, openAIClient, embeddingDeployment,
"sample.pdf");
private static string[] ReadPdfFile(string filePath)
{
using PdfDocument pdfDoc = new PdfDocument(new PdfReader(filePath));
List<string> pages = new List<string>();
for (int page = 1; page <= pdfDoc.GetNumberOfPages(); page++)
{
PdfPage pdfPage = pdfDoc.GetPage(page);
ITextExtractionStrategy strategy = new SimpleTextExtractionStrategy();
pages.Add(PdfTextExtractor.GetTextFromPage(pdfPage, strategy));
Console.WriteLine($"Page {page}: # of chars = {pages[page - 1].Length}");
}
return pages.ToArray();
}
private static async Task EmbedPdfFilesAsync(
QdrantClient qdrantClient, string collectionName, OpenAIClient openAIClient, string embeddingDeployment,
string pdfFile)
{
var collections = await qdrantClient.ListCollectionsAsync();
if (collections.Contains(collectionName))
{
// await qdrantClient.DeleteCollectionAsync(collectionName);
return;
}
string[] pdfPages = ReadPdfFile(pdfFile);
var emddedPages =
pdfPages.Select(page => new EmbeddedPage(page, [])).ToArray();
var tokenizer = await Tokenizer.CreateAsync(TokenizerModel.ada2);
foreach (var (page, index) in emddedPages.WithIndex())
{
var fullText = page.Text;
if (string.IsNullOrWhiteSpace(fullText))
{
continue;
}
int tokenCount = tokenizer.GetTokenCount(fullText);
Console.WriteLine($"Page {index + 1} - # of tokens = {tokenCount}");
var chunks = tokenizer.ChunkByTokenCountWithOverlap(fullText, 3000, 50).Chunk(16).ToArray();
foreach (var chunk in chunks)
{
var embeddingResponse = await openAIClient.GetEmbeddingsAsync(
new EmbeddingsOptions(embeddingDeployment, chunk));
page.Chunks.AddRange(
embeddingResponse.Value.Data.Select(d =>
new TextWithEmbedding(chunk[d.Index], d.Embedding.ToArray())));
}
}
await qdrantClient.CreateCollectionAsync(collectionName,
new VectorParams { Size = 1536, Distance = Distance.Cosine });
var vectors = emddedPages
.Where(d => d.Chunks.Count > 0)
.SelectMany(d =>
d.Chunks.Select(c => new
{
Embedding = c.Embedding,
Text = d.Text,
}))
.ToList();
var points = vectors.Select(vector =>
{
var point = new PointStruct
{
Id = new PointId { Uuid = Guid.NewGuid().ToString() },
Vectors = vector.Embedding,
Payload =
{
["text"] = vector.Text
}
};
return point;
}).ToList();
await qdrantClient.UpsertAsync(collectionName, points);
}
지난번 코드와 비교하면 PDF 데이터로 바뀌었다는 점을 제외하고는 거의 그대로 재사용이 되었습니다.
테스트로 사용한 PDF는 5개의 페이지를 포함하고 있는데요, 그래서 위의 코드를 실행하면 다음과 같은 결과를 볼 수 있습니다.
Page 1: # of chars = 3062
Page 2: # of chars = 2476
Page 3: # of chars = 2696
Page 4: # of chars = 1647
Page 5: # of chars = 899
Page 1: # of tokens = 617
Page 2: # of tokens = 514
Page 3: # of tokens = 576
Page 4: # of tokens = 342
Page 5: # of tokens = 453
달리 말하면, 웬만한 PDF 페이지는 Token 수가 1,000개를 넘지 않으므로 페이지 단위 정도라면 OpenAI 측의 대화 문맥으로 사용하는데 부담이 없는 수준입니다. (물론, 좀 더 검색 수준을 높이고 싶다면 페이지 단위보다는, 허용이 되는 수준의 장(Chapter) 또는 절(Section) 단위로 문서 구분을 하는 것도 좋을 것입니다.)
어쨌든, 위와 같은 상황에서 검색을 해보면,
string question = "What is the features of Pdf995 Suite solution?";
string[] results = await SearchWithQdrantAsync(qdrantClient, collectionName,
openAIClient, embeddingDeployment,
question, 5); // 5개 페이지인데, limit을 5로 설정했으니 모든 페이지를 반환
results.All((text) =>
{
Console.WriteLine(text);
Console.WriteLine("-----------------------------------");
return true;
});
public static async Task<string[]> SearchWithQdrantAsync(
QdrantClient qdrantClient, string collectionName,
OpenAIClient openAIClient, string embeddingDeployment,
string query, int resultLimit = 1)
{
var embeddingResponse = await openAIClient.GetEmbeddingsAsync(
new EmbeddingsOptions(embeddingDeployment, new[] { query }));
var embeddingVector = embeddingResponse.Value.Data[0].Embedding.ToArray();
var results = await qdrantClient.SearchAsync(collectionName, embeddingVector,
limit: (ulong)resultLimit);
foreach (var result in results)
{
Console.WriteLine($"Score: {result.Score}");
}
return results.Select(r => r.Payload["text"].StringValue).ToArray();
}
검색 결과에 따른 개별 문서(위의 예에서는 페이지)별 Cosine 유사도는 다음과 같이 나옵니다.
Score: 0.9092298
Score: 0.7339479
Score: 0.70261437
Score: 0.70222175
Score: 0.69459313
실제 서비스라면 전체 문서를 모두 검색 결과로 받지는 않을 것이므로, 대략 0.8 이상의 유사도가 나오는 문서를 검색하도록 Qdrant 검색 조건에 주는 것이 좋겠습니다.
var results = await qdrantClient.SearchAsync(collectionName, embeddingVector,
scoreThreshold: 0.8f, limit: (ulong)resultLimit);
자, 그럼 이렇게 DB를 구축했으니 이후부터는 사용자로부터 질문을 받고, ChatGPT처럼 답하는 코드를 작성할 수 있습니다.
string question = "...[사용자가 입력한 질문]...";
string[] results = await SearchWithQdrantAsync(qdrantClient, collectionName,
openAIClient, embeddingDeployment,
question, 5);
var chatCompletionsOptions = new ChatCompletionsOptions()
{
DeploymentName = deploymentModel,
MaxTokens = 1000,
Temperature = 0, // Knowledge Base 조회인 경우, 정확함을 목표로 하게 되므로 0으로 지정
Messages =
{
new ChatMessage(ChatRole.System, "You are a helpful AI assistant"),
new ChatMessage(ChatRole.User, "The following information is from the PDF text: " + string.Join('\n', results)),
new ChatMessage(ChatRole.User, question),
}
};
Response<ChatCompletions> response = openAIClient.GetChatCompletions(chatCompletionsOptions);
Console.WriteLine(response.Value.Choices.First().Message.Content);
보는 바와 같이, DB로부터 조회한 문서 데이터와 함께 사용자의 질문을 chat model에게 전달해 응답을 받아 처리하고 있습니다. 실제로 실행해 보면 이런 결과를 얻게 됩니다.
[질문]: What is the features of Pdf995 Suite solution?
[답변]
The Pdf995 Suite of products offers the following features:
- Creation of professional-quality PDF documents
- Easy-to-use interface for creating PDF files
- Network file saving
- Fast user switching on XP
- Citrix/Terminal Server support
...[생략]...
- Specify PDF document properties
- Control PDF opening mode
- Can be configured to add functionality to Acrobat Distiller
- Free: Creates PDFs without annoying watermarks
- Free: Fully functional, not a trial and does not expire
- Over 5 million satisfied customers
- Over 1000 Enterprise Customers worldwide
All of these features are available at no cost to the user.
[질문]: What Pdf995 product is for?
[답변]
The Pdf995 suite of products, which includes Pdf995, PdfEdit995, and Signature995, is a complete solution for document publishing needs. It provides ease of use, flexibility in format, and industry-standard security, all at no cost to the user. Pdf995 makes it easy and affordable to create professional-quality documents in the popular PDF file format. PdfEdit995 offers additional functionality, such as combining documents into a single PDF, automatic link insertion, and PDF conversion to HTML or DOC. Signature995 offers state-of-the-art security and encryption to protect documents and add digital signatures.
이 정도면 대충 감이 오시죠? ^^
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
여러분만의 Knowledge Base를 embedding 시킨 DB가 있다면, 물론 예전에도
Elasticsearch와 같은 검색 엔진을 사용할 수 있었지만 OpenAI의 Chat Completion 기능과 함께 연동하면 좀 더 자연스러운 수준의 검색 결과를 받아올 수 있습니다.
뭐랄까... 달리 생각하면 해당 DB 하나가 자신의 또 다른 두뇌 저장소라고 봐도 좋을 개념이 된 것입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]