pgvector로 PostgreSQL에서 시맨틱 검색 구현하기
임베딩과 벡터 검색의 원리부터 pgvector의 거리 연산자, IVFFlat·HNSW 인덱스 트레이드오프, 하이브리드 검색까지 PostgreSQL로 시맨틱 검색을 구현하는 법을 정리한다.

키워드 검색의 한계에서 시작하기
전통적인 텍스트 검색은 문자열 일치에 기반한다. LIKE, 전문검색(full-text search), 역색인 모두 결국 "어떤 토큰이 문서에 등장하는가"를 본다. 이 방식은 빠르고 설명 가능하지만 의미를 모른다.
- "노트북 추천"으로 검색하면 "랩탑 추천" 문서는 매칭되지 않는다. 동의어를 모른다.
- "환불 절차가 궁금해요"라는 질문은 "결제 취소 방법 안내"라는 문서와 거의 같은 의미지만, 공통 토큰이 거의 없어 잡히지 않는다.
- 다국어/오타/어순 변형에 취약하다.
동의어 사전, 형태소 분석, 쿼리 확장으로 일부 보완할 수 있지만 이는 본질적으로 표면형(surface form)을 늘리는 작업이다. 의미 자체를 다루지 못한다.
시맨틱 검색은 접근을 바꾼다. 텍스트를 의미 공간의 벡터로 변환한 뒤, 의미가 가까운 것끼리는 벡터도 가깝다는 성질을 이용한다.
임베딩이란
임베딩(embedding)은 텍스트(또는 이미지·오디오)를 고정 길이의 실수 벡터로 사상하는 함수다. 임베딩 모델은 대량의 데이터로 학습되어, 의미가 비슷한 입력을 벡터 공간에서 가까운 위치로 보낸다.
- "강아지"와 "반려견"은 토큰이 다르지만 벡터는 가깝다.
- "환불"과 "결제 취소"도 가깝다.
- "은행(금융)"과 "강둑(river bank)"은 같은 표기여도 문맥에 따라 다른 위치로 간다(문맥 임베딩의 경우).
차원 수는 모델마다 다르다. 흔히 384, 768, 1024, 1536 차원을 쓴다. 차원이 높을수록 표현력은 크지만 저장·연산 비용이 커진다.
시맨틱 검색이란 결국 이렇다. 쿼리도 임베딩으로 바꾸고, 미리 임베딩해 둔 문서들 중 벡터 거리가 가장 가까운 것을 찾는다. 이 "가장 가까운 K개 찾기"가 최근접 이웃(K-Nearest Neighbors, KNN) 탐색이다.
PostgreSQL에서 이를 가능하게 하는 것이 pgvector 확장이다. 별도 벡터 DB를 도입하지 않고, 기존 관계형 데이터·트랜잭션·조인과 같은 테이블에서 벡터 검색을 처리할 수 있다.
pgvector 확장 설치와 벡터 컬럼
확장을 활성화한다.
CREATE EXTENSION IF NOT EXISTS vector;vector(N) 타입은 N차원 실수 벡터를 저장한다. N은 사용하는 임베딩 모델의 출력 차원과 정확히 일치해야 한다. 예를 들어 1536차원 모델을 쓴다면 이렇게 테이블을 만든다.
CREATE TABLE documents (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
content text NOT NULL,
embedding vector(1536),
created_at timestamptz NOT NULL DEFAULT now()
);임베딩 저장은 일반 INSERT다. 벡터 리터럴은 대괄호로 감싼 콤마 구분 문자열이다.
INSERT INTO documents (title, content, embedding)
VALUES (
'환불 정책',
'주문 후 7일 이내 환불이 가능합니다 ...',
'[0.0123, -0.0345, 0.918, ...]' -- 1536개 값
);실무에서는 이 벡터 문자열을 손으로 쓰지 않는다. 애플리케이션이 임베딩 모델을 호출해 받은 배열을 파라미터 바인딩으로 넘긴다. 이 파이프라인은 뒤에서 다룬다.
거리/유사도 연산자
pgvector는 세 가지 거리 연산자를 제공한다. 의미 검색의 핵심은 이 연산자다.
| 연산자 | 의미 | 값의 방향 |
|---|---|---|
<-> | L2(유클리드) 거리 | 작을수록 가까움 |
<=> | 코사인 거리 (1 - 코사인 유사도) | 작을수록 가까움 |
<#> | 내적(inner product)의 음수 | 작을수록(더 음수일수록) 가까움 |
세 연산자 모두 "값이 작을수록 더 유사"하도록 설계되어 있어, 그대로 ORDER BY ... ASC에 넣으면 가까운 순으로 정렬된다.
무엇을 언제 쓰나
- 코사인 거리(
<=>): 벡터의 방향만 보고 크기(magnitude)는 무시한다. 텍스트 임베딩의 의미 유사도에 가장 널리 쓰인다. 대부분의 임베딩 모델은 벡터를 정규화해 내보내거나 코사인 기준으로 학습되므로 기본 선택지로 적합하다. - L2 거리(
<->): 절대적 위치 차이를 본다. 임베딩이 정규화되어 있다면 L2와 코사인 순위는 사실상 동일해진다(단조 관계). 이미지 특징 벡터 등 크기 정보가 의미 있는 경우에 쓴다. - 내적(
<#>): 정규화된 벡터에서는 내적이 코사인 유사도와 같다. 정규화를 보장할 수 있다면 내적이 코사인 거리보다 연산이 약간 가볍다. 다만 정규화되지 않은 벡터에서는 크기가 큰 벡터가 부당하게 높은 점수를 받으므로 주의한다.
정리하면, 정규화된 텍스트 임베딩이라면 코사인(<=>)을 기본으로 쓰고, 정규화가 보장되면 내적(<#>)으로 최적화할 수 있다. 중요한 것은 검색에 쓰는 연산자와 인덱스의 연산자 클래스가 일치해야 한다는 점이다(뒤에서 설명).
기본 검색 쿼리 — KNN
쿼리 텍스트를 임베딩한 벡터를 :query로 바인딩한다고 하자. 가장 가까운 5개 문서를 찾는 쿼리는 다음과 같다.
SELECT id, title, content,
embedding <=> :query AS distance
FROM documents
ORDER BY embedding <=> :query
LIMIT 5;여기서 ORDER BY embedding <=> :query LIMIT k 패턴이 KNN의 표준형이다. distance를 함께 SELECT하면 유사도 점수를 응답에 넣거나 임계값으로 거를 수 있다.
-- 코사인 거리가 0.25 미만(=충분히 유사)인 것만
SELECT id, title, embedding <=> :query AS distance
FROM documents
WHERE embedding <=> :query < 0.25
ORDER BY embedding <=> :query
LIMIT 10;주의할 점은, 인덱스가 없으면 이 쿼리는 모든 행과 쿼리 벡터의 거리를 계산(브루트포스, 풀스캔)한다. 정확하지만 행 수가 늘수록 선형으로 느려진다. 수천 건이면 괜찮지만 수십만~수백만 건이면 ANN 인덱스가 필요하다.
인덱스 — 정확검색 대 근사최근접(ANN)
브루트포스 KNN은 항상 정확한 결과(exact nearest neighbor)를 주지만 느리다. 대규모에서는 약간의 정확도를 포기하고 속도를 얻는 근사 최근접 이웃(Approximate Nearest Neighbor, ANN) 인덱스를 쓴다. pgvector는 두 가지를 제공한다.
ANN의 품질은 보통 recall로 측정한다. recall은 "정확한 상위 K개 중 인덱스가 실제로 찾아낸 비율"이다. recall 1.0은 브루트포스와 동일한 결과를 뜻한다. 모든 ANN 튜닝은 본질적으로 recall과 latency(혹은 메모리·빌드 시간)의 트레이드오프다.
인덱스는 연산자별로 연산자 클래스가 다르다. 코사인 거리로 검색한다면 코사인용 연산자 클래스로 인덱스를 만들어야 한다.
IVFFlat
IVFFlat는 벡터 공간을 lists개의 클러스터(셀)로 나눈다(k-means 류 분할). 검색 시에는 쿼리와 가까운 일부 셀(probes개)만 스캔한다.
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);검색 시 탐색할 셀 수는 세션 파라미터로 조절한다.
SET ivfflat.probes = 10; -- 클수록 recall↑, 속도↓lists: 클러스터 개수. 경험칙으로 행 수 N에 대해N/1000정도(최대 수천), 백만 행 이상이면sqrt(N)근처를 기준으로 잡는다.probes: 검색 시 스캔할 셀 수. 1이면 가장 빠르지만 recall이 낮고,lists에 가까울수록 브루트포스에 수렴한다.
특징: 빌드가 빠르고 메모리를 적게 쓰지만, 정확도는 중간 수준이고 파라미터(특히 probes) 튜닝에 민감하다. 또한 IVFFlat는 데이터 분포를 학습해 셀을 나누므로 테이블에 대표 데이터가 충분히 적재된 뒤 만들어야 한다. 빈 테이블에 만들면 클러스터링이 엉망이 된다.
HNSW
HNSW(Hierarchical Navigable Small World)는 다층 그래프를 만들어, 상위 층에서 거칠게 진입한 뒤 하위 층으로 내려가며 이웃을 탐색한다.
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);검색 시 탐색 폭은 세션 파라미터로 조절한다.
SET hnsw.ef_search = 100; -- 클수록 recall↑, 속도↓m: 각 노드가 갖는 그래프 연결(이웃) 수. 크면 recall↑·메모리↑·빌드 느림. 보통 16 전후.ef_construction: 빌드 시 후보 탐색 폭. 크면 그래프 품질↑·빌드 느림. 보통 64~200.ef_search: 검색 시 후보 탐색 폭. 크면 recall↑·latency↑. 런타임에 쿼리별로 조절 가능.
특징: 정확도(recall)가 높고 검색이 빠르지만, 빌드가 느리고 메모리를 많이 쓴다. 또한 IVFFlat와 달리 사전 데이터 분포 학습이 필요 없어 빈 테이블에 미리 만들어 두고 점진적으로 INSERT해도 동작한다(다만 대량 적재라면 적재 후 빌드가 더 빠르다).
선택 기준
- 데이터가 자주 바뀌고 점진적 삽입이 많으며 높은 recall이 필요하면 HNSW. 사실상 대부분의 프로덕션 의미 검색 기본 선택지다.
- 메모리·빌드 비용이 빡빡하고 중간 정확도로 충분하거나, 일괄 적재 후 한 번 빌드하는 패턴이면 IVFFlat.
두 인덱스 모두 ANN이므로 결과는 근사다. 정확한 결과가 반드시 필요한 소규모 테이블이라면 인덱스 없이 브루트포스가 오히려 단순하고 정확하다.
임베딩 생성은 DB 밖에서
pgvector는 벡터를 저장·검색하지만 생성하지는 않는다. 임베딩은 DB 외부의 임베딩 모델(원격 API 또는 로컬 추론)로 만든 뒤 벡터로 넣어준다. 적재와 검색 양쪽에서 같은 모델·같은 차원·같은 정규화 규칙을 써야 한다.
적재 파이프라인 의사코드:
// 1) 문서를 적당한 청크로 분할 (너무 길면 의미가 희석된다)
const chunks = splitIntoChunks(document.content, { maxTokens: 500 });
for (const chunk of chunks) {
// 2) 임베딩 모델 호출 → 1536차원 배열
const vector: number[] = await embed(chunk.text); // 모델 차원 = 컬럼 차원
// 3) (모델이 정규화하지 않으면) 직접 정규화
const v = normalize(vector); // L2 norm = 1
// 4) INSERT — 벡터는 파라미터 바인딩으로
await db.query(
`INSERT INTO documents (title, content, embedding)
VALUES ($1, $2, $3)`,
[document.title, chunk.text, toVectorLiteral(v)] // '[0.01, -0.02, ...]'
);
}검색 파이프라인 의사코드:
async function semanticSearch(queryText: string, k = 5) {
// 1) 쿼리도 같은 모델로 임베딩 (+ 동일 정규화)
const q = normalize(await embed(queryText));
// 2) KNN 쿼리 — ORDER BY <=> ... LIMIT k
const rows = await db.query(
`SELECT id, title, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT $2`,
[toVectorLiteral(q), k]
);
return rows; // distance 오름차순 = 유사도 내림차순
}핵심 불변식: 적재 임베딩과 쿼리 임베딩은 같은 모델·같은 차원·같은 정규화로 만든다. 모델을 교체하면 기존 임베딩은 무효가 되므로 전체 재임베딩이 필요하다.
하이브리드 검색 — 벡터 + 키워드
시맨틱 검색은 의미는 잘 잡지만 정확한 토큰(제품 코드, 고유명사, 숫자, 약어)에는 약할 수 있다. "ERR-4012 에러"처럼 정확 일치가 중요한 쿼리는 벡터보다 키워드 검색이 낫다. 두 방식을 결합하는 것이 하이브리드 검색이다.
PostgreSQL은 전문검색(tsvector/tsquery)을 내장하므로 한 DB 안에서 둘을 합칠 수 있다.
ALTER TABLE documents
ADD COLUMN ts tsvector
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
CREATE INDEX ON documents USING gin (ts);결합 방식은 두 갈래다.
1) 점수 가중합. 벡터 유사도와 키워드 랭크를 정규화해 가중 합산한다. 두 점수의 스케일이 다르므로 정규화가 까다롭다.
SELECT id, title,
(1 - (embedding <=> :query)) AS vec_score,
ts_rank(ts, plainto_tsquery('simple', :q_text)) AS kw_score
FROM documents
WHERE ts @@ plainto_tsquery('simple', :q_text)
OR embedding <=> :query < 0.4
ORDER BY (0.7 * (1 - (embedding <=> :query))
+ 0.3 * ts_rank(ts, plainto_tsquery('simple', :q_text))) DESC
LIMIT 10;2) RRF(Reciprocal Rank Fusion). 점수 스케일을 신경 쓰지 않고 순위만으로 합치는 방법이다. 각 검색 결과에서 문서의 등수 rank에 대해 1 / (k + rank)(상수 k는 보통 60)를 더한다. 벡터 검색 상위 N개와 키워드 검색 상위 N개를 각각 뽑아 문서별 RRF 점수를 합산하고 재정렬한다. 스케일 정규화가 필요 없어 실무에서 견고하게 동작한다.
score(doc) = 1/(k + rank_vector(doc)) + 1/(k + rank_keyword(doc))대개 RRF가 가중합보다 튜닝 부담이 적고 안정적이다. 먼저 RRF로 시작하고, 도메인 특성에 맞춰 가중치를 도입하는 순서를 권한다.
실전 주의사항
- 차원 일치. 컬럼
vector(N)의 N과 임베딩 모델 출력 차원이 다르면 INSERT가 실패한다. 모델 교체 시 컬럼 차원과 전체 데이터를 함께 마이그레이션한다. - 정규화. 코사인/내적을 쓸 때 모델이 정규화 벡터를 주는지 확인한다. 아니면 적재·쿼리 양쪽에서 동일하게 정규화한다. 한쪽만 정규화하면 순위가 어긋난다.
- 인덱스 빌드 시점. IVFFlat는 데이터 분포를 학습하므로 대표 데이터를 적재한 뒤 인덱스를 만든다. 대량 일괄 적재라면 두 인덱스 모두 적재 후 한 번에 빌드하는 편이 빠르다. 빌드 시간이 길면
maintenance_work_mem을 늘리고, HNSW는 병렬 빌드를 활용한다. - 필터링과 ANN의 상호작용.
WHERE category = 'faq'같은 메타데이터 필터를 함께 걸 때 동작 방식을 이해해야 한다.- 사후 필터(post-filter): ANN으로 상위 K개를 먼저 뽑고 그다음 조건으로 거른다. 필터가 엄격하면 K개 중 거의 다 탈락해 결과가 부족해질 수 있다. 이때는
LIMIT을 넉넉히 키워 후보를 더 확보한다. - 사전 필터(pre-filter): 조건을 먼저 적용한 부분집합에서 최근접을 찾는다. ANN 인덱스는 전체 그래프/클러스터 위에서 동작하므로, 좁은 부분집합에 대한 사전 필터는 인덱스 효율을 떨어뜨리거나 인덱스를 못 타게 만들 수 있다. 필터 선택도가 매우 높으면 차라리 부분집합에서 브루트포스가 빠를 때도 있다.
- 카테고리별로 분리하고 싶다면 파티셔닝하거나, pgvector의 반복 스캔(iterative scan) 옵션으로 필터 후 후보 부족 문제를 완화할 수 있다.
- 사후 필터(post-filter): ANN으로 상위 K개를 먼저 뽑고 그다음 조건으로 거른다. 필터가 엄격하면 K개 중 거의 다 탈락해 결과가 부족해질 수 있다. 이때는
- EXPLAIN ANALYZE로 검증. 인덱스를 실제로 타는지(
Index Scan using ... hnsw) 확인한다.ORDER BY의 연산자가 인덱스 연산자 클래스와 다르면 인덱스를 못 탄다(예: 인덱스는 코사인인데 쿼리는<->). - 청크 크기. 문서를 통째로 한 벡터로 만들면 의미가 평균화되어 흐려진다. 문단/섹션 단위로 청크를 나눠 임베딩하면 검색 정밀도가 올라간다. 단, 너무 잘게 쪼개면 맥락이 끊긴다.
RAG와의 연결
시맨틱 검색은 RAG(Retrieval-Augmented Generation)의 검색(retrieval) 단계 그 자체다. 흐름은 다음과 같다.
- 문서를 청크로 나눠 임베딩하고 pgvector에 적재한다(인덱싱).
- 사용자 질문이 오면 질문을 임베딩해 KNN(필요하면 하이브리드)으로 관련 청크 상위 K개를 검색한다.
- 검색된 청크를 컨텍스트로 묶어 언어 모델 프롬프트에 함께 넣는다.
- 모델은 그 컨텍스트에 근거해 답을 생성한다.
즉 pgvector는 RAG의 "지식 검색기" 역할을 한다. 검색 품질(recall과 관련성)이 곧 RAG 답변 품질의 상한이다. 검색이 엉뚱한 청크를 주면 모델도 엉뚱하게 답한다. 그래서 청크 전략, 임베딩 모델 선택, 하이브리드 결합, 인덱스 튜닝이 RAG 시스템의 핵심 레버가 된다.
정리
- 키워드 검색은 표면형에 묶여 동의어·의미를 다루지 못한다. 임베딩은 텍스트를 의미 공간의 벡터로 바꾸고, 시맨틱 검색은 벡터 거리로 의미적 근접도를 잰다.
- pgvector는 PostgreSQL 안에서
vector(N)컬럼과<->/<=>/<#>연산자, ANN 인덱스로 이를 구현한다. 정규화된 텍스트 임베딩이면 코사인(<=>)이 기본이다. - 기본 검색은
ORDER BY embedding <=> :query LIMIT k. 대규모에서는 IVFFlat(빠른 빌드·중간 정확도) 또는 HNSW(높은 정확도·무거운 빌드)로 ANN을 쓰며, 모든 것은 recall과 latency의 트레이드오프다. - 임베딩 생성은 DB 밖에서 하고, 적재·쿼리에 같은 모델·차원·정규화를 강제한다.
- 정확 토큰 약점은 키워드 검색과의 하이브리드(가중합 또는 RRF)로 보완한다. 필터링·인덱스 빌드 시점·차원 일치는 실전에서 자주 발을 거는 지점이다.
- 별도 벡터 DB 없이 기존 PostgreSQL의 트랜잭션·조인·전문검색과 한 곳에서 운용할 수 있다는 점이 pgvector의 가장 큰 실용적 강점이다.