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)
13231정성태1/27/20233740오류 유형: 839. python - TypeError: '...' object is not callable
13230정성태1/26/20234106개발 환경 구성: 660. WSL 2 내부로부터 호스트 측의 네트워크로 UDP 데이터가 1개의 패킷으로만 제한되는 문제
13229정성태1/25/20235103.NET Framework: 2090. C# - UDP Datagram의 최대 크기
13228정성태1/24/20235230.NET Framework: 2089. C# - WMI 논리 디스크가 속한 물리 디스크의 정보를 얻는 방법 [2]파일 다운로드1
13227정성태1/23/20234930개발 환경 구성: 659. Windows - IP MTU 값을 바꿀 수 있을까요? [1]
13226정성태1/23/20234602.NET Framework: 2088. .NET 5부터 지원하는 GetRawSocketOption 사용 시 주의할 점
13225정성태1/21/20233849개발 환경 구성: 658. Windows에서 실행 중인 소켓 서버를 다른 PC 또는 WSL에서 접속할 수 없는 경우
13224정성태1/21/20234207Windows: 221. Windows - Private/Public/Domain이 아닌 네트워크 어댑터 단위로 방화벽을 on/off하는 방법
13223정성태1/20/20234409오류 유형: 838. RDP 연결 오류 - The two computers couldn't connect in the amount of time allotted
13222정성태1/20/20234095개발 환경 구성: 657. WSL - DockerDesktop.vhdx 파일 위치를 옮기는 방법
13221정성태1/19/20234322Linux: 57. C# - 리눅스 프로세스 메모리 정보파일 다운로드1
13220정성태1/19/20234436오류 유형: 837. NETSDK1045 The current .NET SDK does not support targeting .NET ...
13219정성태1/18/20234008Windows: 220. 네트워크의 인터넷 접속 가능 여부에 대한 판단 기준
13218정성태1/17/20233945VS.NET IDE: 178. Visual Studio 17.5 (Preview 2) - 포트 터널링을 이용한 웹 응용 프로그램의 외부 접근 허용
13217정성태1/13/20234526디버깅 기술: 185. windbg - 64비트 운영체제에서 작업 관리자로 뜬 32비트 프로세스의 덤프를 sos로 디버깅하는 방법
13216정성태1/12/20234777디버깅 기술: 184. windbg - 32비트 프로세스의 메모리 덤프인 경우 !peb 명령어로 나타나지 않는 환경 변수
13215정성태1/11/20236347Linux: 56. 리눅스 - /proc/pid/stat 정보를 이용해 프로세스의 CPU 사용량 구하는 방법 [1]
13214정성태1/10/20235882.NET Framework: 2087. .NET 6부터 SourceGenerator와 통합된 System.Text.Json [1]파일 다운로드1
13213정성태1/9/20235426오류 유형: 836. docker 이미지 빌드 시 "RUN apt install ..." 명령어가 실패하는 이유
13212정성태1/8/20235177기타: 85. 단정도/배정도 부동 소수점의 정밀도(Precision)에 따른 형변환 손실
13211정성태1/6/20235204웹: 42. (https가 아닌) http 다운로드를 막는 웹 브라우저
13210정성태1/5/20234299Windows: 219. 윈도우 x64의 경우 0x00000000`7ffe0000 아래의 주소는 왜 사용하지 않을까요?
13209정성태1/4/20234201Windows: 218. 왜 윈도우에서 가상 메모리 공간은 64KB 정렬이 된 걸까요?
13208정성태1/3/20234181.NET Framework: 2086. C# - Windows 운영체제의 2MB Large 페이지 크기 할당 방법파일 다운로드1
13207정성태12/26/20224445.NET Framework: 2085. C# - gpedit.msc의 "User Rights Assignment" 특권을 코드로 설정/해제하는 방법파일 다운로드1
13206정성태12/24/20224683.NET Framework: 2084. C# - GetTokenInformation으로 사용자 SID(Security identifiers) 구하는 방법 [3]파일 다운로드1
... [16]  17  18  19  20  21  22  23  24  25  26  27  28  29  30  ...