개요

Class S를 만들었던 지난 하이브리드 검색 글에서는 텍스트를 토큰으로 나눠 검색하는 FTS 방식과, 임베딩 모델을 거쳐 수치로 표현해 비교하는 벡터 검색 방식을 사용했습니다. 전에는 이 두 방식을 로직 흐름으로 짧게 지나갔지만, 이번에는 더 면밀히 봐봅시다.

FTS

FTS는 Full Text Search입니다. LIKE '%키워드%'보다 키워드와 토큰 검색에 적합하고, 보통 tsvector, tsquery, GIN index를 조합해서 사용합니다.
※ ts: Text Search의 약어

  • tsvector: PostgreSQL의 데이터 타입으로, 문서를 검색 가능한 토큰 형태로 표현합니다
  • tsquery: PostgreSQL의 데이터 타입으로, 검색 조건을 표현합니다. plainto_tsquery(), to_tsquery() 같은 함수가 tsquery 값을 만듭니다
  • GIN index: tsvector의 토큰을 빠르게 찾기 위한 인덱스 구조입니다

GIN은 역인덱스(Inverted Index) 구조를 사용합니다. 일반적인 저장이 행 → 토큰이라면, 역인덱스는 이를 뒤집어 토큰 → 해당 토큰이 들어 있는 인덱스(행 위치) 목록으로 저장합니다.

postgres → [1, 3]
index    → [1, 2]
vector   → [2, 3]

그래서 postgres를 검색할 때 모든 행을 처음부터 읽지 않고, 해당 토큰에 연결된 인덱스 목록을 가져올 수 있습니다, Elasticsearch도 같은 방식을 사용합니다.

주의점으로는 PostgreSQL 기본 FTS가 영어처럼 사전과 어간 추출 설정이 제공되는 언어에서 더 잘 동작한다는 점입니다. 한국어는 조사와 형태소 때문에 기본 simple 설정만으로는 검색 품질이 제한적일 수 있는 게 아쉽죠.

Class S에서는 각 청크의 text를 PostgreSQL FTS 검색용 데이터인 tsvector로 변환해 search_vector 컬럼에 저장했습니다.

원본 텍스트 → to_tsvector('simple', text) → search_vector
UPDATE content_ai_searchchunk
SET search_vector = to_tsvector('simple', text)
WHERE video_id = $1;

그리고 검색할 때는 사용자가 입력한 문장을 공백 기준으로 나누고, 각각을 tsquery로 만든 뒤 OR 조건으로 결합합니다. 사용자가 “벡터 검색"이라고 입력하면 내부적으로 아래처럼 동작하도록 구현했죠.

plainto_tsquery('simple', '벡터') || plainto_tsquery('simple', '검색')

실제 검색 쿼리로 보면 아래와 같죠.

-- 검색어를 공백으로 나눈 토큰을 OR로 묶는다.
-- 예: '벡터 검색' → plainto_tsquery('simple', '벡터') || plainto_tsquery('simple', '검색')
SELECT
  sc.id,
  sc.video_id,
  sc.start_seconds,
  sc.end_seconds,
  left(sc.text, 120) AS text_preview,
  ts_rank(sc.search_vector, query) AS fts_rank
FROM content_ai_searchchunk AS sc
INNER JOIN media_video AS v ON v.id = sc.video_id
INNER JOIN media_course AS c ON c.id = v.course_id
CROSS JOIN LATERAL (
  SELECT (
    plainto_tsquery('simple', '벡터')
    || plainto_tsquery('simple', '검색')
  ) AS query
) AS q
WHERE sc.search_vector @@ q.query
  AND c.publication_status = 'published'
ORDER BY fts_rank DESC
LIMIT 20;

위 구조에서 조인을 제외하고 FTS 매칭을 담당하는 부분은 search_vector @@ query로 보면 됩니다. @@tsvectortsquery가 매칭되는지를 검사하는 연산자입니다.

Class S 코드에서는 여기에 토큰별 적중 여부를 세어 fts_rank * (적중 수 / 전체 토큰 수)로 한 번 더 가중합니다. ts_rank가 비슷하더라도 검색어를 더 많이 포함한 청크가 위로 올라갈 수 있게 했죠. 아래 표의 coverage는 앞의 SQL이 직접 반환하는 값이 아니라, Class S 코드에서 별도로 계산해 최종 점수에 곱하는 값입니다.

예를 들어 청크가 아래와 같다고 합시다.

id청크 내용
101pgvector를 이용하면 벡터 검색으로 비슷한 문서를 찾을 수 있다
102PostgreSQL Full Text Search를 이용한 검색 방법을 알아본다
103벡터 임베딩은 문장을 숫자 배열로 표현하는 방법이다
104데이터베이스 인덱스를 이용하면 검색 속도를 높일 수 있다

검색어가 벡터 검색이라면 결과는 대략 다음처럼 나올 수 있습니다.

idtext_previewfts_rankcoverage
101pgvector를 이용하면 벡터 검색으로 비슷한 문서를 찾을 수 있다0.0941.0
103벡터 임베딩은 문장을 숫자 배열로 표현하는 방법이다0.0610.5
102PostgreSQL Full Text Search를 이용한 검색 방법을 알아본다0.0520.5
104데이터베이스 인덱스를 이용하면 검색 속도를 높일 수 있다0.0380.5

추가로 search_vector에 GIN 인덱스를 만들어 두었다고 해서 항상 인덱스를 쓰는 것은 아닙니다. 데이터 양에 따라 옵티마이저가 순차 스캔으로 동작할 수 있어서, 실제 실행 계획은 아래처럼 확인해야 하죠.

  • ※ GIN(Generalized Inverted Index): 토큰에서 해당 토큰을 포함하는 행을 역으로 찾을 수 있게 구성한 인덱스
EXPLAIN
SELECT id
FROM content_ai_searchchunk
WHERE search_vector @@ plainto_tsquery('simple', 'pgvector')
LIMIT 10;

GIN이 붙으면 보통 Bitmap Index Scan 쪽으로 계획이 잡히고, 행 수가 적으면 Seq Scan이 나올 수도 있습니다.

Limit  (cost=0.00..11.62 rows=1 width=8)
  ->  Seq Scan on content_ai_searchchunk  (cost=0.00..11.62 rows=1 width=8)
        Filter: (search_vector @@ '''pgvector'''::tsquery)

여기서는 행 수가 적어서 Seq Scan이군요.

Vector

PostgreSQL에서는 pgvector 확장(extension)을 설치해 vector 타입을 사용할 수 있습니다.

  • PostgreSQL: 기존 DB와 같이 정확히 일치하는 값이나 조건을 찾음
  • pgvector: 의미 검색 기능을 PostgreSQL에 추가한 것

벡터는 간단하게 숫자 배열입니다. 이 값을 좌표로도 볼 수 있는데, 이 수치들로 특정 텍스트/의미를 표현할 수 있습니다. 아래 예시에서 “고양이가 귀엽다"와 “귀여운 고양이"처럼 의미가 겹치는 두 문장은 수치가 비슷하고, “PostgreSQL 설치 방법"은 의미가 다르기에 다르게 표현될 수 있는 거죠.

"고양이가 귀엽다"
→ [0.81, 0.24, -0.13, ...]

"귀여운 고양이"
→ [0.79, 0.27, -0.11, ...]

"PostgreSQL 설치 방법"
→ [-0.31, 0.77, 0.55, ...]

이처럼 텍스트를 수치로 표현하는 기법은 오래전부터 꾸준히 발전해 왔죠.

시점대표 기법한 줄 요약
1970년대TF-IDF단어 빈도 가중치. 의미보다 키워드 겹침에 가깝다
2013Word2Vec단어를 벡터로. 문장 단위 검색엔 약하다
2014Doc2Vec문서 단위 벡터. 문맥 이해는 얕다
2018BERT양방향 문맥. 문장 임베딩은 별도 처리가 필요하다
2019Sentence-BERT문장 유사도용으로 BERT를 재학습
2021SimCSE대조 학습으로 문장 표현을 더 단단히
2022~BGE / E5 등검색과 RAG에 맞춘 최신 embedding

Class S에서는 문장의 의미를 표현하는 OpenAI text-embedding-3-small(1536차원)로 청크를 만들었고, PostgreSQL pgvector의 코사인 거리 연산자 <=>로 가까운 청크를 고르게 구현했습니다. CosineDistance는 거리가 작을수록 비슷하고, 점수로 쓸 때는 1 - distance로 대략 0~1 유사도로 바꿉니다.

Class S의 쿼리로 보면 아래와 같죠.

-- 질의 문장을 embedding API로 만든 벡터를 $1 에 바인딩한다.
-- 예: $1 = '[0.01, -0.03, ...]'::vector(1536)
SELECT
  sc.id,
  sc.video_id,
  sc.start_seconds,
  sc.end_seconds,
  left(sc.text, 120) AS text_preview,
  sc.embedding <=> $1::vector AS cosine_distance,
  (1 - (sc.embedding <=> $1::vector)) AS similarity
FROM content_ai_searchchunk AS sc
INNER JOIN media_video AS v ON v.id = sc.video_id
INNER JOIN media_course AS c ON c.id = v.course_id
WHERE sc.embedding IS NOT NULL
  AND sc.embedding_model_version = 'openai:text-embedding-3-small'
  AND c.publication_status = 'published'
ORDER BY sc.embedding <=> $1::vector
LIMIT 20;

위 구조에서 조인을 제외하고 벡터 검색을 담당하는 부분은 ORDER BY sc.embedding <=> $1::vector로 보면 됩니다.

질의 벡터를 마지막 청크(id=14)로 잡았을 때, 같은 영상(video_id=8) 안에서 거리가 가까운 순으로 나온 예시입니다. 거리는 작을수록, 유사도는 클수록 의미가 가깝습니다.

idvideo구간(초)distancesimilarity
148874–9300.0001.000
138736–8760.1910.809
98286–4150.2410.759
680–1490.2840.716
118550–6250.2900.710
108409–5520.2950.705
78147–1630.3090.691
128621–7420.3140.686
88163–2930.3220.678

추가로 embedding 컬럼에 HNSW 인덱스(vector_cosine_ops)를 만들어 두었다고 해서 모든 벡터 검색에서 항상 인덱스를 쓰는 것은 아닙니다. 데이터 양에 따라 옵티마이저가 순차 스캔으로 동작할 수 있어서, 실제 실행 계획은 아래처럼 확인해야 하죠.

  • ※ HNSW(Hierarchical Navigable Small World) 인덱스: 고차원 벡터에서 유사한 항목을 빠르게 찾는 근사 최근접 이웃(ANN) 검색용 인덱스
EXPLAIN
SELECT id
FROM content_ai_searchchunk
ORDER BY embedding <=> (
  SELECT embedding
  FROM content_ai_searchchunk
  WHERE embedding IS NOT NULL
  LIMIT 1
)
LIMIT 10;

<=> 연산자는 두 벡터의 Cosine Distance를 계산해서, 기준 벡터와 가장 가까운 데이터를 앞쪽에 정렬한 뒤 상위 10개를 가져옵니다. 이 쿼리의 실행 계획을 보면

Limit  (cost=14.52..14.55 rows=10 width=16)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..0.09 rows=1 width=32)
          ->  Seq Scan on content_ai_searchchunk content_ai_searchchunk_1  (cost=0.00..11.30 rows=129 width=32)
                Filter: (embedding IS NOT NULL)
  ->  Sort  (cost=14.43..14.76 rows=130 width=16)
        Sort Key: ((content_ai_searchchunk.embedding <=> $0))
        ->  Seq Scan on content_ai_searchchunk  (cost=0.00..11.62 rows=130 width=16)

위처럼 Seq Scan → Sort → Limit로 나옵니다. 현재 데이터가 약 130 row라 순차 스캔 후 정렬로 동작하고 있음을 알 수 있죠. 그래서 HNSW 효과를 제대로 보려면 수천~수만 건 이상의 벡터 데이터를 넣은 뒤 실행해야 합니다.

결국 Class S에서는 정확한 토큰 매칭에 강한 FTS와 문맥이 비슷한 내용을 찾는 벡터 검색을 함께 사용합니다. 두 검색 결과에 조회수 점수까지 더해 최종 순위를 정하는 과정은 앞선 하이브리드 검색 글에서 확인할 수 있습니다.

LIKE vs FTS + GIN vs pgvector + HNSW

데이터 양에 따라 PostgreSQL 옵티마이저가 선택하는 실행 계획은 달라집니다. 이를 합성 데이터를 넣은 시뮬레이션으로 비교해 봤습니다.

세 방식의 특징을 먼저 정리하면 다음과 같습니다.

  • LIKE: 문자열 패턴을 직접 비교합니다. 별도 검색 인덱스 없이 부분 문자열을 찾을 수 있지만, LIKE '%검색어%'처럼 앞에 와일드카드가 붙으면 일반 B-tree 인덱스를 활용하기 어렵습니다.
  • FTS + GIN: 텍스트를 tsvector 토큰으로 바꾸고 GIN 역인덱스로 찾습니다. 정확한 키워드와 토큰 검색에 적합합니다.
  • pgvector + HNSW: 텍스트의 임베딩 벡터를 HNSW 인덱스로 탐색합니다. 같은 단어가 없어도 문맥과 의미가 가까운 데이터를 찾는 데 적합합니다.

이 테스트에서 pgvector 부분은 의미 검색 품질이 아니라 HNSW 인덱스 탐색 비용을 확인하는 것이 목적이므로, 외부 모델 대신 deterministic pseudo-embedding을 사용했습니다.
※ deterministic pseudo-embedding: 실제 임베딩 모델이 만든 벡터가 아니라, 같은 입력에 대해 항상 같은 벡터를 만드는 가짜 임베딩

조건

테이블은 아래 컬럼으로 구성했습니다.

id
title
body
embedding vector(64)
search_vector tsvector

다양하면서도 재현할 수 있는 합성 데이터셋을 만들기 위해 다음 요소를 조합했습니다.

구분예시
문서 타입runbook, incident report, api guide, tuning note
개발 주제postgres, docker, kubernetes, redis, auth, search
세부 키워드connection pooling, query planner, service discovery, cache invalidation
랜덤 문장latency regressions, rollout plan, integration tests

데이터셋은 영어로 구성했습니다. 따라서 한국어로 검색하면 토큰화와 검색 품질에서 다른 결과가 나올 수 있으며, 동일한 실행 시간이 나온다고 단정할 수는 없습니다.

결과

결론부터 보면 100건에서는 세 방식 모두 Seq Scan을 선택했지만, 1,000건부터 FTS는 GIN, 벡터 검색은 HNSW 인덱스를 사용하기 시작했습니다.

데이터 수LIKEFTS + GINHNSW
100Seq ScanSeq ScanSeq Scan
1,000Seq ScanGINHNSW
10,000Seq ScanGINHNSW
100,000Seq ScanGINHNSW

실행 시간의 단위는 모두 ms입니다. top_node는 모든 측정에서 Limit였습니다.

데이터 수방식접근 경로평균p50p95최소최대표준편차
100LIKESeq Scan0.338300000000000050.3310.4020.3230.4050.02099942527949195
100FTS + GINSeq Scan0.104933333333333340.1030.1330.0950.1330.00995830387626096
100HNSWSeq Scan0.045733333333333330.0460.0520.0380.0620.0052714804132355875
1,000LIKESeq Scan3.3401333333333333.3253.6243.1233.8340.18153915381486194
1,000FTS + GINGIN0.71333333333333330.6970.8150.6760.8520.04137326584204806
1,000HNSWHNSW0.222466666666666670.2170.2670.2080.2710.0168967112354657
10,000LIKESeq Scan33.3526333333333333.33233.95932.74734.6030.39091806032090404
10,000FTS + GINGIN7.3237666666666677.2897.5757.0637.7150.1399127026678282
10,000HNSWHNSW0.38923333333333330.3820.4530.3490.510.03535210410099824
100,000LIKESeq Scan132.7567130.627150.897124.813157.2957.7032709405004445
100,000FTS + GINGIN79.928578.36790.86776.735101.0874.774276899572658
100,000HNSWHNSW0.91113333333333340.6132.1690.5476.1771.0536460418988889

플래닝 시간은 PostgreSQL 옵티마이저가 여러 후보 중 실행 계획을 선택하는 데 걸린 시간이며, 단위는 ms입니다. 실행 계획은 그 결과로 선택된 Seq Scan, Sort, Index Scan 등의 연산 순서입니다.

아래 표에는 플래닝 시간, 예상 비용, 반환 행 수, 공유 버퍼와 실행 계획을 정리했습니다.

데이터 수방식평균 플래닝총비용반환 행shared hitshared read실행 계획
100LIKE0.025121.026570Limit > Sort > Seq Scan
100FTS + GIN0.052620.2920570Limit > Sort > Seq Scan
100HNSW0.0219666666666666722.9620570Limit > Sort > Seq Scan
1,000LIKE0.035666666666666666209.01205670Limit > Sort > Seq Scan
1,000FTS + GIN0.062233333333333335104.08204810Limit > Sort > Bitmap Heap Scan > Bitmap Index Scan
1,000HNSW0.0203185.742018500Limit > Index Scan
10,000LIKE0.06432086.022056580Limit > Sort > Seq Scan
10,000FTS + GIN0.07326666666666666764.892045010Limit > Sort > Bitmap Heap Scan > Bitmap Index Scan
10,000HNSW0.022233333333333334242.022035400Limit > Index Scan
100,000LIKE0.06620747.33204114634728Limit > Gather Merge > Sort > Seq Scan
100,000FTS + GIN0.10377542.9720449620Limit > Sort > Bitmap Heap Scan > Bitmap Index Scan
100,000HNSW0.03266666666666666298.472047520Limit > Index Scan

분석

  • 100건에서는 인덱스를 거치는 비용보다 테이블 전체를 읽고 정렬하는 비용이 작아 세 방식 모두 Seq Scan을 선택했습니다.
  • 1,000건부터 LIKE는 계속 Seq Scan을 사용했지만 FTS와 벡터 검색은 각각 GIN과 HNSW 인덱스를 사용했습니다.
  • 100,000건에서 평균 실행 시간은 LIKE 132.7567ms, FTS + GIN 79.9285ms, HNSW 0.9111333333333334ms였습니다.
  • 100건 LIKE는 반환 행이 6개이고 나머지는 20개이므로, 해당 구간의 실행 시간은 같은 반환 행 수를 기준으로 한 직접 비교가 아닙니다.

실행 시간은 PC 환경과 반복 횟수, 실제 검색 조건에 따라 달라질 수 있으므로 참고용으로만 보는 것이 좋습니다.