Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 6개 있습니다.)
.NET Framework: 2116. C# - OpenAI API 사용 - 지원 모델 목록
; https://www.sysnet.pe.kr/2/0/13344

닷넷: 2165. C# - Azure OpenAI API를 이용해 ChatGPT처럼 동작하는 콘솔 응용 프로그램 제작
; https://www.sysnet.pe.kr/2/0/13451

닷넷: 2166. C# - Azure OpenAI API를 이용해 사용자가 제공하는 정보를 대상으로 검색하는 방법
; https://www.sysnet.pe.kr/2/0/13452

닷넷: 2167. C# - Qdrant Vector DB를 이용한 Embedding 벡터 값 보관/조회 (Azure OpenAI)
; https://www.sysnet.pe.kr/2/0/13454

닷넷: 2168. C# - Azure.AI.OpenAI 패키지로 OpenAI 사용
; https://www.sysnet.pe.kr/2/0/13455

닷넷: 2169. C# - OpenAI를 사용해 PDF 데이터를 대상으로 OpenAI 챗봇 작성
; https://www.sysnet.pe.kr/2/0/13456




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

대략 다음과 같은 처리 순서를 설명했습니다.

  1. 사용자가 제공하는 문서를 embedding 시켜 벡터로 보관 (대개의 경우 DB에 보관)
  2. 사용자 입력한 쿼리를 embedding 시키고, 1번 과정에서 저장한 것과 비교해 적절한 문서를 선택(혹은 DB로부터 조회)
  3. 조회한 문서와 함께 사용자가 입력한 쿼리를 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) =&gt;
{
    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 하나가 자신의 또 다른 두뇌 저장소라고 봐도 좋을 개념이 된 것입니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 11/25/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2023-12-17 06시03분
[Langchain] PDF를 학습한 나만의 챗봇 streamlit에 배포 하기
; https://unfinishedgod.netlify.app/2023/12/16/langchain-pdf-streamlit/

SciSharp/LLamaSharp
; https://github.com/SciSharp/LLamaSharp
정성태

... 16  17  18  19  20  21  [22]  23  24  25  26  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
13082정성태6/19/20226317.NET Framework: 2023. C# - Process의 I/O 사용량을 보여주는 GetProcessIoCounters Win32 API파일 다운로드1
13081정성태6/17/20226391.NET Framework: 2022. C# - .NET 7 Preview 5 신규 기능 - System.IO.Stream ReadExactly / ReadAtLeast파일 다운로드1
13080정성태6/17/20227008개발 환경 구성: 643. Visual Studio 2022 17.2 버전에서 C# 11 또는 .NET 7.0 preview 적용
13079정성태6/17/20224657오류 유형: 814. 파이썬 - Error: The file/path provided (...) does not appear to exist
13078정성태6/16/20226708.NET Framework: 2021. WPF - UI Thread와 Render Thread파일 다운로드1
13077정성태6/15/20227033스크립트: 40. 파이썬 - PostgreSQL 환경 구성
13075정성태6/15/20226008Linux: 50. Linux - apt와 apt-get의 차이 [2]
13074정성태6/13/20226309.NET Framework: 2020. C# - NTFS 파일에 사용자 정의 속성값 추가하는 방법파일 다운로드1
13073정성태6/12/20226530Windows: 207. Windows Server 2022에 도입된 WSL 2
13072정성태6/10/20226807Linux: 49. Linux - ls 명령어로 출력되는 디렉터리 색상 변경 방법
13071정성태6/9/20227393스크립트: 39. Python에서 cx_Oracle 환경 구성
13070정성태6/8/20227194오류 유형: 813. Windows 11에서 입력 포커스가 바뀌는 문제 [1]
13069정성태5/26/20229425.NET Framework: 2019. C# - .NET에서 제공하는 3가지 Timer 비교 [2]
13068정성태5/24/20227929.NET Framework: 2018. C# - 일정 크기를 할당하는 동안 GC를 (가능한) 멈추는 방법 [1]파일 다운로드1
13067정성태5/23/20227245Windows: 206. Outlook - 1년 이상 지난 메일이 기본적으로 안 보이는 문제
13066정성태5/23/20226578Windows: 205. Windows 11 - Windows + S(또는 Q)로 뜨는 작업 표시줄의 검색 바가 동작하지 않는 경우
13065정성태5/20/20227257.NET Framework: 2017. C# - Windows I/O Ring 소개 [2]파일 다운로드1
13064정성태5/18/20226787.NET Framework: 2016. C# - JIT 컴파일러의 인라인 메서드 처리 유무
13063정성태5/18/20227204.NET Framework: 2015. C# - 인라인 메서드(inline methods)
13062정성태5/17/20228015.NET Framework: 2014. C# - async/await 그리고 스레드 (4) 비동기 I/O 재현파일 다운로드1
13061정성태5/16/20226821.NET Framework: 2013. C# - FILE_FLAG_OVERLAPPED가 적용된 파일의 읽기/쓰기 시 Position 관리파일 다운로드1
13060정성태5/15/20229304.NET Framework: 2012. C# - async/await 그리고 스레드 (3) Task.Delay 재현파일 다운로드1
13059정성태5/14/20227755.NET Framework: 2011. C# - CLR ThreadPool의 I/O 스레드에 작업을 맡기는 방법 [1]파일 다운로드1
13058정성태5/13/20227659.NET Framework: 2010. C# - ThreadPool.SetMaxThreads 사용법
13057정성태5/12/20229334오류 유형: 812. 파이썬 - ImportError: cannot import name ...
13056정성태5/12/20226461.NET Framework: 2009. C# - async/await 그리고 스레드 (2) MyTask의 호출 흐름 [2]파일 다운로드1
... 16  17  18  19  20  21  [22]  23  24  25  26  27  28  29  30  ...