THE DEVLOG

scribbly.

Next JS 치트 시트

2025.09.13 23:46:54

PostgreSQL을 위한 오픈소스 라이브러리인 pgvector(https://github.com/pgvector/pgvector)는 벡터를 이용한 각종 거리계산 함수를 이용한다. 이를 이용하여 symentic search를 구현할 수 있다.

이 게시글에서는 pgvector를 이용하여 코사인 거리로 검색하는 내용을 정리하였다.

스키마 생성

-- 1) 확장 설치 (Supabase에서 "vector" 확장)
create extension if not exists vector;

-- 2) 벡터 컬럼이 있는 테이블 (N은 임베딩 차원 수)
create table if not exists documents (
  id serial primary key,
  content text not null,
  metadata jsonb,
  embedding vector(768) -- 예: 모델 차원 수에 맞춰 설정(embeddinggemma는 768차원)
);
  • id : serial은 Postgres에서 자동 증가하는 32비트 정수이다. uuid의 128비트에 비해 성능 최적화에 용이한 점이 있다.

  • content : 임베딩할 텍스트

  • embedding : pgvector를 통해 생성되는 벡터. 차원을 정확히 입력해야 한다.

  • metadata : jsonb를 통해 임의로 입력이 가능하도록 설정한다. 청크 번호, 출처, 출처의 id 등 문서 검색에 필요한 값을 키-밸류 형태로 편하게 넣으면 된다.

    {
        "source": "world_bank_2023.pdf",
        "page_number": 15,
        "chunk_index": 0,
        "word_count": 512,
        "created_at": "2025-09-13",
    }
    

 

HNSW 인덱싱

create index concurrently on documents using hnsw (embedding vector_cosine_ops);

근사 최근접 이웃(ANN)알고리즘인 Hierarchical Navigable Small World로 인덱싱한다.

HNSW는 새로운 벡터가 생성되면, 인접한 벡터들을 찾은 후 연결해둔다.

쿼리가 입력되면, 쿼리와 인접한 벡터들부터 탐색한 후 top-k를 잘라 검색시간을 크게 줄여준다.

 

코사인 유사도 검색

-- 코사인 유사도 매칭 함수
create or replace function match_documents(
  query_embedding vector(768),
  match_threshold float default 0.0,  -- 유사도 임계값 (-1..1)
  match_count int default 10          -- 반환 개수
)
returns table(
  id bigint,
  content text,
  metadata jsonb,
  similarity float
)
language sql stable
as $$
  select
    d.id,
    d.content,
    d.metadata,
    1 - (d.embedding <=> query_embedding) as similarity  -- 코사인 유사도
  from documents d
  where 1 - (d.embedding <=> query_embedding) >= match_threshold
  order by d.embedding <=> query_embedding asc           -- 인덱스가 먹히도록 거리로 정렬
  limit least(match_count, 200);
$$;

pg벡터의 거리 연산자는 <->(L2 거리), <#>(내적), <=>(코사인 거리)가 있다.

L2 거리: 절대적인 차이가 중요할 때 (두 점 사이의 직선 거리)

  • 이미지 픽셀 값 비교

  • 수치 데이터의 정확한 거리 측정

음수 내적: 계산 속도가 중요할 때 (벡터 간의 곱(내적))

  • 대용량 데이터에서 빠른 검색

  • 추천 시스템

코사인 거리: 방향성이 중요할 때 (각도와 방향)

  • 텍스트 유사도 (문서 길이와 상관없이)

  • 사용자 선호도 패턴 비교

게시글의 각도가 같으면 내용이 유사하다는 의미가 된다. 따라서 코사인 유사도(1-코사인 거리)를 적용한다.

 

JOIN

만일 원본 게시글이 존재하는 경우, 해당 테이블을 조인하여 반환하는 것이 좋다.

Posts 테이블을 청킹해서 Documents로 저장하는 1:N 관계라고 하면 아래와 같이 스키마를 정의할 수 있을 것이다.

create table if not exists posts (
  id          uuid primary key,
  title       text        not null,
  content     text        not null,
);

create table if not exists documents (
  id           bigserial primary key,
  post_id      uuid      not null references posts(id) on delete cascade,
  chunk_index  integer     not null,
  content      text        not null, -- 청킹된 텍스트
  metadata     jsonb       not null default '{}',
  embedding    vector(768) not null,
  created_at   timestamptz not null default now(),

  -- 같은 post 내에서 chunk_index는 유일
  constraint documents_post_chunk_unique unique (post_id, chunk_index),
  -- chkunk_index는 0 이상
  constraint documents_chunk_index_nonneg check (chunk_index >= 0)
);

 

위의 두 테이블을 조인해서 반환하고 싶다면 아래와 같이 함수를 작성하면 된다.

create or replace function match_documents_with_posts(
  query_embedding vector(768),
  match_threshold float default 0.0,   -- 유사도 임계값 (-1..1)
  match_count     int   default 10,    -- 반환 개수
  filter_post_id  uuid  default null   -- 특정 post만 필터링하고 싶을 때 (옵션)
)
returns table(
  document_id   bigint,
  post_id       uuid,
  post_title    text,
  chunk_index   int,
  chunk_content text,
  similarity    float,
  metadata      jsonb
)
language sql
stable
as $$
  select
    d.id                                   as document_id,
    d.post_id                              as post_id,
    p.title                                as post_title,
    d.chunk_index                          as chunk_index,
    d.content                              as chunk_content,
    1 - (d.embedding <=> query_embedding)  as similarity,   -- 코사인 유사도
    d.metadata                             as metadata
  from documents d
  join posts p on p.id = d.post_id
  where (filter_post_id is null or d.post_id = filter_post_id)
    and (1 - (d.embedding <=> query_embedding)) >= match_threshold
  order by d.embedding <=> query_embedding asc              -- "거리"로 정렬해야 인덱스 사용
  limit least(match_count, 200);
$$;

위 함수의 반환값은 아래와 같다.

RETURNS TABLE (
  document_id   bigint,
  post_id       uuid,
  post_title    text,
  chunk_index   integer,
  chunk_content text,
  similarity    double precision, -- Postgres에서 float(=float8)과 동일
  metadata      jsonb
)

 

오버샘플링

만일 동일한 Post에서 가장 결과에 적합한 하나의 결과만 추출하고 싶다면 오버샘플링 기법을 활용해야 정확한 결과를 얻을 수 있다.

각 Post당 최대 5개를 뽑은 후, 이중 코사인 거리가 가장 낮은 하나를 추출하여 topK에 넣는다.

create or replace function match_posts_distinct_on(
  query_embedding vector(768),
  match_threshold float default 0.0,  -- 유사도 임계값 (-1..1)
  match_count     int   default 10,   -- 최종 K
  oversample      int   default 5     -- 중복 제거 대비 1차 후보 과추출 배수
)
returns table(
  document_id   bigint,
  post_id       uuid,
  post_title    text,
  chunk_index   integer,
  chunk_content text,
  similarity    double precision,
  metadata      jsonb
)
language sql
stable
as $$
  -- 1) 1차 후보 추출
  with candidates as (
    select
      d.id                                    as document_id,
      d.post_id                               as post_id,
      p.title                                 as post_title,
      d.chunk_index                           as chunk_index,
      d.content                               as chunk_content,
      d.metadata                              as metadata,
      d.embedding <=> query_embedding         as dist -- 코사인 "거리"
    from documents d
    join posts p on p.id = d.post_id
    where (1 - (d.embedding <=> query_embedding)) >= match_threshold
    order by d.embedding <=> query_embedding asc
    limit least(match_count * oversample, 1000) -- 5배 과추출
  ),
  -- 2) 중복 제거 : post_id를 기준으로 하나의 후보만 남김
  dedup as (
    select distinct on (post_id)
      document_id, post_id, post_title, chunk_index, chunk_content, metadata, dist
    from candidates
    order by post_id, dist asc, document_id asc
  )
  -- 3) 중복 제거된 목록에서 코사인 유사도를 기반으로 topK 반환
  select
    document_id,
    post_id,
    post_title,
    chunk_index,
    chunk_content,
    1 - dist as similarity,
    metadata
  from dedup
  order by dist asc
  limit match_count;
$$;