벡터 데이터베이스와 코사인 유사도는 LLM, 추천 시스템, 검색 시스템에서 핵심적으로 사용되는 개념입니다. 특히 최근의 RAG(Retrieval Augmented Generation) 구조에서는 거의 기본 구성요소라고 볼 수 있는데, 이를 학습한 내용을 정리하기 위해 글을 작성하게 되었습니다.
1. 벡터
벡터(Vector)란 크기와 방향을 가지는 숫자 배열입니다. AI 분야에서는 텍스트, 이미지 등의 데이터가 담고 있는 의미를 고차원 공간의 좌표로 변환한 것을 벡터라고 부릅니다. 쉽게 말해, 컴퓨터가 이해할 수 있는 형태로 의미를 숫자화한 것입니다.
일반적인 데이터베이스에서는 데이터를 문자열, 숫자, 날짜 같은 형태로 저장합니다. 이런 데이터는 사람이 읽으면 바로 뜻을 알 수 있지만, 컴퓨터 입장에서는 그저 문자의 나열일 뿐입니다. 예를 들어 이런 데이터가 있습니다.
1
2
3
4
5
문장
----------------------
정산은 매주 수요일에 진행됩니다
배송은 CJ대한통운을 사용합니다
판매자 등급 기준은 매출입니다
하지만 컴퓨터는 문장의 의미를 직접 이해할 수 없습니다. 그래서 텍스트를 숫자 배열로 변환합니다.
1
2
3
"정산은 매주 수요일에 진행됩니다"
↓
[0.12, -0.34, 0.56, 0.21, ...] (1536개 숫자)
이 숫자 배열을 벡터(vector) 라고 합니다.
텍스트를 벡터로 변환하는 과정을 임베딩(Embedding) 이라고 하며, 이 변환을 수행하는 모델을 임베딩 모델이라고 합니다. 임베딩 모델은 단순히 글자를 숫자로 바꾸는 것이 아니라, 텍스트가 담고 있는 의미 자체를 고차원 공간의 좌표로 표현합니다.
여기서 “고차원”이라 함은 보통 수백에서 수천 개의 숫자로 이루어진다는 뜻입니다. OpenAI의 text-embedding-3-small 모델은 1536차원, text-embedding-3-large 모델은 3072차원의 벡터를 생성합니다. 차원이 높을수록 의미의 미세한 차이를 더 세밀하게 표현할 수 있지만, 그만큼 저장 공간과 연산 비용이 늘어납니다.
1
텍스트 → 임베딩 모델 → 벡터
임베딩의 핵심 원리는 의미가 비슷한 텍스트는 벡터 공간에서 가까운 위치에 놓인다는 것입니다. 마치 지도에서 서울과 인천이 가까운 좌표에 찍히는 것처럼, “배송”과 “택배”는 벡터 공간에서 가까운 좌표에 위치하게 됩니다. 이 원리 덕분에 언어가 달라도 의미가 비슷하면 벡터도 비슷한 위치에 생성됩니다. 두 문장의 벡터가 거의 같은 좌표에 위치하는 것을 볼 수 있습니다. 이것이 의미 기반 검색의 출발점입니다.
1
2
"고양이가 자고 있다" → [0.82, -0.11, 0.34, ...]
"cat is sleeping" → [0.80, -0.10, 0.35, ...]
2. 벡터 데이터베이스
벡터 데이터베이스(Vector Database)란 벡터를 저장하고, 주어진 벡터와 가장 유사한 벡터를 빠르게 찾아주는 데 특화된 데이터베이스입니다. 일반적인 RDB가 WHERE price > 1000 같은 정확한 값 일치나 범위 조건으로 데이터를 찾는다면, 벡터 데이터베이스는 “이 벡터와 의미적으로 가장 가까운 데이터가 뭐지?”라는 질문에 답하는 데 최적화되어 있습니다.
대표적인 벡터 데이터베이스로는 Pinecone, Weaviate, Milvus, Qdrant 등이 있고, PostgreSQL에 벡터 기능을 추가하는 pgvector 확장도 널리 사용됩니다. 저장 구조는 대략 이런 형태입니다.
1
2
3
4
5
id | content | embedding
--------------------------------------------------------------
1 | 정산은 매주 수요일입니다 | [0.12, -0.34, 0.56, ...]
2 | 배송은 CJ대한통운입니다 | [0.81, 0.12, -0.44, ...]
3 | 판매자 등급은 매출 기준입니다 | [-0.21, 0.65, 0.19, ...]
여기서 중요한 점은 원본 텍스트(content)와 벡터(embedding)를 함께 저장한다는 것입니다. 벡터는 검색에 사용하고, 실제로 사용자에게 보여주거나 LLM에게 전달하는 것은 원본 텍스트입니다.
사용자가 “돈 언제 들어와?” 라고 질문하면, 이 질문도 같은 임베딩 모델을 통해 벡터로 변환됩니다. 여기서 “같은 임베딩 모델”이라는 점이 중요합니다. 저장할 때 사용한 모델과 검색할 때 사용하는 모델이 다르면, 벡터 공간 자체가 달라지기 때문에 유사도 비교가 의미를 잃게 됩니다.
1
"돈 언제 들어와?" → [0.11, -0.33, 0.55, ...]
그 다음 DB에 저장된 모든 벡터들과 유사도 계산을 수행합니다.
1
2
3
4
5
질문 벡터 vs 문서 벡터
정산은 매주 수요일입니다 → 거리 0.05 (매우 가까움)
배송은 CJ대한통운입니다 → 거리 0.92 (멀다)
판매자 등급 기준은 매출 → 거리 0.88 (멀다)
가장 가까운 문서인 “정산은 매주 수요일입니다”가 선택되고, 이 문서를 LLM에게 참고 자료로 함께 전달합니다.
1
2
질문: 돈 언제 들어와?
참고 문서: 정산은 매주 수요일입니다
LLM은 이 참고 문서를 바탕으로 “정산은 매주 수요일에 진행됩니다.”라는 답변을 생성합니다. 이 전체 구조가 바로 RAG(Retrieval Augmented Generation) 입니다. RAG를 직역하면 “검색으로 보강된 생성”인데, 말 그대로 LLM이 답변을 생성(Generation)하기 전에 관련 문서를 먼저 검색(Retrieval)해서 답변의 근거를 보강(Augmented)하는 방식입니다. 이 구조 덕분에 LLM이 학습하지 않은 사내 문서, 최신 정보, 도메인 특화 지식에 대해서도 정확한 답변을 할 수 있게 됩니다.
3. 코사인 유사도
코사인 유사도(Cosine Similarity)란 두 벡터가 이루는 각도의 코사인 값을 구해서, 두 벡터의 방향이 얼마나 비슷한지를 -1에서 1 사이의 숫자로 나타내는 유사도 측정 방법입니다. 1에 가까우면 같은 의미, 0이면 관련 없음, -1이면 반대 의미를 나타냅니다. 벡터 데이터베이스에서 “어떤 문서가 질문과 가장 비슷한가”를 판단할 때 가장 널리 사용되는 방법이 바로 이 코사인 유사도입니다.
핵심 개념은 간단합니다.
두 벡터가 같은 방향을 바라볼수록 의미가 비슷하다
실제 임베딩 벡터는 1536차원처럼 눈으로 볼 수 없는 고차원이지만, 원리를 이해하기 위해 2차원 평면에서 생각해 볼 수 있습니다. 두 벡터 A와 B가 있을 때, 이 둘이 이루는 각도(θ)가 작을수록 유사합니다.
1
2
3
4
5
6
7
8
9
↑
| B
| /
| / θ
| /
| /
| /
|/
------A------------→
각도에 따른 유사도 값은 다음과 같습니다.
1
2
3
θ = 0° → 완전히 같은 방향 → 유사도 1.0
θ = 90° → 직각(관련 없음) → 유사도 0.0
θ = 180° → 반대 방향 → 유사도 -1.0
공식
1
2
3
A · B
cos(θ) = ─────────────
|A| × |B|
이 공식의 구조를 풀어서 이해하면 이렇습니다. 분자인 A · B는 두 벡터의 내적(dot product) 입니다. 내적은 대응하는 원소끼리 곱해서 모두 더한 값인데, 직관적으로는 “두 벡터가 같은 방향으로 얼마나 힘을 합치고 있는가”를 나타냅니다. 대응하는 차원의 부호와 크기가 일치할수록 내적이 커지고, 한쪽이 양수인데 다른 쪽이 음수이면 내적이 줄어듭니다.
분모인 |A| × |B|는 각 벡터의 크기(L2 norm) 를 곱한 값입니다. 벡터의 크기는 각 원소를 제곱하여 더한 뒤 제곱근을 취해서 구합니다. 이 분모가 하는 역할이 핵심인데, 내적 값을 벡터 길이로 나눠줌으로써 벡터의 크기 영향을 상쇄합니다. 그래서 벡터가 아무리 크거나 작아도 결과는 항상 -1에서 1 사이의 값으로 정규화됩니다. 이것이 바로 “크기를 무시하고 방향만 비교한다”의 수학적 원리입니다.
계산 예시
1
2
3
4
5
6
7
8
9
A = [2, 1]
B = [1, 2]
내적: A · B = 2×1 + 1×2 = 4
크기: |A| = √(2² + 1²) = √5
|B| = √(1² + 2²) = √5
코사인 유사도 = 4 / (√5 × √5) = 4/5 = 0.8
유사도 0.8은 두 벡터가 꽤 비슷한 방향을 가리키고 있다는 뜻입니다. 임베딩 벡터에서 이 정도면 의미적으로 관련 있는 문장이라고 판단할 수 있습니다. 참고로 유사도가 정확히 1.0이면 완전히 같은 방향(같은 의미), 0.0이면 직교(전혀 관련 없음)입니다. 실무에서 텍스트 임베딩의 유사도는 보통 0.6 이상이면 관련성이 있고, 0.8 이상이면 매우 유사하다고 판단합니다.
텍스트 의미 비교에서 코사인 유사도가 선호되는 핵심 이유는 벡터의 크기(magnitude)를 무시하고 방향(direction)만 비교하기 때문입니다. 여기서 “크기”란 데이터의 개수나 모집단 크기가 아니라, 벡터의 길이(norm)를 뜻합니다. 공식의 분모가 벡터 길이를 나눠서 상쇄해 주기 때문에, 순수하게 방향만 남는 것입니다. 예를 들어 두 벡터가 있다고 가정해보겠습니다.
1
2
A = [1, 2]
B = [100, 200]
B는 A를 단순히 100배 늘린 것뿐이고, 방향은 완전히 동일합니다. 유클리드 거리(L2 distance)로 비교하면 두 점 사이의 직선 거리를 계산하므로 약 222라는 큰 값이 나옵니다. 같은 방향임에도 “매우 멀다”고 판정하는 것입니다. 반면 코사인 유사도로 비교하면 cos(A, B) = 1.0, 즉 완전히 동일한 의미로 판정됩니다.
이것이 중요한 이유는 텍스트에서 문장의 길이나 반복이 의미 자체를 바꾸지 않기 때문입니다. “고양이 좋아”와 “고양이 좋아 고양이 좋아 고양이 좋아”는 벡터의 크기가 다를 수 있지만 의미는 같습니다. 코사인 유사도는 크기를 제거하고 방향만 비교하기 때문에 이런 경우에도 올바르게 “같은 의미”로 판단합니다. 임베딩 모델이 텍스트의 의미를 벡터의 방향에 담기 때문에, 크기에 영향을 받지 않는 코사인 유사도가 텍스트 유사도 비교에 가장 적합한 것입니다.
실제로 OpenAI의 text-embedding-ada-002, text-embedding-3-small 등 대부분의 임베딩 모델이 코사인 유사도 기반 검색을 권장합니다. 참고로, 유사도 측정 방법은 코사인 유사도만 있는 것은 아닙니다. 각각의 특성이 다르기 때문에 상황에 따라 적합한 방법이 달라집니다.
1
2
3
코사인 유사도 : 방향만 비교, 크기 무시 → 텍스트 의미 비교에 적합
유클리드 거리 : 두 점 사이의 직선 거리 → 공간적 위치가 중요한 경우에 적합
내적(dot product) : 방향 + 크기 모두 반영 → 정규화된 벡터에서는 코사인과 동일
유클리드 거리는 좌표계에서 두 점 사이의 물리적 거리를 재는 것이므로, 지도 위 두 지점의 거리처럼 실제 위치가 중요한 경우에 적합합니다. 내적은 방향과 크기를 모두 반영하는데, 만약 벡터가 이미 정규화(길이를 1로 맞춤)되어 있다면 코사인 유사도와 동일한 결과를 냅니다. 많은 임베딩 모델이 이미 정규화된 벡터를 출력하기 때문에, 이 경우에는 내적을 사용해도 무방합니다.
4. pgvector에서의 유사도 연산
pgvector란 PostgreSQL에 벡터 데이터 타입과 유사도 검색 기능을 추가하는 오픈소스 확장(extension)입니다. 별도의 벡터 전용 데이터베이스를 새로 도입하지 않아도, 기존에 사용하던 PostgreSQL 위에서 벡터 저장과 검색을 바로 할 수 있기 때문에, 이미 PostgreSQL을 사용하고 있는 환경에서 가장 도입 장벽이 낮은 선택지입니다. 기존 테이블에 벡터 컬럼만 추가하면 되므로, 트랜잭션 관리나 JOIN 같은 RDB의 장점을 그대로 활용하면서 벡터 검색을 함께 쓸 수 있습니다.
pgvector는 세 가지 거리 연산자를 제공합니다.
1
2
3
<=> 코사인 거리 (cosine distance)
<-> 유클리드 거리 (L2 distance)
<#> 내적의 음수 (negative inner product)
여기서 주의할 점이 있습니다. <=>는 코사인 유사도가 아니라 코사인 거리를 반환합니다. 유사도와 거리는 반대 개념입니다. 유사도가 높을수록 의미가 비슷하고, 거리가 가까울수록(작을수록) 의미가 비슷합니다. 관계는 거리 = 1 - 유사도입니다.
예를 들어 유사도가 0.9인 두 벡터의 코사인 거리는 0.1이 됩니다. SQL에서 ORDER BY는 기본적으로 오름차순이므로, 거리가 가장 작은(유사도가 가장 높은) 문서가 먼저 나오게 됩니다. 실제 쿼리 예시는 다음과 같습니다. 이 쿼리는 질문 벡터와 코사인 거리가 가장 가까운(의미적으로 가장 유사한) 문서 5개를 반환합니다. distance 값이 0에 가까울수록 의미가 비슷하고, 1에 가까울수록 관련이 없는 문서입니다.
1
2
3
4
SELECT content, embedding <=> '[0.12, -0.33, 0.55, ...]' AS distance
FROM documents
ORDER BY embedding <=> '[0.12, -0.33, 0.55, ...]'
LIMIT 5;
5. 벡터 검색이 필요한 이유
시맨틱 검색(Semantic Search)이란 키워드의 정확한 일치가 아니라 의미의 유사성을 기반으로 검색하는 방식입니다. 벡터 검색은 이 시맨틱 검색을 구현하는 핵심 기술입니다. 왜 기존 키워드 검색으로는 부족한지 살펴보겠습니다. 기존 검색 방식은 키워드 기반입니다. SQL의 LIKE나 전문 검색(Full-Text Search)이 대표적입니다.
1
2
3
SELECT *
FROM docs
WHERE content LIKE '%정산%'
이 방식의 한계는 명확합니다. 사용자가 “돈 언제 들어와?”라고 물었을 때, 문서에는 “정산은 매주 수요일입니다”라고 적혀 있습니다. “돈”이라는 키워드가 문서에 없기 때문에 키워드 기반 검색으로는 이 문서를 찾을 수 없습니다.
반면 벡터 검색은 의미를 기반으로 검색합니다. 임베딩 모델이 “돈 언제 들어와?”, “정산 언제 해?”, “지급 언제 되지?” 같은 문장들을 모두 비슷한 벡터 좌표에 매핑하기 때문에, 키워드가 다르더라도 의미가 통하면 검색이 됩니다. 사용자의 자연어 질문에 대응해야 하는 시스템에서는 키워드 검색보다 훨씬 효과적입니다.
실무에서는 키워드 검색과 벡터 검색을 함께 사용하는 하이브리드 검색(Hybrid Search) 방식도 널리 쓰입니다. 고유명사나 코드명처럼 정확한 일치가 필요한 경우에는 키워드 검색이 더 정확하고, 의미적 유사성이 중요한 경우에는 벡터 검색이 더 효과적이기 때문입니다.
6. 벡터 검색의 성능 문제와 ANN 알고리즘
ANN(Approximate Nearest Neighbor)이란 정확히 가장 가까운 이웃을 찾는 대신, 거의 가장 가까운 이웃을 훨씬 빠르게 찾는 근사 알고리즘입니다. 벡터 검색에서 왜 이런 근사 방식이 필요한지부터 살펴보겠습니다.
벡터 검색의 가장 큰 과제는 성능입니다. 가장 단순한 방식은 질문 벡터와 DB에 저장된 모든 벡터 사이의 거리를 하나씩 전부 계산하는 것입니다. 문서가 100만 개라면 매 질문마다 100만 번의 거리 계산이 필요하고, 벡터 차원이 1536이라면 각 계산마다 1536번의 곱셈과 덧셈이 수행됩니다. 이 방식을 전수 검색(Brute-Force Search) 이라고 합니다. 정확도는 100%이지만, 데이터가 수십만 건만 넘어가도 응답 시간이 수 초 이상 걸릴 수 있어서 실시간 서비스에서는 현실적으로 사용하기 어렵습니다.
이 문제를 해결하기 위해 ANN 알고리즘을 사용합니다. 핵심 아이디어는 “100만 개를 다 비교하지 않아도, 전체의 일부만 영리하게 살펴보면 거의 정확한 결과를 얻을 수 있다”는 것입니다. 약간의 정확도를 희생하는 대신 검색 속도를 수십~수백 배 향상시킵니다. 실제로 대부분의 경우 전수 검색과 ANN의 결과가 동일하거나, 극히 미세한 차이만 있을 뿐입니다.
6-1. IVFFlat
IVFFlat(Inverted File with Flat Compression)은 벡터 공간을 미리 여러 개의 구역(클러스터)으로 나눠두는 방식입니다. 인덱스를 생성할 때 K-means 같은 클러스터링 알고리즘으로 모든 벡터를 그룹으로 분류하고, 각 그룹의 중심점(centroid)을 기록해 둡니다. 검색할 때는 먼저 질문 벡터와 각 중심점의 거리를 비교해서 가장 가까운 클러스터 몇 개를 고르고, 그 클러스터에 속한 벡터들만 상세하게 비교합니다. 전체를 훑는 것보다 훨씬 빠릅니다.
비유하자면 도서관에서 책을 찾을 때, 모든 서가를 다 뒤지는 대신 해당 분야의 서가 몇 개만 집중적으로 살펴보는 것과 같습니다. 다만 원하는 책이 엉뚱한 서가에 꽂혀 있을 수도 있기 때문에, 검색 범위를 넓힐수록 정확도가 올라가고 속도는 느려지는 트레이드오프가 있습니다. pgvector에서는 probes 파라미터로 몇 개의 클러스터를 탐색할지 조절할 수 있습니다.
1
2
3
4
5
6
7
-- IVFFlat 인덱스 생성 (lists = 클러스터 수)
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- 검색 시 탐색할 클러스터 수 조정 (높을수록 정확, 느림)
SET ivfflat.probes = 10;
6-2. HNSW
HNSW(Hierarchical Navigable Small World)는 벡터들을 그래프로 연결하는 방식입니다. IVFFlat이 공간을 구역으로 나누는 것과 달리, HNSW는 벡터들 사이에 “이웃 관계”를 만들어서 그래프 탐색으로 가까운 벡터를 찾습니다. 여러 계층(layer)의 그래프를 만드는데, 상위 계층은 노드가 적고 장거리 연결을 가지며, 하위 계층으로 내려갈수록 노드가 많아지고 지역적으로 촘촘한 연결을 가집니다. 검색할 때는 상위 계층에서 대략적인 위치를 잡고, 점점 하위 계층으로 내려가면서 정밀하게 탐색합니다.
비유하자면 서울에서 특정 식당을 찾는다고 할 때, 먼저 “강남 쪽이 가까울 것 같다”고 대략적인 방향을 잡고(상위 계층), 강남에서 “역삼역 근처”로 좁히고(중간 계층), 마지막으로 “역삼역 3번 출구 앞 골목”까지 정확하게 찾아가는(하위 계층) 것과 같습니다. 각 단계에서 현재 위치의 이웃 노드들을 살펴보고 질문 벡터에 더 가까운 노드로 이동하는 과정을 반복합니다.
1
2
3
4
5
6
7
-- HNSW 인덱스 생성
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 검색 시 정확도 조정 (높을수록 정확, 느림)
SET hnsw.ef_search = 40;