2025. 2. 19. 12:00ㆍ공부/Database
CREATE EXTENSION IF NOT EXISTS vector;
개요
postgres에 저장된 문서 정보를 조회하여 사용자의 질의를 처리하는 RAG 시스템을 문서 유사도 검색으로 구현했더니 질의에 존재하는 키워드들을 잘 가져오지 못 하는 문제가 발생했습니다.
예를 들어 사용자가 "모든 회의록의 내용을 요약해줘"라는 질의를 보내면 "회의록" 키워드가 포함된 문서들을 잘 가져오지 못 했습니다.
문서 유사도 검색으로는 한계를 느꼈고 키워드 기반 검색을 추가한 hybrid search를 구현하기로 결정했습니다.
이번 포스팅은 사용자의 자연어 질의를 분석하여 hybrid_search를 통해 필요한 문서를 조회하는 기능을 구현한 과정을 기록한 포스팅입니다.
postgres 환경 설정
저희 프로젝트는 확장성이 굉장히 좋은 postgres를 데이터베이스로 사용했기 때문에 굳이 여러 데이터베이스를 사용할 필요 없이 postgres 하나로 hybrid search를 구현할 수 있었습니다.
vector 기반 유사도 검색을 위한 pgvector extension과 키워드 기반 검색을 위해 postgres에서 기본적으로 지원해주는 tsvector를 사용하면 쉽게 구현할 수 있습니다.
postgres에 다음 2가지를 설치해야 합니다.
- vector 저장을 위한 pgvector extension
- 한국어 검색을 위한 mecab 형태소 분석기
저희는 postgres 16버전을 사용했고 이 버전의 docker image 기반으로 pgvector extension과 mecab 형태소 분석기를 설치했습니다.
<Dockerfile>
# 베이스 이미지를 postgres:16으로 설정
FROM postgres:16
# 시스템 패키지 업데이트 및 설치
RUN apt update -y && \
apt install -y --no-install-recommends \
wget \
build-essential \
postgresql-server-dev-16 \
automake \
unzip \
libmecab-dev \
git && \
\
# pgvector 설치
cd /tmp && \
wget --no-check-certificate https://github.com/pgvector/pgvector/archive/refs/tags/v0.8.0.tar.gz && \
tar -xvzf v0.8.0.tar.gz && \
cd pgvector-0.8.0 && \
make && \
make install && \
\
# mecab-ko 설치
cd /tmp && \
wget --no-check-certificate https://bitbucket.org/eunjeon/mecab-ko/downloads/mecab-0.996-ko-0.9.2.tar.gz && \
tar xvfz mecab-0.996-ko-0.9.2.tar.gz && \
cd mecab-0.996-ko-0.9.2 && \
./configure CC=gcc CXX=g++ CFLAGS="-m64" CXXFLAGS="-m64" && \
make && \
make install && \
\
# mecab-ko-dic 설치
cd /tmp && \
wget --no-check-certificate https://bitbucket.org/eunjeon/mecab-ko-dic/downloads/mecab-ko-dic-2.1.1-20180720.tar.gz && \
tar xvfz mecab-ko-dic-2.1.1-20180720.tar.gz && \
cd mecab-ko-dic-2.1.1-20180720 && \
./autogen.sh && \
./configure && \
make && \
make install && \
\
# textsearch_ko 설치
cd /tmp && \
git config --global http.sslVerify false && \
git clone https://github.com/i0seph/textsearch_ko.git && \
cd textsearch_ko && \
make USE_PGXS=1 && \
make USE_PGXS=1 install && \
cp ts_mecab_ko.sql /docker-entrypoint-initdb.d/ && \
\
# 불필요한 파일 및 패키지 제거
apt remove -y wget git build-essential automake unzip && \
apt autoremove -y && \
apt clean && \
rm -rf /var/lib/apt/lists/* /tmp/*
# 초기화 SQL 스크립트 복사
COPY services/postgres/init.sql /docker-entrypoint-initdb.d/init.sql
# PostgreSQL 컨테이너 기본 명령어 설정
CMD ["postgres"]
저기서 init.sql을 docker-entrypoint-initdb.d에 넣는 모습을 확인할 수 있는데 postgres가 시작될 때 init.sql을 실행하기 위해 넣어주었습니다.
init.sql은 별 것은 아니고 pgvector extension을 활성화하는 코드가 담겨있습니다.
<init.sql>
CREATE EXTENSION IF NOT EXISTS vector;
위 Dockerfile에 ts_mecab_ko.sql을 postgres가 시작될 때 실행하여 mecab 형태소 분석기를 설치하고 있습니다.
postgres에는 기본적으로 영어 형태소 분석기를 사용하기 때문에 mecab을 설치하지 않으면 조사를 잘 걸러내지 못 하기 때문에 한국어 키워드 검색을 위해서 mecab 설치는 필수입니다.
<영어 형태소 분석기>
'값':64 '개선':4,6,44 '검색':17,55,72 '검색이랑':21
'검색한다':57 '결정':91 '계획':2 '고민':58 '고정하면':37
'광범위하다':31 '구글':75 '구분이':22
<한국어 형태소 분석기>
검색':19,23,55,57,73 '결정':92 '계획':4 '고민':59 '고정':37
'광범위':32 '구글':76 '구분':24 '구현':79
mecab을 설치하고 postgres 내부에 활성화하면 to_tsvector의 첫 번째 인자로 'korean'을 넣어 한국어 형태소 분석을 할 수 있습니다.
hybrid search 구현
hybrid search는 아래 주소를 참고했습니다.
https://supabase.com/docs/guides/ai/hybrid-search
Hybrid search | Supabase Docs
Combine keyword search with semantic search to get both direct and contextual results.
supabase.com
위 주소에서 확인할 수 있는 hybrid_search function은 아래와 같습니다.
create or replace function hybrid_search(
query_text text,
query_embedding vector(512),
match_count int,
full_text_weight float = 1,
semantic_weight float = 1,
rrf_k int = 50
)
returns setof documents
language sql
as $$
with full_text as (
select
id,
-- Note: ts_rank_cd is not indexable but will only rank matches of the where clause
-- which shouldn't be too big
row_number() over(order by ts_rank_cd(fts, websearch_to_tsquery(query_text)) desc) as rank_ix
from
documents
where
fts @@ websearch_to_tsquery(query_text)
order by rank_ix
limit least(match_count, 30) * 2
),
semantic as (
select
id,
row_number() over (order by embedding <#> query_embedding) as rank_ix
from
documents
order by rank_ix
limit least(match_count, 30) * 2
)
select
documents.*
from
full_text
full outer join semantic
on full_text.id = semantic.id
join documents
on coalesce(full_text.id, semantic.id) = documents.id
order by
coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) * full_text_weight +
coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0) * semantic_weight
desc
limit
least(match_count, 30)
$$;
키워드 기반 FTS 구현
이전에 postgres 키워드 기반 full text search에 대해 정리한 포스팅이 있는데 참고하시는 것을 추천드립니다.
https://growth-coder.tistory.com/326
[PostgreSQL] PostgreSQL Full Text Search
tsvector @@ tsquerytsquery @@ tsvectortext @@ tsquerytext @@ textPostgreSQL 공식 문서를 정리한 포스팅입니다.https://www.postgresql.org/docs/current/textsearch-intro.html 12.1. Introduction12.1. Introduction # 12.1.1. What Is a Document? 12
growth-coder.tistory.com
위 hybrid_search 함수를 보면 tsvector 컬럼 이름으로 fts를 사용하고 있는 모습을 확인할 수 있습니다.
문서 내용은 to_tsvector 함수로 tsvector로 변환할 수 있는데 이 때 stored generated column을 생성하면 원본 문서 내용이 변경될 때 자동으로 tsvector도 변경되도록 구현할 수 있습니다.
ALTER TABLE page
ADD COLUMN textsearchable_index_col fts
GENERATED ALWAYS AS (to_tsvector('korean', coalesce(document, ''))) STORED;
테이블, 컬럼 이름의 경우 자유롭게 수정하시면 됩니다.
이제 fts 컬럼을 생성했고 이 컬럼을 기반으로 검색 쿼리를 작성하시면 됩니다.
위 hybrid_search 함수에서는 websearch_to_tsquery로 키워드 검색을 할 수 있는 쿼리를 생성하고 있습니다.
fts @@ websearch_to_tsquery(query_text)
벡터 기반 유사도 검색 구현
다음은 벡터 기반 유사도 검색을 구현할 차례입니다.
위 Dockerfile을 사용하셨다면 postgres 데이터베이스를 시작할 때 아래 쿼리가 실행되서 자동으로 pgvector extension이 활성화됩니다.
CREATE EXTENSION IF NOT EXISTS vector;
이제 pgvector 컬럼을 생성할 차례입니다.
위 hybrid_search에서 pgvector 컬럼 이름을 embedding으로 사용했기 때문에 동일하게 사용하겠습니다.
ALTER TABLE page ADD COLUMN embedding vector(384)
vector의 파라미터에는 벡터 차원을 넣어주면 됩니다.
제가 사용한 임베딩 모델은 384차원이라 384를 넣어주었습니다.
임베딩의 경우 애플리케이션에서 직접 임베딩을 생성한 뒤 vector 컬럼에 넣어주는 방식을 선택했습니다.
위 hybrid_search에서는 512 벡터를 입력으로 받고 있는데 저는 이 차원을 384로 바꾸겠습니다.
그리고 벡터끼리 유사도를 구하면 됩니다.
위에서는 내적 방식을 사용하고 있네요.
embedding <#> query_embedding
pgvector가 지원해주는 거리구하는 함수는 다음과 같습니다.
- <-> - L2 distance
- <#> - (negative) inner product
- <=> - cosine distance
- <+> - L1 distance (added in 0.7.0)
- <~> - Hamming distance (binary vectors, added in 0.7.0)
- <%> - Jaccard distance (binary vectors, added in 0.7.0)
자세한 내용은 github README를 확인해주세요.
https://github.com/pgvector/pgvector
GitHub - pgvector/pgvector: Open-source vector similarity search for Postgres
Open-source vector similarity search for Postgres. Contribute to pgvector/pgvector development by creating an account on GitHub.
github.com
필요하다면 적절히 변경해주시면 되겠습니다.
이제 키워드 검색과 유사도 검색 준비가 완료되었습니다.
프로젝트에 구조에 맞게 적절히 hybrid_search를 수정해주세요.
hybrid_search 분석
hybrid_search가 어떠한 방식으로 키워드 검색과 유사도 검색을 사용하는지 알아봅시다.
우선 위에서는 CTE를 만들어서 서브 쿼리의 결과를 재활용하고 있습니다.
FTS CTE부터 확인해봅시다.
with full_text as (
select
id,
-- Note: ts_rank_cd is not indexable but will only rank matches of the where clause
-- which shouldn't be too big
row_number() over(order by ts_rank_cd(fts, websearch_to_tsquery(query_text)) desc) as rank_ix
from
documents
where
fts @@ websearch_to_tsquery(query_text)
order by rank_ix
limit least(match_count, 30) * 2
),
키워드 기반 검색을 한 결과를 바탕으로 순위를 재정렬하고 있습니다.
order by ts_rank_cd(fts, websearch_to_tsquery(query_text)) desc
ts_rank_cd는 커버 밀도 알고리즘으로 키워드와 tsvector 간 유사도를 반환합니다.
유사한 순서대로 순위를 재정렬하고 있습니다.
다음은 벡터 검색 CTE입니다.
semantic as (
select
id,
row_number() over (order by embedding <#> query_embedding) as rank_ix
from
documents
order by rank_ix
limit least(match_count, 30) * 2
)
postgres에서 제공해주는 연산으로 유사도를 측정한 뒤 역시 유사한 순서대로 순위를 재정렬하고 있습니다.
이렇게 각 검색에서 유사도 순위를 구했습니다.
다음은 각 순위를 활용하여 다시 한 번 문서의 순위를 재정렬합니다.
order by
coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) * full_text_weight +
coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0) * semantic_weight
desc
위에서 사용한 공식은 다음과 같습니다.

순위를 분모에 배치하여 순위가 낮을수록 (숫자가 클 수록) 낮은 점수를 부여하고 있습니다.
k 값은 상수로 낮은 순위일수록 점수가 너무 떨어지는 현상을 방지하고 있습니다.
이렇게 hybrid_search에 대해 알아보았습니다.
요약을 해보자면 다음과 같습니다.
- 문서들의 키워드 검색 순위를 구한다.
- 문서들의 유사도 검색 순위를 구한다.
- 두 순위를 합쳐서 순위를 재정렬한다.
이제 이 hybrid_search를 사용하면 keyword를 잘 인식하지 못 하던 문제를 어느정도 해결할 수 있습니다.
이제 hybrid_search를 구현하고 싶으신 분들은 여기까지만 보셔도 괜찮습니다.
다음은 저희 프로젝트에서 RAG 시스템이 사용자의 질의를 처리하기 위한 hybrid_search를 구현한 과정입니다.
저희 프로젝트의 특성에 맞춰 커스텀한 과정입니다.
RAG 기반 hybrid_search
tsquery 변환 수정
첫 번째로 수정한 부분은 websearch_to_tsquery입니다.
websearch_to_tsquery의 경우 형태소를 분석해서 모두 &로 연결합니다.
아래는 "모든 회의록을 요약해줘"를 websearch_to_tsquery로 변환한 결과입니다.
SELECT websearch_to_tsquery('korean', '모든 회의록을 요약해줘');
websearch_to_tsquery
----------------------------
'모든' & '회의록' & '요약'
(1 row)
일반 검색에는 유용하겠지만 자연어 질문의 경우 적합하지 않습니다.
핵심 키워드인 "회의록"과 함께 AI에게 요구하는 "모든"과 "요약" 같은 키워드가 모두 포함되어 있어야 하기 때문입니다.
실제로 websearch_to_tsquery로 검색을 했을 때 자연어 질의에 대해 키워드 기반 검색은 대부분 결과를 조회하지 못 했습니다.
핵심 키워드를 미리 뽑아내서 전달해주는 것이 가장 좋겠지만 그 정도의 세밀한 작업은 postgres query로 할 수 없고 애플리케이션에서 별도로 처리해야 합니다.
하지만 저는 최대한 postgres에서 처리하고 싶었기 때문에 단순하게 '&' 연산자를 모두 '|'로 바꾸어 주었습니다.
to_tsquery(replace(websearch_to_tsquery('korean', query_text)::text, '&', '|'))
이렇게 되면 조회 결과가 너무 많아 검색 성능이 좋지 않을 것 같지만 이후 검색 결과를 바탕으로 순위를 재정렬 할 것이기 때문에 속도는 조금 느려질 수 있어도 검색 성능 자체는 크게 저하되지 않을 것이라고 판단했습니다.
만약 AI 질의가 아닌 단순 일반 검색이라면 websearch_to_tsquery를 그대로 사용해도 좋을 것 같습니다.
키워드 기반 검색에서 제목에 높은 가중치
저희 프로젝트에서 문서는 제목과 내용을 서로 다른 컬럼에 저장하고 있습니다.
일반적으로 내용보다는 제목에 핵심 키워드들이 존재할 것이라고 판단했고 제목에 높은 가중치를 주었습니다.
우선 page 테이블에서 제목과 내용에 대한 tsvector를 생성하는 stored generated column을 생성했습니다.
<page.entity.ts>
// dodcument 키워드 추출
@Column({
generatedType: "STORED",
type: "tsvector",
asExpression: `to_tsvector('korean', COALESCE(document, ''))`,
nullable: true,
})
documentFts: string;
// title 키워드 추출
@Column({
generatedType: "STORED",
type: "tsvector",
asExpression: `to_tsvector('korean', COALESCE(title, ''))`,
nullable: true,
})
titleFts: string;
그리고 검색을 할 때 title tsvector와 document tsvector를 바탕으로 결과를 가져올 수 있도록 OR 조건으로 묶었습니다.
where(
"titleFts" @@ to_tsquery(replace(websearch_to_tsquery('korean', query_text)::text, '&', '|')) OR
"documentFts" @@ to_tsquery(replace(websearch_to_tsquery('korean', query_text)::text, '&', '|'))
)
키워드 기반 검색을 해서 결과를 가져온 뒤 순위를 재정렬 해야 하는데 문서 내용보다는 제목에 더욱 중요한 키워드가 담길
것을 고려하여 제목에 높은 가중치를 주었습니다.
tsvector에 A, B, C, D로 가중치를 부여할 수 있습니다. (A가 가장 높음)
title tsvector에는 가장 높은 A를 주었고 docment tsvector에는 가장 낮은 D를 주었습니다.
row_number() over(order by ts_rank_cd(setweight("titleFts", 'A') || setweight("documentFts", 'D'), to_tsquery(replace(websearch_to_tsquery('korean', query_text)::text, '&', '|'))) desc) as rank_ix
LLM 프롬프트 수정
기존에는 검색한 문서들을 단순하게 new line으로 연결해주었습니다.
이렇게 LLM에게 문서들을 전달했더니 각 문서들을 구별하지 못 했습니다.
아래는 2월 10일 회의록을 요약해달라고 요구한 결과입니다

.
실제 2월 10일에 AI 기능 구체화에 대한 회의는 이루어지지 않았습니다.
AI가 이렇게 인식한 이유는 문서들을 구별하지 못 했기 때문입니다.
"2월", "10일" "회의록"과 유사한 문서에는 다른 날짜 회의록도 함께 가져오고 단순하게 new line으로 연결했더니 모든 회의록을 하나의 문서로 인식한 것이었습니다.
문서의 구분을 위해 각 문서마다 제목, 내용이 무엇인지 알려주는 프롬프트와 각 문서들을 '====='으로 구분하였습니다.
const docsContent = retrievedDocs
.map((doc) => `제목 : ${doc.title}\n내용 : ${doc.document}\n==========\n`)
.join('\n');
이제 문서들을 잘 구분하고 조회한 여러 개의 문서들 중 2월 10일 회의록 문서만 요약을 해주게 되었습니다.

'공부 > Database' 카테고리의 다른 글
[PostgreSQL] postgres GIN 인덱스 (1) | 2025.02.21 |
---|---|
[PostgreSQL] postgres SCAN 종류 (Bitmap SCAN) (0) | 2025.02.12 |
[PostgreSQL] PostgreSQL Full Text Search (0) | 2025.02.03 |
[redis] node.js 환경에서 redis 분산 락 구현하기 (0) | 2025.01.10 |
[redis] redis의 transaction (0) | 2025.01.09 |