Microsoft .NET/C#

[C#] .NET에서 로컬 벡터 DB(Qdrant) 연동과 RAG 검색 파이프라인 최소 구현

전자기린 2026. 11. 11. 12:08

 

로컬 벡터 DB(Qdrant)를 .NET에서 붙여 문서 검색(RAG) 파이프라인을 최소 구성하는 절차입니다. 임베딩은 로컬 Ollama로 만들고, 저장·검색은 Qdrant gRPC 클라이언트로 처리합니다.

 

절차

 

  1. Qdrant 실행과 포트 확인
  2. 패키지 설치와 클라이언트 연결
  3. 컬렉션 생성과 벡터 설정
  4. 임베딩 생성 (Ollama)
  5. 포인트 업서트
  6. 검색과 RAG 프롬프트 조립
  7. 운영에서 걸리는 지점

 

1. Qdrant 실행

 

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant

 

  1. 6333: REST API와 웹 대시보드
  2. 6334: gRPC. .NET 클라이언트는 이 포트를 사용합니다.
  3. 스토리지 볼륨을 붙이지 않으면 컨테이너 재시작 시 컬렉션이 사라집니다.

 

curl http://localhost:6333/healthz
curl http://localhost:6333/collections

 

2. 패키지와 클라이언트

 

dotnet add package Qdrant.Client

 

using Qdrant.Client;

QdrantClient client = new(
    host: "localhost",
    port: 6334,
    https: false);

var collections = await client.ListCollectionsAsync();
foreach (var name in collections)
    Console.WriteLine(name);

 

QdrantClient는 IDisposable입니다. DI에 등록할 때는 싱글톤으로 두고 애플리케이션 종료 시 함께 해제합니다.

 

builder.Services.AddSingleton(_ => new QdrantClient("localhost", 6334, https: false));

 

3. 컬렉션 생성

 

벡터 차원은 임베딩 모델의 출력 차원과 정확히 일치해야 합니다. 모델을 바꾸면 컬렉션을 새로 만듭니다.

 

using Qdrant.Client.Grpc;

const ulong VectorSize = 768;   // nomic-embed-text 기준 예시

await client.CreateCollectionAsync(
    collectionName: "docs",
    vectorsConfig: new VectorParams
    {
        Size = VectorSize,
        Distance = Distance.Cosine
    });

 

이미 있으면 예외가 나므로 생성 전에 존재 여부를 확인합니다.

 

if (!(await client.CollectionExistsAsync("docs")))
{
    await client.CreateCollectionAsync("docs", new VectorParams
    {
        Size = VectorSize,
        Distance = Distance.Cosine
    });
}

 

4. 임베딩 생성

 

Ollama의 임베딩 API를 직접 호출합니다. 별도 SDK 없이 HttpClient로 충분합니다.

 

ollama pull nomic-embed-text
curl http://localhost:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": "EF Core 지연 로딩 설정"
}'

 

using System.Net.Http.Json;

public sealed class OllamaEmbedder(HttpClient http)
{
    public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
    {
        var response = await http.PostAsJsonAsync(
            "http://localhost:11434/api/embed",
            new { model = "nomic-embed-text", input = text }, ct);

        response.EnsureSuccessStatusCode();

        var payload = await response.Content.ReadFromJsonAsync<EmbedResponse>(ct)
            ?? throw new InvalidOperationException("empty embedding response");

        return payload.Embeddings[0];
    }

    private sealed record EmbedResponse(float[][] Embeddings);
}

 

5. 포인트 업서트

 

문서를 청크로 나눠 임베딩하고, 원문 텍스트는 payload에 저장합니다. 검색 결과에서 원문을 꺼내 프롬프트에 넣기 위해서입니다.

 

public sealed record DocChunk(Guid Id, string Text, string Source);

public async Task IndexAsync(IEnumerable<DocChunk> chunks, OllamaEmbedder embedder)
{
    var points = new List<PointStruct>();

    foreach (var chunk in chunks)
    {
        var vector = await embedder.EmbedAsync(chunk.Text);

        points.Add(new PointStruct
        {
            Id = chunk.Id,
            Vectors = vector,
            Payload = { ["text"] = chunk.Text, ["source"] = chunk.Source }
        });
    }

    await client.UpsertAsync("docs", points);
}

 

ID는 재색인 시 같은 값이 나오도록 결정적으로 만듭니다. Guid.NewGuid()를 쓰면 같은 문서가 중복으로 쌓입니다.

 

6. 검색과 RAG 프롬프트

 

public async Task<IReadOnlyList<string>> SearchAsync(string question, OllamaEmbedder embedder)
{
    var queryVector = await embedder.EmbedAsync(question);

    var hits = await client.SearchAsync(
        collectionName: "docs",
        vector: queryVector,
        limit: 3,
        payloadSelector: true);

    return hits.Select(h => h.Payload["text"].StringValue).ToList();
}

 

검색 결과를 그대로 프롬프트에 붙이고, 근거가 없을 때의 답변 규칙을 함께 넣습니다.

 

var contexts = await SearchAsync(question, embedder);
var contextBlock = string.Join("\n\n", contexts);

var prompt = $"""
    아래 문맥만 사용해 질문에 답하세요.
    문맥에 없는 내용은 "제공된 문서에서 확인할 수 없습니다"라고만 답하세요.

    [문맥]
    {contextBlock}

    [질문]
    {question}
    """;

 

이 프롬프트를 Ollama 채팅 API로 보내면 RAG 한 바퀴가 완성됩니다. 검색 결과 0건일 때는 모델을 호출하지 않고 바로 안내 문구를 반환하는 편이 비용과 오답을 줄입니다.

 

7. 운영에서 걸리는 지점

 

  1. 벡터 차원 불일치: 컬렉션 차원과 임베딩 차원이 다르면 업서트가 거부됩니다. 모델 교체 시 컬렉션을 새로 만들고 재색인합니다.
  2. 청크 크기: 너무 크면 검색 정확도가 떨어지고, 너무 작으면 문맥이 끊깁니다. 문단 단위로 자르고 겹침을 조금 둡니다.
  3. 재색인 비용: 임베딩은 배치로 처리합니다. Ollama의 /api/embed는 input에 배열을 넣어 여러 개를 한 번에 보낼 수 있습니다.
  4. 필터 검색: 문서 종류나 날짜로 먼저 좁혀야 할 때는 payload 인덱스를 만들고 Filter를 함께 넘깁니다.
  5. 백업: 컬렉션 스냅샷 API로 볼륨 밖에 보관합니다. 볼륨만 믿으면 호스트 장애 시 함께 사라집니다.

 

await client.CreatePayloadIndexAsync("docs", "source", PayloadSchemaType.Keyword);

 

인덱스 없이 필터를 걸면 전체 스캔이 되어 검색 지연이 커집니다. 필터 조건으로 자주 쓰는 필드는 색인부터 만듭니다.

 

정리

 

Qdrant를 컨테이너로 띄우고 gRPC 포트(6334)로 연결한 뒤, 임베딩 차원과 같은 크기로 컬렉션을 만듭니다. Ollama /api/embed로 벡터를 만들어 payload에 원문을 함께 저장하고, 검색 결과를 프롬프트에 붙이면 최소 RAG 파이프라인이 완성됩니다.