Context Window이 길어져도 Vector Database가 필요한가?
1. 개요
각 세대의 대형 언어 모델(LLM)은 더 긴 context window를 제공합니다. 따라서 많은 팀이 의문을 제기하기 시작했습니다: prompt에 모든 문서를 담을 수 있다면 Vector Database와 함께 RAG를 구축해야 할까요?
이 글은 ‘필요하다’ 또는 ’필요하지 않다’고 절대적으로 대답하지 않습니다. 두 가지 접근 방식이 어디서 다른지 보게 될 것입니다. 이 글에는 Token 예산을 추정하는 방법을 보여주는 Java 코드, retrieval 메커니즘을 이해하기 위한 최소 Vector Store, 그리고 간단한 전략 선택 도구가 포함되어 있습니다. 이 글을 읽고 나면 자신의 시스템에 어떤 방식을 적용할지 스스로 결정할 수 있을 것입니다.
2. LLM에 지식을 제공하는 두 가지 방법
LLM은 환불 정책, API 문서 또는 고객의 주문 이력 같은 내부 데이터를 모릅니다. 그 데이터를 답변에 포함하는 두 가지 주요 방법이 있습니다.
2.1. Vector Database를 이용한 Retrieval
전형적인 RAG(Retrieval-Augmented Generation) 프로세스는 네 가지 단계로 구성됩니다:
- 문서를 작은 조각(chunk)으로 나눕니다.
- Embedding 모델을 사용하여 각 chunk를 숫자 벡터로 변환합니다.
- 벡터를 Vector Database에 저장하고, 가장 가까운 이웃을 찾기 위한 index를 만듭니다.
- 사용자가 질문하면, 질문을 embedding하고 가장 가까운 top-K개의 chunk를 가져와 prompt에 포함시킵니다.
최종 prompt는 관련된 몇 개의 구절만 포함하며, 전체 문서 저장소를 포함하지 않습니다.
2.2. Long Context: “모두를 prompt에 담기”
이 방식은 훨씬 간단합니다: 전체 문서를 prompt에 연결하고 모델이 스스로 정보를 찾도록 합니다. Chunking이 필요 없고, embedding도 필요 없으며, 추가 인프라도 필요 없습니다.
작은 문서 저장소의 경우, 이 방식은 일반적으로 좋은 결과를 제공합니다. 모델이 전체 문맥을 보므로, 여러 부분을 종합해야 하는 질문에도 답할 수 있으며, 이는 top-K retrieval이 놓칠 수 있는 부분입니다.
3. 이 질문이 주목받고 있는 이유
이 논쟁이 뜨거워진 이유는 몇 가지입니다:
-
Context window의 급속한 증가. 이전에 분할되어야 했던 문서 저장소가 이제 단일 prompt에 들어갈 수 있습니다.
-
Prompt caching. 많은 제공자가 고정된 prompt 부분을 캐시할 수 있도록 합니다. 이 덕분에 동일한 문서 블록을 다시 보낼 때의 비용과 지연 시간이 크게 감소합니다.
-
RAG을 올바르게 구현하기가 어렵습니다. 잘못된 Chunking, 언어에 맞지 않는 embedding, top-K가 중요한 구절을 놓치는 것 등이 매우 일반적인 오류입니다. RAG pipeline을 디버깅해본 팀이라면 복잡한 계층 하나를 줄이고 싶을 것입니다.
Long context도 고유한 제한이 있습니다. 매우 긴 prompt에서 정보 검색의 품질이 일정하지 않을 수 있습니다: 큰 텍스트 블록의 중간에 있는 정보가 때때로 무시됩니다. 비용과 지연 시간은 여전히 입력 token 수에 따라 증가합니다. 또한 context window가 해결할 수 없는 문제도 있습니다. 예를 들어 권한 관리와 지속적으로 변경되는 데이터 같은 것입니다. 다음 섹션에서 각 포인트를 자세히 살펴봅니다.
4. 숫자부터 시작하기: Token 예산 추정
아키텍처를 선택하기 전에 문서 저장소가 몇 개의 token을 “사용”하는지 알아야 합니다. 아래 예제는 표준 Java만 사용합니다.
import java.util.List;
public class TokenBudget {
// 대략적인 추정: 실제 tokenizer는 다른 결과를 주며, 특히 베트남어에서는 그렇습니다
private static final double CHARS_PER_TOKEN = 4.0;
public static long estimateTokens(String text) {
return Math.round(text.length() / CHARS_PER_TOKEN);
}
public static long estimateCorpus(List<String> documents) {
return documents.stream().mapToLong(TokenBudget::estimateTokens).sum();
}
public static void main(String[] args) {
List<String> policies = List.of(
"환불 정책: 고객은 주문이 잘못되었을 때 환불받을 수 있습니다.",
"배송비는 무게와 배송 거리에 따라 계산됩니다.",
"고객 계정은 여러 번 로그인에 실패한 후 잠금됩니다."
);
long total = estimateCorpus(policies);
long budget = 8_000; // 당신이 문서용으로 할당한 context의 부분
System.out.printf("Corpus ~%d tokens, budget %d, fits=%b%n",
total, budget, total <= budget);
}
}
각 부분을 설명하면:
CHARS_PER_TOKEN은 매우 대략적인 휴리스틱입니다. 베트남어의 음성 기호는 영어보다 더 많은 token을 사용하는 경향이 있습니다. 정확한 수치가 필요할 때는 사용 중인 정확한 모델의 tokenizer를 사용해야 합니다.budget은 전체 context window가 아닙니다. System prompt, 대화 이력, 답변을 위한 공간을 남겨둬야 합니다.- 세 개의 짧은 구절에 대한 결과:
Corpus ~48 tokens, budget 8000, fits=true
fits=true이고 문서 저장소가 거의 변하지 않는다면, long context는 진지한 후보입니다. 문서 저장소가 예산보다 여러 배 크다면, context window가 아무리 길어도 여전히 retrieval이 필요합니다.
5. Retrieval 메커니즘을 이해하기 위한 최소 Vector Store
Retrieval이 실제로 무엇을 하는지 보기 위해, 메모리 내 vector store를 작성합니다. 여기서 embedding은 ‘hashing bag-of-words’ 유형이며, 설명 목적으로만 사용됩니다. 실제 시스템은 embedding 모델을 호출합니다.
import java.util.*;
public record Document(String id, String text) {}
public record ScoredDocument(Document document, double score) {}
public class HashingEmbedder {
private final int dimensions;
public HashingEmbedder(int dimensions) { this.dimensions = dimensions; }
public double[] embed(String text) {
double[] vector = new double[dimensions];
for (String token : text.toLowerCase().split("\\P{L}+")) {
if (token.isBlank()) continue;
vector[Math.floorMod(token.hashCode(), dimensions)] += 1.0;
}
double norm = Math.sqrt(Arrays.stream(vector).map(v -> v * v).sum());
if (norm > 0) for (int i = 0; i < dimensions; i++) vector[i] /= norm;
return vector;
}
}
public class InMemoryVectorStore {
private record Entry(Document document, double[] vector) {}
private final HashingEmbedder embedder;
private final List<Entry> entries = new ArrayList<>();
public InMemoryVectorStore(HashingEmbedder embedder) { this.embedder = embedder; }
public void add(Document document) {
entries.add(new Entry(document, embedder.embed(document.text())));
}
public List<ScoredDocument> search(String query, int topK) {
double[] q = embedder.embed(query);
return entries.stream()
.map(e -> new ScoredDocument(e.document(), dot(q, e.vector())))
.sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
.limit(topK)
.toList();
}
private static double dot(double[] a, double[] b) {
double sum = 0;
for (int i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
}
주요 사항:
embed는 문자가 아닌 문자로 단어를 분할합니다(\\P{L}+). 따라서 베트남어 음성 기호를 유지합니다. 각 단어는 벡터의 한 차원으로 해시됩니다.- 벡터는 길이 1로 정규화됩니다. 이때 내적(
dot)이 바로 코사인 유사도입니다. search는 모든 항목을 순회합니다. 이것은 전수 검색이며, 시간 복잡도는 O(n)입니다. 실제 vector database는 HNSW와 같은 근사 index를 사용하여 벡터 수가 많을 때 속도를 유지합니다.
테스트 실행:
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "환불 정책: 고객은 주문이 잘못되었을 때 환불받을 수 있습니다."));
store.add(new Document("shipping", "배송비는 무게와 배송 거리에 따라 계산됩니다."));
store.add(new Document("account", "고객 계정은 여러 번 로그인에 실패한 후 잠금됩니다."));
store.search("고객이 환불받고 싶어합니다", 2)
.forEach(r -> System.out.printf("%s -> %.3f%n", r.document().id(), r.score()));
refund -> 0.6xx
shipping -> 0.1xx
구체적인 숫자는 해시 함수에 따라 다릅니다. 순서는 안정적입니다: refund가 첫 번째인 이유는 질문과 많은 단어를 공유하기 때문입니다. 이 구절만 prompt에 포함되고, 세 개 모두가 아닙니다.
참고: 실제 embedding은 의미론적 관계를 포착합니다(“돈을 되돌려주기”는 “환불”과 가까움). 그러나 hashing bag-of-words는 그렇지 않습니다. 위의 예제를 프로덕션에 사용하지 마십시오.
프로덕션에서 PostgreSQL을 이미 사용하고 있다면, pgvector extension은 전문 vector database를 고려하기 전에 합리적인 첫 번째 단계입니다:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE policy_chunk (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
content TEXT NOT NULL,
embedding vector(768)
);
CREATE INDEX ON policy_chunk USING hnsw (embedding vector_cosine_ops);
-- 응용 프로그램이 제공하는 :query_embedding
SELECT id, content
FROM policy_chunk
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_embedding
LIMIT 5;
768의 차원은 사용하는 embedding 모델과 일치해야 합니다. 연산자 <=>는 코사인 거리를 계산합니다. WHERE tenant_id = ... 절은 retrieval이 여전히 필요한 중요한 이유 중 하나를 지적하며, 이는 섹션 7에서 논의합니다.
6. 두 가지 방법을 결합하기: 데이터에 따라 전략 선택
실제로 답변은 일반적으로 ’데이터에 따라 다르다’입니다. 이 결정을 코드로 표현하여 팀 전체가 기준에 동의할 수 있게 할 수 있습니다.
public enum ContextStrategy { FULL_CONTEXT, RETRIEVAL }
public record CorpusProfile(long totalTokens,
boolean changesFrequently,
boolean needsPerUserAccess) {}
public class ContextStrategySelector {
private final long documentBudgetTokens;
public ContextStrategySelector(long documentBudgetTokens) {
this.documentBudgetTokens = documentBudgetTokens;
}
public ContextStrategy choose(CorpusProfile profile) {
if (profile.needsPerUserAccess()) return ContextStrategy.RETRIEVAL;
if (profile.totalTokens() > documentBudgetTokens) return ContextStrategy.RETRIEVAL;
if (profile.changesFrequently() && profile.totalTokens() > documentBudgetTokens / 2) {
return ContextStrategy.RETRIEVAL; // 데이터가 자주 변할 때 prompt 캐싱을 활용하기 어렵습니다
}
return ContextStrategy.FULL_CONTEXT;
}
}
우선순위 순서의 로직:
- 각 사용자가 데이터의 일부만 볼 수 있다면, prompt에 넣기 전에 필터링해야 합니다. Retrieval의 필터는 이를 자연스럽게 처리합니다.
- 저장소가 token 예산보다 크면, retrieval 외에 다른 옵션이 없습니다.
- 데이터가 자주 변경되고 이미 상당한 예산을 차지한다면, prompt 캐싱이 효율적이지 않으므로 retrieval이 일반적으로 더 저렴합니다.
- 나머지 경우: 단순함을 위해 long context를 사용합니다.
/ 2 임계값은 예일 뿐입니다. 자신의 데이터에서 비용과 품질을 측정한 후 조정해야 합니다.
7. 주요 동작 테스트
테스트 의존성 - Spring Boot parent를 사용하는 프로젝트라면 버전이 이미 관리되고 있습니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RetrievalAndStrategyTest {
@Test
void givenRefundQuestion_whenSearch_thenRefundPolicyRanksFirst() {
var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "환불 정책: 고객은 주문이 잘못되었을 때 환불받을 수 있습니다."));
store.add(new Document("shipping", "배송비는 무게와 배송 거리에 따라 계산됩니다."));
var results = store.search("고객이 환불받고 싶어합니다", 1);
assertThat(results).hasSize(1);
assertThat(results.getFirst().document().id()).isEqualTo("refund");
}
@Test
void givenSmallStaticCorpus_whenChoose_thenFullContext() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(10_000, false, false);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.FULL_CONTEXT);
}
@Test
void givenPerUserAccess_whenChoose_thenRetrievalEvenIfSmall() {
var selector = new ContextStrategySelector(50_000);
var profile = new CorpusProfile(1_000, false, true);
assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.RETRIEVAL);
}
}
세 번째 테스트가 비즈니스 관점에서 가장 중요합니다. 문서 저장소가 작더라도, 액세스 제어 요구 사항은 여전히 retrieval을 사용하도록 강제합니다. 이것은 long context가 해제할 수 없는 제약 조건입니다.
8. Vector Database가 언제 필요하고 언제는 아닌가
Retrieval(Vector Database 또는 기존 DB의 vector index)이 여전히 필요한 경우:
- 문서 저장소가 context window보다 여러 배 크다. 예: 모든 지원 ticket 또는 전체 내부 wiki.
- 사용자 또는 테넌트별 액세스 제어가 있습니다. 모델이 사용자가 볼 수 없는 데이터를 ‘보게’ 해서는 안 됩니다. 심지어 prompt에서도 마찬가지입니다.
- 데이터가 지속적으로 업데이트되고 거의 즉시 반영되어야 합니다.
- 높은 쿼리 처리량입니다. 각 요청마다 거대한 prompt를 보내면 비용과 지연이 token 수에 따라 증가합니다.
- 명확한 출처 인용이 필요합니다. 즉, 답변이 어느 chunk에 기반하는지 알아야 합니다.
Vector Database를 건너뛸 수 있는 경우:
- 문서 저장소가 작고, 안정적이며, token 예산 내에 있습니다. 예: 제품의 정책 모음 또는 모듈 문서.
- 질문이 일반적으로 전체 문서를 종합해야 합니다. 예: ‘두 계약서의 모든 차이점을 요약하세요.’ Top-K retrieval은 이를 잘 처리하지 못합니다.
- 프로토타입을 작성 중이며 인프라에 투자하기 전에 가치를 검증하려고 합니다.
Vector search가 반드시 필요하지는 않습니다. 주문 코드, SKU 또는 함수 이름 같은 정확한 쿼리의 경우입니다. Full-text search 또는 SQL 쿼리가 종종 더 낫습니다. 많은 시스템은 keyword와 벡터를 결합하는 hybrid search를 사용합니다.
9. 일반적인 실수
-
Context window를 문서 예산으로 간주합니다. System prompt, 채팅 이력, 출력을 위한 공간을 남겨두는 것을 잊으면 실제 환경에서 요청이 잘리거나 오류가 발생합니다.
-
품질을 평가하지 않고 모두를 prompt에 담습니다. Prompt가 길수록 모델이 중간의 세부 사항을 놓치기 쉬워집니다. 표준 답변이 있는 테스트 질문 세트를 구축하고 데이터에서 측정합니다.
-
전문 vector database를 너무 빨리 구축합니다. 수만 개의 chunk가 있을 때, PostgreSQL의 vector index로 충분한 경우가 많으며, 운영할 시스템 하나를 줄일 수 있습니다.
-
Retrieval 후 권한을 필터링합니다. Top-K를 먼저 가져온 후 권한으로 필터링하면 모든 유효한 결과를 잃을 수 있습니다. 섹션 5의
WHERE tenant_id절처럼 권한 조건을 쿼리 자체에 포함시킵니다. -
Re-indexing 없이 embedding model을 변경합니다. 두 개의 다른 모델에서 생성된 벡터는 서로 비교할 수 없습니다.
참고: Long context와 retrieval은 서로 배제되지 않습니다. 일반적인 패턴은 ‘관대한’ retrieval로, 이전보다 더 많은 chunk를 가져온 다음, long context를 활용하여 모델이 자신의 선택을 하도록 합니다. 이 방식은 누락의 위험을 줄이면서 비용을 통제 가능한 범위로 유지합니다.
10. 결론
더 긴 context window는 균형 지점을 바꾸지만, retrieval의 필요성을 제거하지는 않습니다. 작고 안정적인 문서 저장소의 경우, ’모두를 prompt에 담기’가 일반적으로 간단하고 충분합니다. 대용량 데이터, 지속적인 변경, 액세스 제어 필요 또는 높은 처리량이 있는 경우, retrieval은 여전히 필수입니다. 그러나 이것이 반드시 별도의 vector database일 필요는 없습니다: 기존 데이터베이스의 vector index로 충분합니다.
실용적인 접근 방식은 먼저 측정한 다음 결정하는 것입니다. 문서 저장소의 token 수를 추정하고, 액세스 제어 요구 사항과 업데이트 빈도를 결정한 다음, 실제 질문 세트에서 품질을 평가합니다. ContextStrategySelector 같은 기준이 코드로 작성되면, 팀 전체가 명확하고 테스트 가능한 결정을 내릴 수 있습니다.