
RAG: 왜 벡터, 코사인 거리, 그리고 벡터 데이터베이스인가? (.NET + Postgres)
요약
.NET과 Postgres를 사용하여 RAG(검색 증강 생성) 시스템의 핵심 원리를 설명하는 튜토리얼입니다. 벡터 데이터베이스의 필요성과 임베딩, 코사인 유사도를 이용한 시맨틱 검색의 수학적 배경을 다룹니다.
핵심 포인트
- RAG는 LLM에 특정 컨텍스트를 제공하여 답변의 정확도를 높이는 기술입니다.
- 텍스트를 다차원 공간의 벡터로 변환하는 임베딩 기술이 핵심입니다.
- 코사인 유사도를 통해 벡터 간의 의미론적 유사도를 측정합니다.
- .NET, Postgres, Ollama를 활용한 실습 구현 과정을 제시합니다.
참고: 이 글은 .NET을 사용하여 RAG 시스템을 구축하는 가장 간단한 방법을 보여줍니다. 목표는 개념을 명확하게 설명하는 것이지, 프로덕션 환경에 바로 적용 가능한 구현을 제공하는 것이 아닙니다. 실제 코드에서 필요할 오류 처리, 보안 점검 및 기타 베스트 프랙티스(Best Practices)는 제외되었습니다.
저는 그 이면에 있는 이론을 진정으로 이해하지 못한 채 한동안 RAG를 사용하고 구현해 왔습니다. 왜 벡터 데이터베이스가 필요할까요? 왜 시맨틱 검색(Semantic Search)은 다른 방법이 아닌 코사인 거리(Cosine Distance)에 의존할까요?
이 질문들에 답하기 위해, 저는 .NET, Postgres (벡터 확장 기능 포함), Gemini, 그리고 qwen3-embedding:0.6b 모델을 실행하는 Ollama를 사용하여 구축한 구현 과정을 상세히 다루는 이 글을 작성했습니다.
RAG란 무엇인가?
RAG는 검색 증강 생성 (Retrieval-Augmented Generation)의 약자입니다. 핵심 아이디어는 LLM (Large Language Model)에 특정 컨텍스트 (Context)를 제공하여, 모델이 학습 과정에서 습득한 일반적인 지식이 아닌 해당 컨텍스트를 바탕으로 답변하게 하는 것입니다.
구체적인 예로 Google의 NotebookLM이 있습니다. 사용자가 애플리케이션에 자신만의 자료를 업로드하고 그에 대해 질문하면, 답변은 해당 특정 콘텐츠에서 추출됩니다. 즉, 폐쇄된 컨텍스트 (Closed Context)를 제공하고 그 컨텍스트로 제한된 응답을 기대하는 것입니다. 회사의 문서를 바탕으로 답변하는 고객 지원 챗봇도 동일한 원리를 따릅니다.
여기서 질문이 생깁니다. 특정 리소스, 텍스트 또는 이미지가 사용자가 찾는 답변과 관련이 있다는 것을 어떻게 알 수 있을까요? 그 모든 자료를 어떻게 수집하여 컨텍스트로 전달하며, 어떻게 하면 이를 최대한 효율적으로 수행할 수 있을까요?
왜 텍스트가 아닌 벡터인가
그 해답은 수학에 있습니다. 의미론적 검색 (semantic search)을 위해 데이터를 표현하는 가장 좋은 방법은 텍스트가 아니라 일련의 숫자, 즉 벡터 (vector)로 표현하는 것입니다. 문자를 직접 다루는 것은 훨씬 더 복잡하고 계산 비용이 많이 드는 반면, 숫자를 비교하는 것은 매우 빠릅니다.
각 리소스를 일련의 숫자로 변환하기 위해 우리는 임베딩 (embeddings)이라고 불리는 기술을 사용합니다. 임베딩은 데이터를 다차원 공간 내의 벡터로 변환하는 머신러닝 (machine learning) 방법이며, 이 벡터들은 의미를 담고 있습니다. 즉, 의미론적으로 유사한 데이터는 해당 공간에서 서로 가까이 위치하게 됩니다. 우리의 경우, Ollama를 사용하여 qwen3-embedding:0.6b 모델로 이러한 임베딩을 로컬에서 생성할 것입니다.
유사도 측정: 코사인 (cosine)
임베딩 모델에 의해 각 데이터 조각이 다차원 벡터로 변환되면, 두 벡터가 얼마나 유사한지 측정할 방법이 필요합니다. 가장 일반적인 기술은 코사인 유사도 (cosine similarity)입니다.
핵심 아이디어는 두 벡터 사이의 각도를 계산하는 것입니다. 두 벡터 사이의 각도가 작을수록 더 유사합니다. 달리 표현하자면, 각도의 코사인 (cosine) 값이 1에 가까울수록 데이터가 의미론적으로 더 유사하다는 것을 의미합니다.
n차원 벡터에 대해 구성 요소별로 작성하면, 공식은 다음과 같은 합산 식이 됩니다:
cos(θ) = ∑ AiBi / (√(∑ Ai²) * √(∑ Bi²))
분자에서는 동일한 위치의 구성 요소들을 곱하고 모두 더하는데, 이것이 내적 (dot product)입니다. 분모에서는 각 벡터의 길이로 정규화 (normalize)합니다. 이는 벡터가 2차원이든, 임베딩 모델이 생성하는 벡터와 같은 1,024차원이든 동일하게 적용됩니다.
이를 구체화하기 위해, 2차원 공간과 아래 그림에 표시된 벡터 A = (3, 5) 및 B = (6, 3)로 표현된 두 데이터 조각(텍스트, 단어 등) A와 B를 생각해 보겠습니다. 공식을 적용하면 다음과 같습니다:
cos(θ) = (A · B) / (|A| × |B|) = (36 + 53) / (√(3² + 5²) * √(6² + 3²)) = 33 / (√34 * √45) ≈ 33 / (5.83 * 6.71) ≈ 33 / 39.115 ≈ 0.844
약 0.84의 코사인 값은 A와 B가 상당히 가까운 방향을 가리키고 있음을 나타내며, 따라서 두 데이터는 상당히 유사합니다.
왜 사인(sine)이 아니라 코사인(cosine)인가?
사인(Sine)은 외적 (cross product)과 연관되어 있으며, 이는 2차원과 3차원에서만 잘 작동합니다. 임베딩 모델 (Embedding models)은 수백 또는 수천 차원에서 작동하므로, 사인을 사용하면 계산 비용이 엄청나게 커질 것입니다. 그 외에도, 2차원이나 3차원으로 제한하는 것은 모순을 야기할 수 있는데, 반대 방향의 각도가 동일한 값을 가질 수 있기 때문입니다. 예를 들어, sin(0)과 sin(180)은 모두 0으로 같습니다. 코사인 (Cosine)은 cos(0)과 cos(180)이 서로 다르기 때문에 이러한 문제가 발생하지 않습니다.
직접 만들어 봅시다
이러한 개요와 임베딩 (embeddings)이 왜 필요한지에 대한 이해를 바탕으로, 다음과 같이 문제를 해결할 수 있습니다.
로컬 LLM (Large Language Model)으로는 만족스러운 결과를 얻지 못했기 때문에, 여기서는 넉넉한 일일 할당량과 견고한 모델을 제공하는 Gemini API를 사용합니다. 반면, 임베딩은 앞서 언급했듯이 Ollama를 통해 로컬에서 실행됩니다. 저장소로는 Vector 확장 기능이 포함된 Postgres를 사용합니다. .NET 측면에서는 Npgsql과 Entity Framework용 vector 패키지가 포함된 Entity Framework를 사용하여, LINQ (Language Integrated Query)와 Microsoft Semantic Kernel의 일부 기능을 함께 사용할 수 있도록 합니다.
패키지
먼저, 다음 패키지들을 설치해야 합니다:
- PgVector
- Npgsql
- EntityFramework
- PdfPig
- OllamaSharp
- Semantic Kernel
- Google GenAI (LLM용; 사용하는 사양에 따라 Ollama를 직접 사용할 수도 있습니다)
PgVector를 수용하도록 PostgreSQL 설정하기
var config = builder.Configuration;
builder.Services.AddOpenApi();
...
이 코드 스니펫의 핵심 세부 사항은 o.UseVector()입니다. 이는 Npgsql에 pgvector 타입 매핑을 등록하여, Entity Framework가 PostgreSQL과 .NET 사이에서 벡터 컬럼을 어떻게 변환할지 알 수 있게 합니다. 이것이 없다면 데이터베이스에서 읽거나 쓸 때 Vector 타입을 인식하지 못할 것입니다.
using Microsoft.EntityFrameworkCore;
public class DatabaseContext : DbContext
...
이 DbContext는 벡터 설정이 실제로 구체화되는 곳입니다. 세 줄의 코드가 주목할 만합니다. HasPostgresExtension("vector")는 마이그레이션(migrations)이 적용될 때 데이터베이스에서 pgvector 확장이 활성화되도록 보장합니다. HasColumnType("vector(1024)")는 Embedding 컬럼이 정확히 1024차원의 벡터를 저장함을 선언하며, 이는 임베딩 모델 (embedding model)의 출력 크기와 일치해야 합니다. qwen3-embedding:0.6b는 1024차원 벡터를 생성하므로, 이 숫자들은 서로 일치해야 합니다. 마지막으로, 인덱스는 vector_cosine_ops 연산자 클래스(operator class)와 함께 HNSW (Hierarchical Navigable Small World) 방식을 사용합니다. HNSW는 대규모 테이블에서도 유사도 검색 (similarity searches)을 빠르게 수행할 수 있게 해주는 근사 최근접 이웃 (approximate nearest neighbor) 인덱스이며, vector_cosine_ops는 시맨틱 검색 (semantic search)을 위해 우리가 의존하는 지표인 코사인 거리 (cosine distance)에 특화되도록 데이터를 구성하라고 지시합니다.
문서 읽기
먼저, 문서를 읽어야 합니다. 여기서는 PdfPig를 사용하여 PDF 파일만 고려했습니다:
using UglyToad.PdfPig;
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
...
이 메서드는 업로드된 파일을 받아 디스크의 uploads 디렉토리에 저장한 다음, PdfPig로 파일을 엽니다. 모든 페이지를 반복하며 ContentOrderTextExtractor를 사용하여 텍스트를 추출하는데, 이는 문자가 PDF에 저장된 임의의 순서가 아니라 자연스러운 읽기 순서대로 콘텐츠를 읽으려고 시도합니다.
임베딩 생성하기
임베딩 (embeddings)을 위해 다음을 사용했습니다:
using Microsoft.SemanticKernel.Text;
using OllamaSharp;
using OllamaSharp.Models;
...
이 서비스는 텍스트를 벡터 (vector)로 변환하는 실제 작업을 수행하며, 이는 두 단계로 진행됩니다. 첫 번째로, 텍스트를 관리 가능한 조각으로 나누는 과정인 청킹 (chunking)이 이루어집니다. SplitPlainTextLines는 원문 텍스트를 최대 30개의 토큰 (token) 단위의 줄로 나누고, 이어서 SplitPlainTextParagraphs는 해당 줄들을 최대 100개의 토큰 단위의 단락으로 그룹화하며, 이때 연속된 청크 (chunk) 사이에 20개의 토큰이 겹치도록 (overlap) 설정합니다. 이 중첩 (overlap)은 매우 중요한데, 경계 부분에서 약간의 공유 문맥 (context)을 유지함으로써 두 개의 청크에 걸쳐 나누어진 문장이 의미를 잃지 않도록 하기 때문입니다. 청킹이 필요한 이유는 매우 긴 텍스트를 하나의 벡터로 임베딩 (embedding)하면 그 의미가 흐려지기 때문이며, 검색 (retrieval) 시 문서 전체가 아닌 작고 집중된 구절을 반환하기를 원하기 때문입니다.
두 번째로, 청크들은 EmbedAsync를 통해 Ollama로 전송됩니다. 이 메서드는 qwen3-embedding:0.6b 모델을 로컬에서 실행하고 각 청크당 하나의 벡터를 반환합니다. 그 후 Zip 호출을 통해 각 텍스트 청크를 그에 대응하는 벡터와 쌍으로 묶고, 이를 CreateSource로 감싸서 원문 텍스트와 임베딩이 함께 유지되어 나란히 저장될 수 있도록 합니다.
데이터베이스 모델
데이터베이스 모델을 위해, Source 모델을 사용하여 다음과 같은 테이블을 생성했습니다:
public class Source
{
public int Id { get; set; }
...
그리고 DTO (Data Transfer Object)들입니다:
using Pgvector;
public class CreateSource
...
using System.ComponentModel.DataAnnotations;
public class FileUploadDto
...
Source 엔티티 (entity)는 영속화 (persisted)되는 대상입니다. 이는 Content에 원문 텍스트를, Embedding에 벡터를 보유합니다. CreateSource는 임베딩을 생성하는 동안 사용되는 중간 객체로, 데이터가 저장될 엔티티로 매핑되기 전 단계에서 쓰입니다. FileUploadDto는 업로드 요청의 형태를 기술하며, FormFile에 적용된 [Required] 속성은 파일이 제공되지 않을 경우 요청을 거부하도록 보장합니다.
파일 컨트롤러 (File Controller)
using Microsoft.AspNetCore.Mvc;
[ApiController]
...
이 컨트롤러는 인제스션 파이프라인 (ingestion pipeline)을 하나로 묶어줍니다. 파일이 업로드되면, PDF에서 텍스트를 읽고, 각 청크 (chunk)에 대한 임베딩 (embeddings)을 생성하며, 결과를 Source 엔티티 (entities)에 매핑한 뒤, AddRangeAsync에 이어 SaveChangesAsync를 호출하여 모든 데이터를 단일 배치 (batch)로 데이터베이스에 저장합니다. 이 과정이 완료되면 데이터베이스에는 각 청크의 텍스트가 벡터 (vector)와 함께 저장되어 검색 준비를 마칩니다. 이것이 RAG의 오프라인 단계인 인덱싱 (indexing) 단계이며, 나중에 질문에 답변할 수 있도록 데이터를 준비하는 과정입니다.
채팅 컨트롤러 (The chat controller)
using Google.GenAI;
using Google.GenAI.Types;
using Microsoft.AspNetCore.Mvc;
...
이 컨트롤러는 검색 (retrieval)과 생성 (generation)이 결합되는 지점이며, 전체 시스템의 핵심입니다. 이 과정은 세 단계로 실행됩니다.
첫째, 사용자의 질문을 인제스션 (ingestion) 단계에서 사용했던 것과 동일한 임베딩 서비스 (embedding service)를 사용하여 벡터 (vector)로 변환합니다. 이는 필수적입니다. 비교가 의미를 가지려면 질문과 저장된 청크들이 반드시 동일한 벡터 공간 (vector space)에 존재해야 하기 때문입니다.
둘째, 질문과 가장 유사한 청크를 찾기 위해 데이터베이스에 쿼리 (query)를 보냅니다. CosineDistance 호출은 Entity Framework에 의해 pgvector의 코사인 연산자 (cosine operator)로 변환되므로, 비교 작업은 애플리케이션 메모리가 아닌 HNSW 인덱스 (index)를 사용하는 PostgreSQL 내부에서 실행됩니다. 그 다음 OrderBy를 통해 결과를 가장 가까운 것부터 먼 순서대로 정렬하고, Select를 통해 텍스트 내용만 남깁니다. 일치하는 청크들은 하나의 context 문자열로 결합됩니다. 이 쿼리에서 확인해야 할 한 가지 사항은, 코사인 거리 (cosine distance)는 유사도 (similarity)의 보완 관계이므로, 값이 작을수록 더 유사하다는 점입니다.
셋째, 질문(question)과 검색된 컨텍스트(context)가 LLM (Large Language Model)으로 전송됩니다. 시스템 지침(system instruction)은 모델이 제공된 컨텍스트 내에서만 답변하도록 제한하며, 그 외의 경우에는 모른다고 말하도록 강제합니다. 이것이 답변을 모델의 일반적인 학습 데이터가 아닌 사용자의 데이터에 근거(grounded)하게 만드는 핵심입니다. 0.1의 낮은 Temperature (온도) 설정은 출력을 더 결정론적(deterministic)으로 만들고 허구의 내용을 만들어낼 가능성을 낮추는데, 이는 충실한 검색(faithful retrieval)을 목표로 할 때 바람직한 설정입니다. Gemini가 반환한 결과는 이후 호출자(caller)에게 다시 전달됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기