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
정성태

1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...
NoWriterDateCnt.TitleFile(s)
13257정성태2/13/20234374.NET Framework: 2094. C# - Job에 Process 포함하는 방법 [1]파일 다운로드1
13256정성태2/10/20235207개발 환경 구성: 665. WSL 2의 네트워크 통신 방법 - 두 번째 이야기
13255정성태2/10/20234536오류 유형: 845. gihub - windows2022 이미지에서 .NET Framework 4.5.2 미만의 프로젝트에 대한 빌드 오류
13254정성태2/10/20234434Windows: 223. (WMI 쿼리를 위한) PowerShell 문자열 escape 처리
13253정성태2/9/20235209Windows: 222. C# - 다른 윈도우 프로그램이 실행되었음을 인식하는 방법파일 다운로드1
13252정성태2/9/20234036오류 유형: 844. ssh로 명령어 수행 시 멈춤 현상
13251정성태2/8/20234468스크립트: 44. 파이썬의 3가지 스레드 ID
13250정성태2/8/20236292오류 유형: 843. System.InvalidOperationException - Unable to configure HTTPS endpoint
13249정성태2/7/20235150오류 유형: 842. 리눅스 - You must wait longer to change your password
13248정성태2/7/20234181오류 유형: 841. 리눅스 - [사용자 계정] is not in the sudoers file. This incident will be reported.
13247정성태2/7/20235081VS.NET IDE: 180. Visual Studio - 닷넷 소스 코드 디버깅 중 "Decompile source code"가 동작하는 않는 문제
13246정성태2/6/20234215개발 환경 구성: 664. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 - 두 번째 이야기
13245정성태2/6/20234786.NET Framework: 2093. C# - PEM 파일을 이용한 RSA 개인키/공개키 설정 방법파일 다운로드1
13244정성태2/5/20234151VS.NET IDE: 179. Visual Studio - External Tools에 Shell 내장 명령어 등록
13243정성태2/5/20235006디버깅 기술: 190. windbg - Win32 API 호출 시점에 BP 거는 방법 [1]
13242정성태2/4/20234442디버깅 기술: 189. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.UnauthorizedAccessException
13241정성태2/3/20233929디버깅 기술: 188. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.IO.FileNotFoundException
13240정성태2/1/20234082디버깅 기술: 187. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.Web.HttpException
13239정성태2/1/20233746디버깅 기술: 186. C# - CacheDependency의 숨겨진 예외 - System.Web.HttpException
13238정성태1/31/20235829.NET Framework: 2092. IIS 웹 사이트를 TLS 1.2 또는 TLS 1.3 프로토콜로만 운영하는 방법
13237정성태1/30/20235490.NET Framework: 2091. C# - 웹 사이트가 어떤 버전의 TLS/SSL을 지원하는지 확인하는 방법
13236정성태1/29/20235108개발 환경 구성: 663. openssl을 이용해 인트라넷 IIS 사이트의 SSL 인증서 생성
13235정성태1/29/20234654개발 환경 구성: 662. openssl - 윈도우 환경의 명령행에서 SAN 적용하는 방법
13234정성태1/28/20235734개발 환경 구성: 661. dnSpy를 이용해 소스 코드가 없는 .NET 어셈블리의 코드를 변경하는 방법 [1]
13233정성태1/28/20237087오류 유형: 840. C# - WebClient로 https 호출 시 "The request was aborted: Could not create SSL/TLS secure channel" 예외 발생
13232정성태1/27/20234856스크립트: 43. uwsgi의 --processes와 --threads 옵션
1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...