도서

벡터 데이터베이스

ioh'sDeveloper 2026. 10. 4. 17:50
반응형

RAG를 만들기 전에, 검색할 데이터를 어떻게 다룰지 생각하게 한 책

한빛미디어 서평단 <나는리뷰어다> 활동을 위해 책을 제공받아 작성한 서평입니다.


RAG를 안다고 생각했는데, 벡터를 어디에 두는지는 설명하기 어려웠다

백엔드 개발자인 내가 왜 LLM과 RAG, 벡터 데이터베이스까지 공부하는지 의아할 수 있다.

나도 처음에는 AI를 개발 도구로 잘 쓰면 충분하다고 생각했다. 그런데 쓰다 보니 모델이 답을 만드는 과정뿐 아니라, 질문에 필요한 자료를 어디서 어떻게 찾아오는지가 궁금해졌다. 그 과정은 내가 익숙한 데이터 저장·검색·갱신의 문제와 맞닿아 있었다.

 

RAG를 설명할 때 나는 보통 “질문과 관련된 문서를 검색해서 LLM에 함께 전달한다”라고 말해 왔다. 흐름만 보면 간단하다. 문서를 잘게 나누고, 임베딩을 만들고, 벡터 데이터베이스에서 비슷한 내용을 찾은 뒤, 검색 결과를 프롬프트에 넣으면 된다. 처음에는 이 정도로 이해해도 충분해 보였다.

그런데 막상 시스템을 만든다고 생각하면 질문이 늘어난다. 문서는 어느 크기로 잘라야 할까? 벡터와 원문은 같은 곳에 저장해야 할까? 검색 결과에 날짜나 카테고리 조건을 걸면 어떻게 될까? 임베딩 모델을 바꾸면 이미 저장한 벡터는 어떻게 해야 할까? 검색 결과가 그럴듯해 보이는데 실제로는 필요한 문서를 놓치고 있다면 어디부터 확인해야 할까?

 

나는 관계형 데이터베이스의 테이블, 인덱스, 트랜잭션에는 익숙한 편이다. 반면 벡터 검색은 “임베딩을 넣으면 비슷한 결과가 나온다”는 수준에서 이해하고 있었다. 그래서 『벡터 데이터베이스』의 목차가 눈에 들어왔다. 이 책은 벡터 검색의 개념을 설명한 뒤 FAISS의 인덱스 구조를 살펴보고, SQLite와 PostgreSQL 위에서 검색 시스템과 RAG 시스템을 차례로 설계한다. 새로운 데이터베이스 제품 하나를 익히는 책이라기보다, 의미를 담은 데이터를 기존 애플리케이션의 저장·검색 구조 안에 어떻게 넣을지 생각하게 하는 책이었다.

 

읽고 나서 가장 크게 달라진 점은 RAG를 바라보는 순서다. 이전에는 LLM이 답을 얼마나 잘 만드는지에 먼저 눈이 갔다. 이제는 그 전에 “무슨 내용을 어떤 단위로 저장했고, 어떤 조건으로 찾아서, 어떤 맥락과 함께 전달했는가”를 먼저 묻게 된다.


책의 흐름: 임베딩에서 시작해 검색 시스템까지

책은 9장으로 구성되어 있다. 1장은 벡터 데이터베이스가 왜 필요한지 설명하고, 2장은 임베딩을 다룬다. 3장에서는 FAISS를 통해 최근접 이웃 검색과 인덱스의 내부를 살펴본다. 이후 4장부터 8장까지는 레딧 콘텐츠 검색, arXiv 논문 검색, 로컬 RAG, 과학 논문 RAG, 개인 대화 기록 검색으로 대상과 요구사항을 바꿔 가며 시스템을 만든다. 마지막 9장은 SQL처럼 벡터 연산을 표현하는 벡터 쿼리 언어를 제안한다.

내게는 이 구성이 좋았다. 앞부분에서 배운 용어가 뒤 프로젝트에서 다시 등장하기 때문이다. 2장에서 읽은 “인덱싱과 질문에 같은 임베딩 모델을 써야 한다”는 원칙은 4장의 수집·검색 경로를 읽을 때 설계 규칙이 된다. 3장의 HNSW와 IVF는 5장의 pgvector 인덱스를 이해할 때 다시 쓰인다. 4장에서 마주친 메타데이터 필터링 문제는 6장의 하이브리드 검색과 9장의 쿼리 문법까지 이어진다.

그래서 이 책을 읽는 동안에는 각 장을 완전히 별개의 예제로 보기보다, 같은 검색 문제를 데이터와 도구가 바뀔 때마다 다시 푸는 과정으로 보는 편이 더 재미있었다.


1장: 검색이 ‘같은 단어 찾기’에서 ‘가까운 의미 찾기’로 바뀐다

1장은 책 전체의 지도 같다. 전통적인 데이터베이스는 제목, 작성자, 날짜, 카테고리처럼 구조가 분명한 데이터를 잘 다룬다. 텍스트도 검색할 수 있지만, 기본적으로는 입력한 표현과 저장된 표현이 얼마나 일치하는지를 본다. 사용자가 “돈을 돌려받으려면 어떻게 해야 하나요?”라고 물었을 때 문서 제목이 ‘환불 및 반품 정책’이라면, 단어가 다르다는 이유로 관련 문서를 놓칠 수 있다.

 

임베딩은 이 간극을 메우기 위한 표현이다. 텍스트나 이미지 같은 데이터를 숫자로 이루어진 벡터에 옮기고, 의미가 가까운 것들이 벡터 공간에서도 가깝게 놓이도록 한다. 그러면 검색은 문자열이 일치하는지 확인하는 작업에서 질문 벡터와 가까운 문서 벡터를 찾는 작업으로 바뀐다.

물론 이것이 키워드 검색을 없애 준다는 뜻은 아니다. 정확한 상품 코드, 오류 메시지, 사람 이름을 찾을 때는 문자 그대로의 일치가 더 중요할 수 있다. 책을 읽으며 오히려 “무엇을 의미로 찾고 무엇을 조건으로 걸러야 하는가”가 중요하다고 느꼈다. 문서의 내용은 벡터로 비교하더라도, 작성일이나 접근 권한, 카테고리 같은 값은 여전히 일반적인 데이터베이스 조건으로 다뤄야 한다.

백엔드 개발자의 눈으로 보면 이 부분이 자연스럽다. 실제 서비스에서 검색 결과는 “비슷한 문서 상위 10개”만으로 끝나지 않는다. 현재 사용자에게 보여 줄 수 있는 문서여야 하고, 특정 기간에 작성되었거나 특정 상태여야 할 수도 있다. 벡터가 의미를 표현한다면, 기존 데이터베이스의 컬럼과 제약은 그 의미를 서비스 안에서 올바르게 사용하는 조건을 표현한다.

 

단어가 일치하는지 확인하는 검색과 의미의 거리를 비교하는 검색은 무엇을 다르게 볼까. 이 차이를 아래 그림으로 정리했다.


2장: 임베딩을 알면 모델 교체 비용까지 보인다

가장 많이 밑줄을 그은 부분은 2장이었다. Word2Vec에서 Doc2Vec, 트랜스포머, 전용 임베딩 모델로 이어지는 흐름을 따라가며 벡터가 왜 단순한 숫자 배열 이상인지 다시 생각하게 됐다.

 

Word2Vec을 설명할 때 자주 등장하는 예가 king - man + woman ≈ queen이다. 처음 보면 재미있는 수학적 묘기처럼 느껴진다. 그런데 책의 흐름 안에서 보면 중요한 것은 계산 결과 하나가 아니다. 단어를 학습한 벡터 공간에서 위치와 방향이 어느 정도 의미 관계를 담을 수 있다는 점이다. 비슷한 문맥에 나타나는 단어가 가까운 표현을 갖게 된다는 생각이 이후 의미 기반 검색의 출발점이 된다.

 

여기서 한 단계 더 흥미로웠던 것은 제로샷 분류였다. 분류할 레이블을 문장으로 표현하고 입력 문장과 같은 벡터 공간에 놓은 다음, 가장 가까운 레이블을 고르는 방식이다. 별도로 해당 분류 작업을 학습시키지 않고도 기존 임베딩 공간의 의미 관계를 활용한다. 이 부분을 읽으니 “검색”이라는 기능이 단순히 문서를 찾는 데만 쓰이지 않는다는 사실이 선명해졌다. 분류 문제를 유사도 비교 문제로 바꿀 수도 있는 것이다.

트랜스포머를 다시 정리한 것도 도움이 됐다. 내 처음 메모에는 트랜스포머를 추론과 거의 같은 뜻으로 적어 두었는데, 정확히는 어텐션을 바탕으로 문맥에 따라 표현을 다루는 신경망 구조다. 임베딩은 그 구조 안팎에서 쓰이는 표현이고, 검색 시스템에서는 특히 문서와 질문을 비교 가능한 공간에 놓는 수단이 된다. 이 구분을 해 두니 생성 모델과 임베딩 모델의 역할도 덜 섞여 보였다.

 

그리고 이 장에서 실무적으로 가장 오래 남은 원칙이 있다. 저장할 문서를 임베딩할 때와 검색 질문을 임베딩할 때는 호환되는 모델을 써야 한다. 서로 다른 모델이 만든 벡터는 숫자 배열의 길이가 같더라도 같은 의미 공간이라고 볼 수 없다. 따라서 임베딩 모델을 교체하는 일은 설정 파일의 모델명 한 줄을 바꾸는 작업으로 끝나지 않는다. 기존 문서를 다시 임베딩하고, 경우에 따라 벡터 컬럼의 차원과 인덱스까지 재구성해야 한다.

모델을 고를 때 정확도나 속도만 볼 수 없겠다고 느낀 이유도 여기에 있다. 한 번 서비스 데이터가 쌓이면 모델 선택이 저장 구조와 재색인 비용으로 이어진다.


3장: FAISS를 읽으며 ‘빠른 검색’의 대가를 생각했다

벡터가 만들어졌다면 다음 질문은 어떻게 빨리 찾느냐다. 데이터가 적을 때는 모든 벡터를 질문과 비교할 수 있다. 하지만 데이터가 커지면 모든 항목을 매번 훑는 방식은 부담이 된다. 3장은 FAISS를 중심으로 이 문제를 설명한다.

Flat 인덱스는 모든 벡터를 비교하므로 이해하기 쉽고 정확하다. IVF는 벡터를 여러 묶음으로 나눈 뒤 질문과 가까운 묶음부터 살펴본다. HNSW는 가까운 벡터들의 연결을 그래프로 구성해 탐색 범위를 좁힌다. 양자화는 벡터가 차지하는 메모리를 줄이는 방향의 선택이다. 이름만 외울 때는 모두 “검색을 빠르게 해 주는 인덱스”처럼 보였는데, 책을 읽고 나니 각 방식이 아끼는 자원과 포기하는 것이 다르다는 점이 보였다.

특히 ANN, 즉 근사 최근접 이웃 검색을 이해하는 방식이 달라졌다. 예전에는 근사 검색을 막연히 “대충 비슷한 것을 찾는 방법”이라고 생각했다. 실제 설계에서는 검색 시간, 메모리, 인덱스를 만드는 시간, 정답을 얼마나 놓치는지를 함께 본다. 빠른 결과를 얻는 대신 가장 가까운 이웃 일부를 놓칠 수 있고, 그 정도는 데이터의 분포와 인덱스 설정에 따라 달라진다.

이 장에서 얻은 실무적인 감각은 작은 데이터셋에 복잡한 인덱스를 먼저 얹을 이유가 없다는 것이다. 개인 문서 수백 개나 수천 개라면 정확 검색으로 시작해도 충분할 수 있다. 이후 데이터가 늘고 실제 검색 지연이 문제가 되었을 때 IVF나 HNSW를 비교하면 된다. 인덱스를 만들었다는 사실보다, 같은 데이터에서 검색 속도와 재현율을 함께 측정했는지가 더 중요하다.

직접 비교해 본 FAISS 검색

책의 인덱스 설명을 확인하려고 Python 3.11 환경에서 64차원 난수 벡터 1만 개와 질문 벡터 100개로 정확 검색인 Flat, 그리고 근사 검색인 IVF와 HNSW를 비교했다. Flat이 반환한 상위 5개를 기준으로 다른 인덱스가 그중 몇 개를 찾았는지 recall@5를 계산했다. 단일 질문의 처리 시간을 세 번 측정한 중앙값도 함께 기록했다.

설정 recall@5 질문당 시간
Flat 정확 검색 1.000 0.1549ms
IVF nprobe=1 0.078 0.0132ms
IVF nprobe=16 0.574 0.0406ms
IVF nprobe=100 1.000 0.1862ms
HNSW efSearch=8 0.380 0.0275ms
HNSW efSearch=128 0.952 0.1673ms

 

원본 FAISS 실행 로그

 

vector-search-experiments/results/faiss_terminal.txt at main · iohyeon/vector-search-experiments

FAISS·SQLite·pgvector로 벡터 검색의 재현율, 원본 ID 연결, 의미 검색을 검증한 재현 가능한 실험 - iohyeon/vector-search-experiments

github.com

 

 

IVF에서 탐색할 묶음의 수인 nprobe를 늘리자 놓치는 정답은 줄었지만 시간은 늘었다. HNSW도 탐색 범위를 넓힐수록 재현율이 올랐다. 특히 이 규모에서는 재현율을 높인 HNSW가 Flat보다 빠르지 않았고, IVF의 nprobe=100도 Flat보다 오래 걸렸다. 3장을 읽으며 생각한 “작은 데이터에서 인덱스부터 복잡하게 만들 필요가 있을까?”라는 질문을 수치로 다시 보게 됐다. 다만 입력은 실제 문장 임베딩이 아닌 균등 난수 벡터다. 이 시간을 서비스 성능으로 일반화할 수는 없고, 실제 데이터와 질문으로 재측정해야 한다.


4장: 가장 백엔드답게 읽힌 SQLite 의미 검색

4장은 레딧 콘텐츠를 SQLite에 저장하고 sqlite-vss로 의미 기반 검색을 만드는 장이다. 내게는 이 책에서 가장 실용적으로 다가온 부분이었다. 벡터 검색 자체도 흥미로웠지만, 그 검색을 기존 데이터와 연결할 때 생기는 문제가 더 기억에 남았다.

 

구조는 두 테이블로 나뉜다. 게시물의 원문과 메타데이터는 posts에, 검색용 벡터 인덱스는 posts_vss 가상 테이블에 둔다. 두 테이블을 posts.id = posts_vss.rowid로 연결한다. 그림으로 보면 단순하다. 그러나 이 연결은 애플리케이션이 지켜야 하는 중요한 약속이다.

기존 게시물을 갱신하면서 INSERT OR REPLACE를 사용하면 SQLite가 충돌한 행을 삭제하고 다시 삽입하는 방식으로 처리할 수 있다. 자동으로 부여된 rowid가 달라지면 원본 게시물과 벡터 인덱스의 연결이 어긋난다. 검색 인덱스에는 벡터가 있는데 원문을 찾을 수 없거나, 새 원문은 저장됐는데 검색에서는 사라지는 상황이 생길 수 있다. 그래서 책은 원본 행의 ID를 유지하는 UPSERT를 사용한다.

이 대목은 벡터 검색에서 흔히 놓치기 쉬운 문제를 보여 준다. 유사도 점수가 아무리 정확해도 검색 결과가 원본 데이터의 올바른 행을 가리키지 못하면 시스템은 신뢰할 수 없다. 벡터 인덱스와 콘텐츠 저장소 사이의 식별자 무결성이 검색 품질의 일부인 셈이다.

메타데이터 필터링에도 제약이 있다. sqlite-vss의 검색 단계는 게시물의 카테고리나 점수를 알지 못한다. 그래서 벡터 기준 상위 후보를 요청한 다음 SQL로 조건에 맞는 것만 거른다. 책에서 사용하는 방법이 ‘초과 조회 후 필터링’이다. 결과 10개가 필요하면 후보를 더 넉넉히 가져온다.

 

문제는 필터가 매우 좁을 때다. 예를 들어 전체 게시물 중 특정 커뮤니티 글이 1%뿐이라면 상위 후보 100개를 가져와도 원하는 글이 몇 개 없을 수 있다. 사용자는 10개를 요청했지만 2개만 볼 수도 있다. LIMIT을 키웠는데도 결과가 적다면 검색 알고리즘이 틀렸다고만 생각할 일이 아니다. 벡터 검색과 필터가 어떤 순서로 적용되는지 확인해야 한다.

마지막으로 증분 인덱싱이 인상적이었다. 데이터를 한 번에 모아 넣는 개인 도구라면 인덱스 전체를 다시 만드는 방법이 단순하다. 그러나 새 글이 계속 들어오는 환경에서는 글 하나가 추가될 때 그 벡터도 곧바로 검색 가능해야 한다. 이때 원본 테이블의 UPSERT와 같은 ID의 벡터 갱신을 하나의 트랜잭션으로 묶는 설계가 나온다. 원본만 최신이고 검색 인덱스는 예전 상태인 시간을 줄이려는 것이다.

직접 확인한 SQLite의 ID 연결

4장에서 눈에 들어온 rowid 문제는 일반 SQLite 테이블 두 개로 최소 재현했다. 하나는 원본 게시물, 다른 하나는 벡터 인덱스의 ID를 대신 보여 주는 테이블이다. 처음에는 원본과 인덱스가 모두 ID 1을 가리켰다. 같은 게시물을 INSERT OR REPLACE로 갱신하자 원본 ID만 2로 바뀌었고, 인덱스에는 1이 남았다. ON CONFLICT DO UPDATE를 사용한 경우에는 원본 ID가 1로 유지됐다.

 

원본 SQLite ID 연결 실행 로그

 

vector-search-experiments/results/sqlite_terminal.txt at main · iohyeon/vector-search-experiments

FAISS·SQLite·pgvector로 벡터 검색의 재현율, 원본 ID 연결, 의미 검색을 검증한 재현 가능한 실험 - iohyeon/vector-search-experiments

github.com

 

유사도 점수가 아무리 높아도 인덱스가 원문을 찾아갈 수 없다면 검색 결과는 쓸 수 없다. 이 실습에서는 sqlite-vss 확장을 설치하거나 벡터 검색을 실행하지 않았다. 확인한 범위는 SQLite의 갱신 방식에 따라 ID 연결이 어떻게 달라지는지까지다. 실제 서비스에서는 원본과 벡터 인덱스의 갱신을 한 트랜잭션으로 묶는 문제도 함께 다뤄야 한다.


5장: pgvector가 반가웠던 이유는 SQL보다 데이터 관리에 있다

5장은 arXiv 논문을 모아 PostgreSQL과 pgvector로 검색하는 시스템을 다룬다. 논문 메타데이터를 받고, PDF를 내려받아 텍스트를 추출하고, 청크로 나누어 임베딩한 뒤 검색한다. 이 장에서 내가 좋았던 부분은 기술 스택을 선택한 이유다.

 

벡터를 검색하려면 전용 벡터 데이터베이스부터 도입해야 한다고 생각하기 쉽다. 하지만 이미 PostgreSQL을 쓰는 서비스라면 다른 선택지가 있다. 논문의 제목, 저자, 발행일, 처리 상태, 텍스트 청크와 벡터를 한 데이터베이스 안에서 관리하는 것이다. 이 경우 벡터 유사도와 일반적인 SQL 필터를 함께 표현할 수 있고, 데이터의 추가·수정·삭제를 기존 트랜잭션 안에서 다룰 수 있다.

 

나는 특히 ‘동기화해야 할 저장소가 하나 줄어든다’는 점에 공감했다. 벡터는 별도 DB에, 문서 메타데이터는 RDB에 둔다면 문서를 삭제하거나 수정할 때 양쪽을 함께 갱신해야 한다. 한쪽 작업만 성공하면 검색 결과가 이미 삭제된 문서를 가리킬 수 있다. PostgreSQL에 pgvector를 붙이면 적어도 이 예제의 데이터 범위에서는 원문과 벡터를 같은 데이터 관리 규칙 안에 둘 수 있다.

그렇다고 PostgreSQL이 언제나 더 낫다는 결론은 아니다. 데이터 규모, 검색량, 운영 환경에 따라 선택은 달라진다. 다만 책이 보여 준 개인,소규모 논문 검색 시스템에서는 익숙한 데이터베이스를 확장하는 접근이 설득력 있었다.

 

이 장에서 가장 오래 고민한 부분은 청크 크기다. 내 메모에도 “청킹할 때 청크 간격을 어떻게 정하지?”라는 질문을 남겼다. 책은 학술 논문을 대상으로 512~1,024토큰 정도의 청크와 일부 중첩을 이야기한다. 너무 짧으면 문맥이 사라지고, 너무 길면 질문과 무관한 내용이 한 청크에 섞인다. 중첩은 경계에 걸친 문장이 끊기는 문제를 줄이지만, 그만큼 저장할 청크와 임베딩 비용도 늘린다.

여기서 책의 권장 크기를 그대로 복사하면 안 되겠다는 생각도 들었다. 청크의 길이와 임베딩 모델이 실제로 읽을 수 있는 입력 길이는 별개다. 텍스트는 1,000토큰을 저장했는데 모델이 앞부분만 임베딩한다면, 뒷부분에 있는 핵심 내용은 검색 벡터에 반영되지 않을 수 있다. 내 노트에도 책의 청크 설정과 사용 모델의 입력 한도가 맞는지 확인해야 한다고 표시해 두었다. 뒤의 논문 검색 실습에서 모델 설정과 청크별 길이를 직접 점검했다.

 

5장을 따라갈 독자에게는 한 가지 기대치 조정도 필요하다. 이 장의 본문은 모든 메서드를 완성한 실행 가이드라기보다 아키텍처와 스키마를 보여 주는 설계도에 가깝다. 메서드 골격만 제시된 부분이 있어 책의 코드만 순서대로 입력한다고 전체 논문 검색 시스템이 완성되지는 않는다. 그래서 나는 이 설계를 출발점 삼아 작은 의미 검색 실험과 실제 논문 검색 시스템을 차례로 만들어 봤다.

pgvector로 확인한 의미 검색

의미 검색은 Docker 컨테이너로 실행한 PostgreSQL과 pgvector에서 확인했다. 실습용으로 작성한 고객 지원 문서 12개를 사용했다. 문서마다 두 문단으로 나누어 총 24개 청크를 만들고, 책에도 등장하는 all-MiniLM-L6-v2 모델로 384차원 임베딩을 생성했다. FastEmbed의 ONNX 실행기로 모델을 돌렸으며, 제목·카테고리·본문·벡터를 PostgreSQL에 저장했다. 질문도 같은 모델로 임베딩한 뒤 pgvector의 코사인 거리 연산자로 검색했다. 문서가 적어서 ANN 인덱스는 만들지 않고 모든 청크를 비교했다.

 

첫 질문은 “I want my money back.”이었다. 문서에는 이 표현 대신 refund가 쓰였다. 입력 단어를 모두 요구하도록 구성한 PostgreSQL plainto_tsquery 키워드 비교에서는 결과가 0건이었다. 벡터 검색은 Check refund status(0.388), Refund policy(0.379), Damaged item on arrival(0.348)의 관련 문단을 상위에 올렸다. 두 번째 질문 “Where can I get a receipt for my order?”에는 billing 카테고리 조건을 걸었다. 같은 키워드 비교는 0건이었지만, 벡터 검색에서는 Download an invoice의 두 청크가 1위(0.557)와 2위(0.514)에 나왔다. receipt와 invoice처럼 표현이 달라도 연결되는 문서를 찾는다는 설명을 실제 결과로 확인했다.

 

원본 pgvector 의미 검색 실행 로그

 

vector-search-experiments/results/pgvector_terminal.txt at main · iohyeon/vector-search-experiments

FAISS·SQLite·pgvector로 벡터 검색의 재현율, 원본 ID 연결, 의미 검색을 검증한 재현 가능한 실험 - iohyeon/vector-search-experiments

github.com

 

여기서 키워드 검색 전체가 의미 검색보다 못하다고 결론 내릴 수는 없다. 비교에 사용한 키워드 방식은 모든 입력 단어를 요구했고 문서도 12개뿐이다. 실제 서비스라면 동의어 처리, 키워드 검색 설정, 두 검색 방식의 결합까지 평가해야 한다. 코사인 점수 0.388이나 0.557도 정답일 확률이 아니다. 관련 문서가 어떤 점수대에 놓이는지는 모델과 데이터에 따라 달라지므로, 실제 질문과 정답 문서로 평가한 뒤 기준을 정해야 한다.

예상하지 못한 문제는 두 번째 질문에서 같은 문서의 청크 두 개가 결과 1위와 2위를 차지한 일이었다. 검색 엔진에는 별개의 청크지만 사용자에게는 같은 문서가 두 번 보일 수 있다. 5장의 논문 검색에서 청크로 찾되 논문 단위로 보여 주려는 이유가 이 결과와 연결됐다. 사용자 화면에서는 문서 ID로 결과를 묶고, 대표 청크나 대표 점수를 정하는 단계가 필요하겠다.

이 작은 실험은 의미 검색까지다. Ollama나 다른 LLM으로 답을 생성하는 RAG는 붙이지 않았다. 그래도 문서를 나누고, 문서와 질문을 같은 모델로 임베딩하고, 메타데이터 조건과 함께 검색해 원문 청크를 돌려주는 경로는 확인했다. 이 경로가 안정적으로 동작해야 생성 단계에서도 답이 어떤 원문에서 왔는지 추적할 수 있겠다고 느꼈다.

실습 코드와 원본 실행 결과는 공개 저장소에 정리했다.

 

GitHub - iohyeon/vector-search-experiments: FAISS·SQLite·pgvector로 벡터 검색의 재현율, 원본 ID 연결, 의미 검

FAISS·SQLite·pgvector로 벡터 검색의 재현율, 원본 ID 연결, 의미 검색을 검증한 재현 가능한 실험 - iohyeon/vector-search-experiments

github.com

 

책의 설계를 실제 arXiv 논문 검색으로 확장해 보기

고객 지원 문서 12개로는 5장이 다루는 PDF 처리, 논문 단위 결과, 인덱스와 필터의 상호작용을 확인하기 어려웠다. 그래서 책의 아키텍처를 바탕으로 pgvector-arxiv-search를 별도로 만들었다. arXiv 논문 225편을 모았고, 그중 30편은 PDF 본문까지, 나머지 195편은 초록을 색인했다. PostgreSQL 16과 pgvector 0.8.7에 청크 2,147개를 저장했다. all-MiniLM-L6-v2로 384차원 벡터를 만들고, 벡터 검색·PostgreSQL 전문 검색·메타데이터 필터를 조합했다. 검색된 청크는 논문 ID로 묶어 대표 구절, 섹션, 쪽 번호와 함께 보여 주도록 했다. 작은 실습에서 같은 문서의 청크 두 개가 상위 결과를 차지했던 문제에 대한 직접적인 답이었다.

 

첫 번째로 부딪힌 것은 책의 청크 권장값과 모델 입력 한도가 다르다는 점이었다. 5장의 512~1,024토큰 설정을 그대로 적용하려 했지만, 사용한 모델의 입력 한도는 256토큰이었다. 긴 청크를 저장해도 뒤쪽 내용은 벡터에 반영되지 않을 수 있다. 그래서 문자 수가 아닌 실제 모델 토크나이저로 길이를 재고, 목표를 200토큰으로 낮췄다. 첫 적재에서는 PDF에 섞인 공백 없는 base64 데이터 때문에 357토큰 청크가 생겼다. 추출 단계에서 그런 데이터를 걸러 내고, 청킹 단계에서도 긴 토큰을 분할한 뒤 다시 적재하니 최대 길이가 200토큰이었다. 정상적인 문단만 보고 청크 크기를 정하면 안 된다는 것을 실행 결과로 배웠다.

 

두 번째는 필터가 SQL에 있다는 사실만으로 요청한 개수만큼 결과가 나온다고 보장되지 않는다는 점이다. 드문 cs.CV 카테고리를 걸고 HNSW 경로를 강제한 뒤 10건을 요청했을 때, 반복 스캔을 끄면 1건만 나왔다. hnsw.iterative_scan = relaxed_order를 켜자 10건을 반환했다. 실행 계획에서 인덱스가 142행을 읽은 것도 확인했다. 가까운 후보를 먼저 뽑고 필터를 적용하면 대부분이 걸러질 수 있고, 부족한 결과를 채우려면 더 탐색해야 한다. 4장의 후보를 넉넉히 가져와 필터링하던 고민이 pgvector에서도 다른 형태로 이어진 셈이다.

 

세 번째는 인덱스를 만들었다는 것과 실제 쿼리가 그 인덱스를 쓴다는 것은 다르다는 점이었다. 필터가 없는 검색의 실행 계획은 HNSW가 아니라 순차 스캔과 정렬을 골랐다. 청크 2,147개 규모에서는 PostgreSQL 플래너가 전체 비교를 더 저렴하다고 판단했다. 별도로 HNSW 경로를 강제해 정확 검색과 비교한 50개 질문에서는 recall@10이 1.000, 지연 시간 중앙값이 1.9ms 대 9.1ms였다. 여기서 recall은 HNSW가 정확 검색 결과를 얼마나 재현했는가를 뜻한다. 검색된 논문이 질문에 실제로 적절한지는 정답 문서를 표시한 별도의 평가 데이터가 있어야 판단할 수 있다. 이 수치 역시 내 로컬 CPU와 이 코퍼스에서 얻은 측정값이다.

 

키워드와 벡터 결과를 합칠 때도 단순히 원점수를 더하지 않았다. 두 점수의 범위와 의미가 달라서, 각각의 순위를 RRF로 결합했다. 정확한 용어가 들어간 대목은 전문 검색이, 표현이 다른 관련 대목은 벡터 검색이 후보로 올릴 수 있다. 어떤 방식이 옳다고 미리 정하기보다, 두 결과가 왜 달라지는지 실제 질문으로 살펴보는 편이 유용했다.

 

결과를 읽는 사람에게는 보기 좋게 만든 이미지 한 장보다 재현 명령, 실행 계획, 측정 기준과 한계가 더 중요하다고 생각한다. 저장소 README에 실행 순서와 수치를 두고, 설계 기록에는 각 결정의 이유를 남겼다. 이 논문 검색 시스템 역시 검색 결과를 반환하는 단계까지 구현했고, LLM으로 답을 생성하는 단계는 붙이지 않았다. 수식과 표의 PDF 추출 품질, 의미상 정답 여부를 재는 평가셋은 앞으로 보완해야 할 과제다.


6장: RAG에서는 ‘찾는 일’과 ‘답하는 일’을 따로 봐야 한다

6장은 SQLite VSS와 Ollama를 이용해 로컬 RAG 시스템을 구성한다. 콘텐츠를 모으고 청크로 나누어 임베딩한 뒤 저장한다. 질문이 들어오면 관련 청크를 찾고, 출처 정보와 함께 프롬프트에 넣어 LLM이 답하도록 한다.

이 장을 읽으며 RAG가 하나의 기능이 아니라 여러 단계의 연결이라는 사실이 더 분명해졌다.

콘텐츠 수집 → 청킹 → 임베딩 → 저장 → 질문 처리 → 검색 → 컨텍스트 구성 → LLM 생성

각 단계에서 잘못된 선택을 하면 최종 답변이 어긋날 수 있다. 문서를 잘못 잘랐거나, 검색 후보를 너무 적게 가져왔거나, 서로 다른 점수 체계를 그대로 더했거나, 필요한 출처를 프롬프트에서 잘라냈다면 생성 모델만 바꿔서는 해결되지 않는다.

 

6장의 핵심은 하이브리드 검색이다. 벡터 검색은 표현이 달라도 의미가 비슷한 글을 찾는 데 강하다. 반면 정확한 오류 코드나 특정 기술명은 키워드 검색이 더 잘 잡을 수 있다. 그래서 책은 의미 검색과 SQLite FTS5 키워드 검색의 결과를 합쳐 순위를 매긴다.

여기서 단순히 두 점수를 더할 수는 없다. 벡터 검색 점수와 BM25 점수는 값의 범위와 방향이 다르기 때문이다. 점수를 맞추는 방식에 따라 어떤 결과가 1위로 올라오는지도 달라진다. “의미 검색 70%, 키워드 검색 30%”처럼 가중치를 정하는 일은 보기보다 훨씬 구체적인 설계 결정이다. 두 점수가 어떤 값이고 어떻게 정규화되는지 모르면 7:3이라는 숫자만으로 실제 영향을 이해하기 어렵다.

검색된 청크를 LLM에 전달할 때는 제목, 작성자, 출처도 함께 넣는다. 답변에 출처를 표시하도록 요구하는 것도 중요하다. 다만 출처 표기가 있다고 해서 답이 자동으로 정확해지는 것은 아니다. 관련 없는 청크를 가져왔거나, 출처의 인기 점수를 사실의 신뢰도로 착각하면 근거가 붙은 잘못된 답이 나올 수 있다. 결국 답변을 확인하려면 검색된 원문까지 거슬러 올라갈 수 있어야 한다.

 

실습 관점에서는 코드를 그대로 실행하기 전에 살펴볼 부분도 있다. 내가 정리한 노트에는 함수 이름과 반환 형식, 테이블 이름이 서로 맞지 않는 대목들이 표시돼 있다. 따라서 이 장은 RAG의 전체 흐름을 이해하는 데 도움이 되지만, 코드 실행은 연결부를 점검하면서 진행하는 편이 좋겠다.

책의 흐름을 문서 준비와 질문 처리로 나누어 그리면, 답변 생성 앞에 놓인 검색 단계가 더 분명해진다.


7장: 논문을 찾는 것과 논문의 특정 대목을 찾는 것은 다르다

7장은 과학 논문 RAG를 다룬다. 5장에서 논문 검색 시스템을 설계했다면, 여기서는 검색한 논문을 바탕으로 질문에 답하는 단계까지 이어진다. 가장 좋았던 아이디어는 초록과 본문 섹션을 서로 다른 수준으로 검색하는 것이었다.

“이 분야에서 어떤 접근이 연구되고 있나?”라는 질문에는 논문 전체를 압축한 초록이 좋은 검색 단위다. 반면 “이 논문에서는 어떤 데이터셋으로 실험했나?”라는 질문에는 방법론이나 실험 섹션의 구체적인 대목이 필요하다. 같은 논문이라도 질문의 목적에 따라 필요한 정보의 크기가 다르다.

 

책은 초록 임베딩과 섹션 임베딩을 각각 저장하고 검색 결과를 조합한다. 내게는 이것이 청킹에 대한 답의 연장선으로 보였다. “몇 토큰씩 자를까?”만으로 검색 단위를 결정할 수 없다. 문서의 구조와 사용자의 질문을 함께 봐야 한다. 학술 논문처럼 초록, 방법론, 결과, 결론이 나뉘는 자료라면 그 구조 자체가 검색 설계에 쓰일 수 있다.

 

한편 유사도 임곗값을 고정해 두는 방식에는 조심스러웠다. 코사인 유사도 0.7 이상이면 관련 있다는 식의 기준은 직관적이지만, 질문 길이와 문서 길이, 모델, 도메인이 바뀌면 점수 분포도 바뀐다. 어떤 환경에서는 좋은 결과가 0.7에 못 미쳐 전부 사라질 수 있다. 먼저 임곗값 없이 상위 결과를 보고, 실제 관련 문서의 점수 분포를 확인한 다음 기준을 정하는 편이 맞겠다고 생각했다.


8장: 대화 기록을 검색할 때는 ‘한 문장’만 돌려주면 부족하다

8장은 공개된 레딧 글이나 논문 대신 개인의 AI 대화 기록을 검색 대상으로 삼는다. 이 장의 데이터는 앞선 예제와 성격이 다르다. 대화 속 메시지에는 “그 방법이 더 낫겠네요”, “앞에서 말한 문제는 해결됐어요”처럼 바로 앞뒤의 맥락이 있어야 이해되는 문장이 많다.

따라서 검색 점수가 높은 메시지 하나만 보여 주면 오히려 의미를 잃는다. 책은 대화, 메시지, 메시지 임베딩을 계층적으로 저장하고, 관련 메시지가 검색되면 주변 메시지까지 가져오는 컨텍스트 윈도 방식을 설명한다. 예를 들어 특정 기술 선택에 대한 답변을 찾았다면, 그 전에 사용자가 어떤 제약을 설명했는지까지 함께 봐야 한다.

 

이 부분을 읽으며 내 대화 기록을 검색한다면 무엇을 찾고 싶은지 떠올려 봤다. 몇 달 전 특정 오류를 해결한 방법일 수도 있고, 한 설계안을 버린 이유일 수도 있다. 그 경우 검색된 문장보다 그 결정을 내리기까지의 대화 흐름이 더 가치 있을 수 있다.

 

개인 데이터라는 점도 설계에 영향을 준다. 데이터를 어디에 저장하고 어떤 모델로 처리할지, 가져온 대화의 순서와 작성 시각을 어떻게 보존할지 생각해야 한다. 책은 PostgreSQL, pgvector, Ollama, FastAPI를 사용해 이 흐름의 최소 구성을 보여 준다. 다만 장에서 제시하는 목표 중 시간 기반 탐색이나 패턴 인식처럼 매력적인 기능이 모두 코드로 구현된 것은 아니다. ‘완성형 개인 지식 시스템’이라는 방향을 보여 주되, 실제 예제의 범위는 구분해서 읽는 것이 좋다.


9장: 벡터 검색도 언어로 표현하려면 모호함을 없애야 한다

마지막 장은 벡터 쿼리 언어, VQL을 제안한다. 앞 장들에서 FAISS 함수, SQLite 확장, pgvector SQL로 각각 다르게 표현했던 검색을 공통 언어처럼 적어 보려는 시도다. 유사도 검색, 키워드와 벡터를 섞은 하이브리드 검색, 일정 거리 안의 결과를 찾는 범위 검색, 여러 질문을 한 번에 처리하는 배치 검색, 벡터 집계 등이 등장한다.

 

처음에는 SQL과 비슷한 문법이 익숙해서 편하게 읽혔다. 그런데 다시 생각하니 언어를 만든다는 일은 함수 이름을 보기 좋게 붙이는 것보다 훨씬 어렵다. 예를 들어 THRESHOLD 0.8이라고 쓰면 0.8 이상을 의미할까, 이하를 의미할까? 유사도라면 클수록 가깝지만 거리라면 작을수록 가깝다. WHERE 조건을 벡터 검색 전에 적용할지, 후보를 뽑은 뒤 적용할지에 따라서도 반환 결과가 달라진다.

 

4장에서 이미 겪은 필터링 문제가 9장에서는 쿼리 언어의 의미를 정의하는 문제로 되돌아오는 셈이다. 이 장의 VQL은 특정 제품에서 바로 실행할 수 있는 표준 문법이 아니라 저자의 제안이다. 그대로 복사해 데이터베이스에 입력하기보다, 앞 장들의 검색 요구를 하나의 문장으로 표현한다면 어떤 규칙이 필요한지 생각해 보는 장으로 읽었다.


읽고 나서 남은 세 가지 설계 질문

책을 읽고 실습 결과까지 살펴보니, 벡터 데이터베이스를 선택할 때 다음 세 가지를 먼저 물어야겠다고 생각했다.

첫째, 무엇을 검색 단위로 삼을 것인가? 레딧 댓글은 글 하나가 검색 단위가 될 수 있지만, 논문은 초록과 섹션을 구분하는 편이 좋을 수 있다. 대화 기록은 메시지 하나를 찾더라도 앞뒤 대화를 같이 돌려줘야 한다. 임베딩 모델보다 먼저 원본 데이터의 구조를 봐야 한다.

둘째, 검색 결과를 어떤 조건과 연결할 것인가? 벡터의 거리가 가깝다는 사실만으로 서비스에 보여 줄 결과가 되지는 않는다. 날짜, 권한, 상태, 출처 같은 메타데이터가 함께 필요하다. 필터가 검색 전후 어느 단계에 적용되는지에 따라 원하는 결과를 놓칠 수도 있다.

셋째, 저장된 벡터를 어떻게 유지할 것인가? 원문이 바뀌면 벡터도 갱신해야 한다. 임베딩 모델을 바꾸면 재색인이 필요할 수 있다. 원본 테이블과 인덱스의 ID가 어긋나지 않게 해야 한다. 검색 품질은 한 번 모델을 선택할 때만 결정되는 것이 아니라 데이터를 계속 관리하는 방식에서 유지된다.

이 세 질문이 내가 이 책에서 얻은 가장 큰 수확이다. RAG를 구성할 때도 결국 이 질문들이 먼저다. LLM은 검색된 내용을 받아 답하지만, 무엇을 근거로 줄지 결정하는 일은 앞단의 데이터 처리와 검색 설계가 맡는다.


좋았던 점과 아쉬웠던 점

가장 좋았던 점은 벡터 검색을 익숙한 데이터베이스 문제와 연결해 준다는 것이다. 임베딩의 원리만 설명하고 끝나지 않는다. 식별자, 트랜잭션, 스키마, 필터, 인덱스, 배치 처리, 검색 단위처럼 백엔드 개발자가 실제로 고민할 문제로 이어진다. SQLite와 PostgreSQL을 오가며 같은 요구를 풀어 보기 때문에 도구별 차이도 비교하기 좋았다.

또 검색에서 생성으로 단계적으로 확장하는 구성이 마음에 들었다. 4장에서 검색 결과 목록을 만드는 과정을 이해하고 나면, 6장에서 왜 그 결과를 선별하고 출처와 함께 LLM에 넣어야 하는지 납득하기 쉽다. 7장과 8장에서는 데이터의 구조가 바뀌면 검색 방식도 달라져야 한다는 점을 확인할 수 있다.

아쉬운 점은 실습 코드를 그대로 실행하는 데 어려운 부분이 있다는 것이다. 특히 5장은 완성된 코드보다 설계 골격의 비중이 크다. 다른 장의 노트에서도 함수 호출과 반환값, 테이블 이름이 맞지 않거나 확인이 필요한 예제를 여러 군데 표시하게 됐다. 개념을 익히며 코드를 고쳐 보는 독자에게는 공부가 될 수 있지만, 한 줄씩 입력해 즉시 실행되는 튜토리얼을 기대한다면 답답할 수 있다.

그리고 책의 수치나 설정은 출발점으로 받아들이는 편이 좋겠다. 권장 청크 크기, 중첩 비율, 유사도 임곗값, HNSW 매개변수는 데이터와 모델에 따라 달라진다. 특히 청크 길이는 임베딩 모델의 실제 입력 한도와 함께 확인해야 한다. 책의 예제를 내 데이터에 가져오려면 “이 값이 왜 필요한가”를 이해한 뒤 검색 결과를 직접 평가해야 한다.


어떤 독자에게 맞을까

RAG 예제를 한 번 따라 해 봤지만 검색 단계가 블랙박스처럼 느껴졌다면 이 책이 도움이 될 것 같다. FAISS, SQLite, PostgreSQL을 거치며 벡터가 어떻게 저장되고 검색되는지 연결해서 볼 수 있기 때문이다.

이미 SQL과 백엔드 개발에 익숙한 독자에게도 잘 맞는다. 이 책의 재미는 새로운 도구의 이름을 많이 소개하는 데보다, 기존에 알고 있던 데이터 관리 원칙이 벡터 검색에서 어떻게 다시 중요해지는지 보여 주는 데 있다. 반대로 코드를 그대로 입력해 완성된 서비스를 만들고 싶은 입문자라면, 실습 환경과 코드의 연결부를 확인할 시간을 넉넉히 잡는 것이 좋겠다.


마치며

책을 읽기 전에는 벡터 데이터베이스를 RAG에 필요한 별도의 저장소 정도로 생각했다. 읽고 검색 결과까지 확인한 뒤에는 관점이 조금 바뀌었다. 벡터는 데이터에 의미를 담아 비교할 방법을 주지만, 그 의미가 실제 사용자에게 쓸 만한 결과가 되려면 원문, 메타데이터, 식별자, 인덱스, 검색 단위가 함께 맞아야 한다.

 

내게 『벡터 데이터베이스』는 “어떤 벡터 DB를 써야 하나?”에 하나의 정답을 주는 책이 아니었다. 대신 내 데이터는 무엇이고, 어떤 질문에 답해야 하며, 그 답을 찾는 과정을 어떻게 믿을 수 있게 만들 것인가를 계속 묻게 하는 책이었다.

작은 의미 검색 실험과 논문 검색 시스템을 직접 만들어 보니, 백엔드 개발자로서 내가 확인해야 할 일도 분명해졌다. 문서를 어떤 단위로 저장하고, 어떤 조건으로 찾으며, 결과가 원문을 제대로 가리키는지 확인하는 일이다. 앞으로 AI 기능을 실무에 적용할 때도 답변의 모습만 볼 것이 아니라, 그 답의 근거가 만들어지는 검색 경로부터 검증하고 싶다.

반응형