LLM은 Stateless하기 때문에 매 요청마다 컨텍스트를 직접 전달해야 한다. 그런데 컨텍스트가 길어질수록 토큰 비용이 폭증하고, 필요한 정보만 골라서 전달해야 한다는 문제가 남아 있었다.
이번 글에서는 그 해결책의 핵심인 Vector DB를 다룬다. 아래 세 가지 질문을 중심으로 풀어나간다.
MyBatis로 Oracle을 다뤄본 사람이라면 검색 쿼리를 이렇게 짰을 것이다.
SELECT * FROM documents WHERE content LIKE '%병가%'
이 방식은 글자가 일치하는지만 본다. 사용자가 "몸이 안 좋아서 쉬고 싶어"라고 검색했을 때, DB에 "병가 신청 방법"이라는 문서가 있어도 찾지 못한다. 두 표현은 의미상 같은 말이지만 글자가 완전히 다르기 때문이다.
Vector DB는 이 문제를 다르게 접근한다. 글자 일치가 아니라 의미의 유사함으로 검색한다.
컴퓨터가 "의미의 유사함"을 판단하려면 텍스트를 숫자로 바꿔야 한다. 이 과정을 임베딩이라고 한다.
원리는 간단하다. 단어를 여러 기준으로 점수 매기는 것이다.
"강아지" 기준별 점수:
- 생물인가? → 0.9
- 귀여운가? → 0.8
- 바퀴가 있나? → 0.0
→ 벡터: [0.9, 0.8, 0.0]
"개": [0.9, 0.8, 0.0] ← 거의 동일!
"자동차": [0.1, 0.2, 1.0] ← 완전히 다름!
두 벡터의 숫자가 비슷하다는 건, 좌표 공간에서 가까운 위치에 있다는 뜻이다. 실제 임베딩 모델은 이 기준(차원)이 3개가 아니라 768~3072개다. 차원이 많아질수록 단어의 의미를 더 세밀하게 표현할 수 있다.---
Vector DB를 쓰는 흐름은 크게 두 단계다: 저장과 검색.---
Vector DB 전용 솔루션(Pinecone, Weaviate 등)을 별도로 도입하면 세 가지 문제가 생긴다.
pgvector는 이 문제를 한 줄로 해결한다.
CREATE EXTENSION IF NOT EXISTS vector;
기존 PostgreSQL에 이 한 줄만 추가하면 Vector DB 기능이 생긴다. 새로운 인프라 없이, SQL 문법 그대로, ACID 트랜잭션 보장까지.
-- 벡터 유사도 검색
SELECT content FROM vector_store
ORDER BY embedding <-> '[0.12, 0.05, ...]'
LIMIT 5;
<-> 연산자 하나로 수학적 거리 계산이 처리된다.
처음 보면 "왜 하나로 합치지 않지?"라는 의문이 생긴다. 답은 간단하다. 1:N 관계이기 때문이다.
vector_documents (1)
└── vector_store (N)
├── 청크 1: "병가는 연간..." [0.9, 0.2, ...]
├── 청크 2: "신청 방법은..." [0.8, 0.3, ...]
└── 청크 3: "HR팀 연락처..." [0.7, 0.1, ...]
파일 하나를 쪼개면 청크가 여러 개 나온다. 원본은 vector_documents에, 청크들은 vector_store에 따로 관리한다.
부가적으로, 각 청크의 메타데이터에 document_id를 달아두면 검색 결과가 "어느 파일에서 왔는지" 역추적이 가능하다. 출처 표시가 되는 것이다.
@Transactional
public DocumentUploadResponse uploadDocument(MultipartFile file) throws IOException {
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
// 1. 원본 저장
VectorDocument saved = vectorDocumentRepository.save(
VectorDocument.builder()
.fileName(file.getOriginalFilename())
.content(content)
.build()
);
// 2. 청크 분할 + 임베딩 + Vector DB 저장
List<Document> chunks = createChunks(content, saved);
vectorStore.add(chunks); // Spring AI가 임베딩까지 처리
return DocumentUploadResponse.builder()
.documentId(saved.getId().toString())
.chunkCount(chunks.size())
.build();
}
TextSplitter splitter = new TokenTextSplitter(
500, // 청크당 최대 토큰 수
100, // 오버랩 크기 (앞뒤 청크와 겹치는 양)
5, // 너무 짧은 문장은 제외
10000, // 파일당 최대 청크 수
true // 문단 구분자 유지
);
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5) // 유사도 상위 5개만 반환
.filterExpression(/* document_id 필터 */)
.build()
);
| 값 | 문제점 |
|---|---|
| 너무 작을 때 (1~2) | 필요한 정보 누락 → 불완전한 답변 |
| 적당할 때 (3~5) | 정확도와 비용의 균형점 |
| 너무 클 때 (10+) | 관련 없는 청크 유입 → 환각 증가, 비용 폭증 |
3~5가 권장값이다.
Vector DB의 핵심은 "글자 일치"에서 "의미 유사도"로의 패러다임 전환이다. 임베딩으로 텍스트를 벡터로 바꾸고, 그 벡터 간 거리를 계산해서 의미적으로 가까운 데이터를 찾는다.