시맨틱 커널을 사용하여 C#으로 RAG 시스템 구축

· 6분 읽기

소개

회사 문서, 제품 사양, 내부 지식 기반 등 자신의 데이터에 대한 질문에 답하기 위해 LLM을 사용해 본 적이 있다면 아마도 환각을 느끼거나 “그 내용에 대한 정보가 없습니다.“라고만 말하는 것을 눈치챘을 것입니다. 모델은 자신이 훈련받은 내용만 알고 있기 때문입니다.

RAG(Retrieval-Augmented Generation)가 이 문제를 해결합니다. 데이터에 대한 모델을 미세 조정하는 대신 쿼리 시 문서의 관련 청크를 검색하여 LLM에 컨텍스트로 전달합니다. 그런 다음 모델은 실제 데이터를 기반으로 답변을 생성합니다.

이 게시물에서는 Semantic Kernel을 사용하여 C#으로 완전한 RAG 파이프라인을 구축하는 과정을 안내하겠습니다.

RAG 작동 방식

흐름은 간단합니다.

  1. 수집: 문서를 청크로 분할하고, 각 청크에 대한 임베딩을 생성하고, 이를 벡터 데이터베이스에 저장합니다.
  2. 쿼리: 사용자가 질문을 하면 쿼리에 대한 임베딩을 생성하고 벡터 데이터베이스에서 유사한 청크를 검색합니다.
  3. 생성: 검색된 청크를 사용자의 질문과 함께 LLM에 컨텍스트로 전달합니다.

그게 다야. 마법은 임베딩에 있습니다. 임베딩은 텍스트의 의미론적 의미를 벡터로 캡처하므로 정확한 단어가 일치하지 않는 경우에도 관련 콘텐츠를 찾을 수 있습니다.

전제 조건

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Microsoft.Extensions.VectorData.Abstractions
dotnet add package Microsoft.SemanticKernel.Connectors.InMemory

프로덕션의 경우 Azure AI Search, Qdrant, Pinecone 또는 기타 지원되는 벡터 데이터베이스에 대한 메모리 내 저장소를 교체합니다. 하지만 인메모리는 학습과 프로토타입 제작에 적합합니다.

커널 설정

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Microsoft.SemanticKernel.Embeddings;

var builder = Kernel.CreateBuilder();

builder.AddAzureOpenAIChatCompletion(
    deploymentName: "gpt-4o",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: "text-embedding-3-small",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

var kernel = builder.Build();

두 가지 모델이 필요합니다. 하나는 채팅 완료(질문에 답변)용이고 다른 하나는 임베딩 생성(텍스트를 벡터로 변환)용입니다.

데이터 모델 정의

벡터 저장소에서 문서 청크를 나타내려면 클래스가 필요합니다.

using Microsoft.Extensions.VectorData;

public class DocumentChunk
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [VectorStoreRecordData]
    public string Content { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public string Source { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public int ChunkIndex { get; set; }

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }
}

VectorStoreRecordVector(1536) 속성은 벡터 저장소에 임베딩의 차원을 알려줍니다. text-embedding-3-small 모델은 1536차원 벡터를 생성합니다.

문서 청킹

임베딩을 생성하기 전에 문서를 관리 가능한 덩어리로 분할해야 합니다. 다음은 간단한 텍스트 분할기입니다.

public static class TextChunker
{
    public static List<string> SplitText(string text, int maxChunkSize = 500, int overlap = 50)
    {
        var chunks = new List<string>();
        var paragraphs = text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
        var currentChunk = new System.Text.StringBuilder();

        foreach (var paragraph in paragraphs)
        {
            if (currentChunk.Length + paragraph.Length > maxChunkSize && currentChunk.Length > 0)
            {
                chunks.Add(currentChunk.ToString().Trim());

                // Keep overlap from the end of the previous chunk
                var overlapText = currentChunk.ToString();
                currentChunk.Clear();
                if (overlapText.Length > overlap)
                {
                    currentChunk.Append(overlapText[^overlap..]);
                    currentChunk.Append(' ');
                }
            }

            currentChunk.Append(paragraph);
            currentChunk.Append("\n\n");
        }

        if (currentChunk.Length > 0)
        {
            chunks.Add(currentChunk.ToString().Trim());
        }

        return chunks;
    }
}

겹치는 부분은 중요합니다. 이는 청크 사이의 경계에 있는 컨텍스트가 손실되지 않도록 보장합니다. 관련 문장이 두 개의 덩어리로 분할되는 경우 중복은 그 중 적어도 하나에 완전히 표시된다는 것을 의미합니다.

문서 수집 중

이제 벡터 저장소에 문서를 수집하기 위해 모든 것을 하나로 묶어 보겠습니다.

var vectorStore = new InMemoryVectorStore();
var collection = vectorStore.GetCollection<string, DocumentChunk>("documents");
await collection.CreateCollectionIfNotExistsAsync();

var embeddingService = kernel.GetRequiredService<ITextEmbeddingGenerationService>();

async Task IngestDocument(string content, string source)
{
    var chunks = TextChunker.SplitText(content);

    for (int i = 0; i < chunks.Count; i++)
    {
        var embedding = await embeddingService.GenerateEmbeddingAsync(chunks[i]);

        var chunk = new DocumentChunk
        {
            Content = chunks[i],
            Source = source,
            ChunkIndex = i,
            Embedding = embedding
        };

        await collection.UpsertAsync(chunk);
    }

    Console.WriteLine($"✅ Ingested {chunks.Count} chunks from {source}");
}

// Ingest some documents
var doc1 = await File.ReadAllTextAsync("docs/product-guide.md");
var doc2 = await File.ReadAllTextAsync("docs/faq.md");
var doc3 = await File.ReadAllTextAsync("docs/troubleshooting.md");

await IngestDocument(doc1, "product-guide.md");
await IngestDocument(doc2, "faq.md");
await IngestDocument(doc3, "troubleshooting.md");

관련 청크 검색

사용자가 질문을 하면 해당 쿼리에 대한 임베딩을 생성하고 유사한 청크를 검색합니다.

async Task<List<DocumentChunk>> SearchAsync(string query, int topK = 3)
{
    var queryEmbedding = await embeddingService.GenerateEmbeddingAsync(query);

    var searchResults = await collection.VectorizedSearchAsync(
        queryEmbedding,
        new VectorSearchOptions { Top = topK });

    var results = new List<DocumentChunk>();
    await foreach (var result in searchResults.Results)
    {
        results.Add(result.Record);
    }

    return results;
}

상황에 맞는 답변 생성

이제 RAG 부분입니다. 검색된 청크를 가져와서 프롬프트에 컨텍스트로 포함시킵니다.

using Microsoft.SemanticKernel.ChatCompletion;

var chatService = kernel.GetRequiredService<IChatCompletionService>();

async Task<string> AskAsync(string question)
{
    // Step 1: Retrieve relevant chunks
    var relevantChunks = await SearchAsync(question);

    // Step 2: Build context from chunks
    var context = string.Join("\n\n---\n\n",
        relevantChunks.Select(c => $"[Source: {c.Source}]\n{c.Content}"));

    // Step 3: Generate answer with context
    var history = new ChatHistory();
    history.AddSystemMessage($$"""
        You are a helpful assistant that answers questions based on the provided context.
        Use ONLY the information from the context to answer. If the context doesn't contain
        enough information to answer the question, say "I don't have enough information
        to answer that question."

        Do not make up information. Always cite the source document when possible.

        Context:
        {{context}}
        """);

    history.AddUserMessage(question);

    var response = await chatService.GetChatMessageContentAsync(history);
    return response.Content ?? "No response generated.";
}

사용하기

// Ask questions about your documents
var answer1 = await AskAsync("How do I reset my password?");
Console.WriteLine($"Q: How do I reset my password?\nA: {answer1}\n");

var answer2 = await AskAsync("What are the system requirements?");
Console.WriteLine($"Q: What are the system requirements?\nA: {answer2}\n");

var answer3 = await AskAsync("What's the capital of France?");
Console.WriteLine($"Q: What's the capital of France?\nA: {answer3}\n");
// Should respond with "I don't have enough information" since it's not in the docs

프로덕션으로 이동

인메모리 벡터 저장소는 프로토타입 제작에 적합하지만 프로덕션에는 영구 벡터 데이터베이스가 필요합니다. Semantic Kernel에는 여러 옵션에 대한 커넥터가 있습니다.

# Azure AI Search
dotnet add package Microsoft.SemanticKernel.Connectors.AzureAISearch

# Qdrant
dotnet add package Microsoft.SemanticKernel.Connectors.Qdrant

# Redis
dotnet add package Microsoft.SemanticKernel.Connectors.Redis

모두 동일한 IVectorStore 인터페이스를 구현하므로 스와핑은 간단합니다.

// Instead of InMemoryVectorStore, use:
using Azure;
using Microsoft.SemanticKernel.Connectors.AzureAISearch;

var vectorStore = new AzureAISearchVectorStore(
    new Azure.Search.Documents.Indexes.SearchIndexClient(
        new Uri(config["AzureAISearch:Endpoint"]),
        new AzureKeyCredential(config["AzureAISearch:ApiKey"])));
```다른 모든 것은 동일하게 유지됩니다. 그것이 바로 추상화의 아름다움이다.

## RAG 시스템 구축 

제가 힘들게 배운  가지 사항은 다음과 같습니다.

- **청크 크기가 매우 중요합니다.** 너무 작으면 맥락을 잃게 됩니다. 너무 크면 관련 없는 콘텐츠에 토큰을 낭비하게 됩니다. 500-800개의 토큰으로 시작하고 데이터에 따라 조정하세요.
- **겹침은 경계 문제를 방지합니다.** 일반적으로 청크 사이에 50-100개의 토큰이 겹치는 것으로 충분합니다.
- **생각보다  많은 것을 검색합니다.** `topK = 5`로 시작하고 노이즈가 너무 많으면 줄이세요. 관련 부분을 놓치는 것보다 추가적인 맥락을 갖는 것이  낫습니다.
- **시스템 프롬프트가 중요합니다.** 제공된 컨텍스트만 사용하는 것에 대해 매우 명시적으로 설명하세요. 해당 지침이 없으면 모델은 "훈련 데이터를 기반으로" 즐거운 환각을 느낄 것입니다.
- **소스를 추적하세요.** 답변의 출처를 인용할  있도록 항상 청크와 함께 메타데이터를 저장하세요. 사용자는 출처를 확인할  있을  답변을  신뢰합니다.
- **필요한 경우 순위를 다시 지정하세요.** 벡터 유사성이 완벽하지 않습니다. 중요한 애플리케이션의 경우 크로스 인코더 모델을 사용하여 순위 재지정 단계를 추가하면 정밀도가 향상됩니다.

## 결론

RAG는 현재 AI에서 가장 실용적인 패턴  하나입니다. 미세 조정 없이 자체 데이터에 대해 AI 기반 Q&A 시스템을 구축할  있으며, Semantic Kernel을 사용하면 C#에서 놀라울 정도로 깔끔하게 정리됩니다. 인메모리 저장소로 시작하여 청크와 프롬프트를 올바르게 얻은 다음, 제작 준비가 되면 실제 벡터 데이터베이스로 교체하세요.

즐거운 코딩하세요!

## 리소스

- [의미론적 커널 벡터 저장소 문서](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [Azure AI Search를 사용한 RAG 패턴](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [텍스트 임베딩 모델](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)