200줄의 코드로 내부 문서에 시맨틱 검색 (Semantic Search) 추가하기
요약
Postgres와 pgvector를 활용하여 약 200줄의 코드로 내부 문서용 시맨틱 검색 시스템을 구축하는 방법을 소개합니다. 키워드 검색의 한계를 극복하기 위해 텍스트를 벡터로 변환하는 임베딩 기술과 3단계 파이프라인을 다룹니다.
핵심 포인트
- 키워드 검색의 한계인 단어 불일치 문제를 시맨틱 검색으로 해결
- Postgres와 pgvector를 이용한 효율적인 벡터 저장 및 검색
- 청킹, 임베딩, 쿼리 단계로 구성된 3단계 파이프라인 구축
- 의미 기반 검색을 통해 유의어 및 의역된 질문 대응 가능
팀원 중 누군가가 채팅으로 질문합니다: "배송이 완료된 고객의 주문을 어떻게 취소하나요?" 여러분의 위키(wiki)에는 답이 있습니다. Returns and reversals — post-fulfilment procedure라는 제목의 페이지입니다. 하지만 아무도 이를 찾지 못합니다. 왜냐하면 그들은 "cancel order"라고 검색했는데, 해당 문서에는 "cancel"이라는 단어가 전혀 사용되지 않았기 때문입니다. 그래서 그들은 동료에게 메시지를 보내고, 동료는 이미 2년 전에 작성된 프로세스를 다시 설명하게 됩니다.
그 간극을 메워주는 것이 바로 내부 문서에 대한 시맨틱 검색 (Semantic Search)입니다. 키워드 검색 (Keyword search)은 문자열을 매칭하지만, 사람들은 자신만의 언어로 질문을 합니다. 이 글은 Postgres와 pgvector를 기반으로 약 200줄 내외로 작성된, 청킹 (chunk), 임베딩 (embed), 의미 기반 쿼리 (query by meaning)를 포함하는 압축적이고 실행 가능한 레시피입니다. 이것은 엄격하게 검색 (retrieval), 즉 적절한 문서를 찾는 것에 관한 것입니다. 그 문서 위에서 답변을 생성하는 것이 아닙니다. 이 차이점이 핵심이며, 왜 중요한지는 나중에 다시 다루겠습니다.
키워드 검색 (Keyword Search)이 한계에 부딪히는 이유
LIKE '%cancel%'나 심지어 Postgres의 전문 검색 (full-text search) 모두 토큰 (token)을 매칭합니다. 검색자와 작성자가 우연히 같은 단어를 사용할 때는 매우 훌륭합니다. 하지만 단어가 일치하지 않는 순간 무너집니다:
- "how do I cancel a shipped order" vs. "cancellation policy"라는 제목의 문서 — 정책 페이지에는 사용자가 입력한 동사가 전혀 포함되어 있지 않을 수 있습니다.
- "the app is slow after login" vs. "performance degradation on session initialization" — 공유되는 내용 단어는 전혀 없지만, 의미는 같습니다.
- "expense reimbursement" vs. "travel claims" — 키워드 인덱스 (keyword index)가 서로 관련 없는 것으로 취급하는 유의어들입니다.
유의어 사전 (synonym dictionaries)과 어간 추출 (stemming)을 통해 이 문제의 일부를 덮을 수 있고, 좋은 tsvector 설정을 갖춘 전문 검색 (full-text search)은 진정으로 도움이 됩니다. 하지만 여러분은 영원히 수동으로 관리되는 유의어 사전을 유지해야 하며, 그럼에도 아무도 예상하지 못한 의역 (paraphrases)은 여전히 놓치게 됩니다. 시맨틱 검색 (Semantic search)은 반대 방향에서 이 문제에 접근합니다. 즉, 표면적인 단어가 아니라 쿼리 (query)의 **의미 (meaning)**와 각 문서의 의미를 비교합니다.
그 메커니즘은 임베딩 (embeddings)입니다. 임베딩 모델은 텍스트 조각을 벡터 (vector) — 즉, 숫자 리스트 — 로 매핑하며, 이를 통해 의미가 유사한 텍스트들은 해당 공간 내에서 서로 가까운 곳에 위치하게 됩니다. "배송된 주문 취소"와 "이행 후 취소 절차"는 공유하는 단어가 전혀 없음에도 불구하고 서로 가까운 곳에 위치하게 됩니다. 그러면 검색은 다음과 같이 이루어집니다: 쿼리 (query)를 임베딩하고, 가장 가까운 문서 벡터들을 찾아, 해당 문서들을 반환합니다.
3단계로 이루어진 파이프라인
전체 시스템은 세 단계로 움직입니다:
- 청크 (Chunk): 각 문서를 적절한 크기의 조각으로 나누되, 약간의 중첩 (overlap)을 둡니다.
- 임베딩 (Embed): 데이터 수집 (ingest) 시점에 모든 청크를 한 번씩 임베딩하고 벡터를 저장합니다.
- 쿼리 (Query): 들어온 질문을 임베딩하고, 코사인 거리 (cosine distance) 기준으로 상위 k개의 가장 가까운 청크를 찾아, 소스(source)로 연결되는 링크와 함께 반환합니다.
그게 전부입니다. 산문을 작성하도록 모델에게 요청하는 것도 아니고, 에이전트 루프 (agent loop)나 스트리밍 (streaming)도 없습니다. 그저 "이 질문이 주어졌을 때, 가장 관련 있는 5개의 구절과 그것들이 어디에서 왔는지"를 알려줄 뿐입니다.
저장소: Postgres + pgvector
이미 Postgres를 사용 중이라면, 시작 단계에서 별도의 벡터 데이터베이스 (vector database)가 필요하지 않습니다. pgvector는 vector 컬럼 타입과 거리 연산자를 추가해 주는 Postgres 확장 기능 (extension)입니다. 이를 통해 이미 백업, 쿼리, 모니터링을 수행하고 있는 동일한 데이터베이스에 문서와 그 임베딩을 함께 보관할 수 있으며, 이는 대부분의 내부 도구 (internal-tooling) 사례에서 충분한 이유가 됩니다. (저는 나머지 Postgres 습관들을 PostgreSQL production patterns에서 유지하고 있으며, 동일한 인덱싱 원칙이 여기에도 적용됩니다.)
스키마 (schema)입니다. 저는 1536차원 벡터를 생성하는 OpenAI의 text-embedding-3-small을 사용하고 있으므로, 컬럼은 vector(1536)이 됩니다:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE doc_chunks (
...
벡터 인덱스(vector index)는 사람들이 가장 많이 실수하는 부분입니다. pgvector는 두 가지 인덱스 유형을 제공합니다. ivfflat은 벡터를 리스트로 분할하며 구축 속도가 빠르지만, 리스트 개수와 쿼리 시점의 probes 설정을 직접 지정해야 합니다. hnsw는 그래프(graph)를 구축하며, 구축 속도가 더 느리고 메모리를 더 많이 사용하지만, 주어진 속도에서 더 나은 재현율(recall)을 제공하며 리스트 튜닝이 필요하지 않습니다. 메모리에 여유롭게 들어가는 내부 코퍼스(corpus)의 경우, 저는 기본적으로 hnsw를 사용합니다. 결정적으로, 인덱스 연산자 클래스(index operator class)는 쿼리 시 사용하는 거리 연산자(distance operator)와 일치해야 합니다. 코사인 거리(cosine distance)는 <=>이므로, 인덱스는 vector_cosine_ops를 사용합니다:
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops);
솔직한 조언을 덧붙이자면, 수백 개의 청크(chunk)로 이루어진 코퍼스의 경우 인덱스나 pgvector가 전혀 필요하지 않을 수도 있습니다. 수백 개의 벡터에 대한 순차 스캔(sequential scan)은 밀리초 단위로 끝나며, 전체 데이터를 메모리에 유지한 채 일반 TypeScript로 코사인 유사도(cosine similarity)를 계산할 수도 있습니다. Postgres를 사용하는 방식은 코퍼스가 커질 때, 데이터를 애플리케이션의 나머지 상태(state)와 함께 두고 싶을 때, 그리고 동시 쿼리(concurrent queries)가 중요할 때 그 가치를 발휘합니다.
청킹 (Chunking)
청킹(Chunking)은 각 문서를 실제로 검색하게 될 단위로 변환하는 과정입니다. 목표는 각 청크가 일관성 있고 자기 완결적인 구절이 되어야 하며, 의미를 전달할 수 있을 만큼 충분히 크면서도, 임베딩(embedding)이 여러 주제가 섞인 모호한 상태가 아닌 하나의 주제를 나타낼 수 있을 만큼 충분히 작아야 한다는 것입니다.
저는 먼저 구조(제목, 그다음 단락)를 기준으로 분할하고, 섹션이 너무 길 때만 엄격한 토큰 제한(token cap)을 적용합니다. 중첩(Overlap)을 두어 경계 사이에 약간의 문맥을 유지함으로써, 두 청크에 걸쳐 나누어진 문장도 여전히 검색될 수 있도록 합니다. 이는 예시를 위한 것이며(단락을 인식하지만 완벽하지는 않음), 제가 실제로 사용하는 형태는 다음과 같습니다:
// chunk.ts
import { encoding_for_model } from "tiktoken";
...
임베딩 및 인제스트 (Embedding and Ingesting)
각 청크를 인제스트(ingest) 시점에 한 번씩 임베딩하고, 소스 메타데이터(metadata)와 함께 벡터를 저장합니다. 호출 시에는 배치(Batch) 처리를 하세요. 임베딩 엔드포인트(embeddings endpoint)는 요청당 많은 입력을 허용하므로, 청크당 한 번씩 호출하는 것보다 더 빠르고 저렴합니다.
// ingest.ts
import OpenAI from "openai";
import { Pool } from "pg";
...
소스별로 DELETE 후 INSERT를 수행하면, 수정된 단일 문서를 다시 수집(re-ingesting)하는 과정이 멱등성(idempotent)을 갖게 됩니다. 즉, 위키 페이지를 변경한 후 해당 소스에 대해서만 수집을 다시 실행하면 기존의 청크(chunks)들이 교체됩니다. 실행 사이에 대부분의 문서가 변경되지 않는 대규모 코퍼스(corpus)의 경우, 콘텐츠를 해싱(hash)하고 해시값이 저장된 값과 일치하면 임베딩(embedding)을 건너뛰도록 하세요. 임베딩 호출은 개별적으로는 저렴하지만, 수천 개의 청크에 걸쳐 쌓이면 비용이 커집니다. 이에 대한 내용과 임베딩 비용 관리의 나머지 부분은 reducing OpenAI API costs in production에서 다룹니다.
쿼리(Query): 코사인 거리(Cosine Distance)를 이용한 최근접 이웃(Nearest Neighbours)
쿼리 시점에는 수집 시 사용했던 것과 동일한 모델로 질문을 임베딩한 다음, Postgres에 가장 가까운 청크를 요청합니다. <=> 연산자는 코사인 거리(cosine distance)를 의미하며, 값이 작을수록 더 가깝습니다. 따라서 ORDER BY embedding <=> $1 및 LIMIT k를 사용합니다.
// search.ts
import OpenAI from "openai";
import { Pool } from "pg";
...
이것이 검색 시스템의 전부입니다. 청킹(Chunking), 수집(ingest), 검색(search)을 모두 합쳐도 수백 줄 내외—약 200줄 정도—에 불과합니다. 이는 전체 RAG 스택의 어려운 부분들을 의도적으로 제외했기 때문입니다. 답변 생성(answer generation), 리랭커(reranker), UI, 스트리밍(streaming)이 없습니다. 출력값은 title, url, 그리고 관련성 힌트로 보여줄 수 있는 distance를 포함한 실제 구절(passages)의 순위 목록입니다. 이를 검색창과 링크 목록에 연결하면, 사람들은 의미(meaning)를 통해 문서를 찾을 수 있습니다.
품질은 청킹(Chunking)에서 결정된다
이 글에서 한 가지만 기억해야 한다면, 임베딩 모델이 병목 현상(bottleneck)이 되는 경우는 드물다는 점입니다. 청킹이 병목입니다. 좋은 청크를 가진 성능이 낮은 모델이, 나쁜 청크를 가진 뛰어난 모델보다 더 낫습니다.
너무 큰 청크(Chunk)는 여러 주제를 하나의 벡터로 흐릿하게 섞어버리므로, 청크가 모든 것에 약하게 매칭되지만 그 무엇에도 강력하게 매칭되지 않게 됩니다. 너무 작은 청크는 의미를 부여하던 문맥(Context)을 잃어버립니다. 주변 문단 없이 검색된 단일 문장은 그것을 읽는 사람에게 종종 쓸모가 없습니다. 번호가 매겨진 목록 중간에서 절차를 나누면, 검색된 파편은 질문의 절반에만 답하게 됩니다.
모델 선택보다 관련성(Relevance)을 더 크게 움직이는 레버(Levers)들은 다음과 같습니다:
- 글자 수가 아닌 구조에 따라 분할하세요. 제목(Heading)과 문단 경계를 존중하세요. 임의의 500자 지점에서 잘린 청크보다 문서의 한 섹션에 매핑되는 청크가 훨씬 더 잘 검색됩니다.
- 메타데이터(Metadata)를 포함하세요. 모든 청크와 함께
title및url을 저장하세요. 출처를 인용하고 다시 연결하려면 이 정보들이 필요합니다. 전체적인 가치 제안은 "여기 문서가 있습니다"이지, "여기 고립된 문단이 있습니다"가 아닙니다. - 중첩(Overlap)을 조정하세요. 약간의 중첩(청크 크기의 10~15%)은 경계에 걸쳐 있는 아이디어들을 검색 가능하게 유지해 줍니다. 중첩이 너무 많으면 저장 공간이 낭비되고 거의 중복된 결과가 반환됩니다.
보편적인 청크 크기는 없습니다. 산문 형태의 핸드북에는 400 토큰(Token)이 합리적인 기본값입니다. 밀도가 높은 참조 자료나 짧은 FAQ 항목은 다른 설정이 필요합니다. 팀에서 실제 쿼리(Query)를 인덱스(Index)에 실행해 보고 결과로 무엇이 돌아오는지 읽어봄으로써 자신만의 설정을 찾을 수 있습니다.
slug="ai-integration"
text="문서는 있지만 의미로 찾을 수 없나요? 저는 내부 위키, 핸드북, 티켓 이력을 대상으로 하는 시맨틱 검색(Semantic Search)을 구축합니다. Postgres 네이티브이며, 검색 우선(Retrieval-first) 방식이고, 환각(Hallucination) 답변이 없습니다."
/>
시맨틱 검색이 부족한 부분 — 솔직하게
순수 벡터 검색(Pure vector search)은 도구일 뿐, 검색의 신탁(Oracle)이 아닙니다. 그것이 실망을 안겨주는 지점들은 예측 가능하므로, 이에 대해 미리 계획을 세우세요.
정확한 용어에 대한 키워드 매칭 (Keyword Matching)에 패배합니다. 에러 코드, 함수 이름, SKU, 티켓 ID, 약어 등에서 문제가 발생합니다. 임베딩 (Embeddings)은 일반화 (Generalize)되는 특성이 있는데, 사용자가 "비슷한 느낌의 에러"가 아니라 정확히 ERR_2043을 원하는 경우에는 이것이 오히려 독이 됩니다. 정확한 토큰 (Tokens)이 중요한 모든 코퍼스 (Corpus)의 해답은 하이브리드 (Hybrid) 방식입니다. 벡터 검색 (Vector Search)과 키워드 검색 (Postgres full-text 또는 BM25)을 병렬로 실행하고 그 순위 (Rankings)를 결합하세요. 추가적인 지연 시간 (Latency)과 비용을 감수할 만큼 중요한 상황이라면 리랭킹 (Reranking) 단계를 추가하십시오. 만약 내부 문서가 식별자 (Identifiers)로 가득 차 있다면, 하이브리드 방식을 업그레이드가 아닌 기본값 (Baseline)으로 취급해야 합니다.
청킹 (Chunking)은 취약하며 정해진 정답이 없습니다. 너무 거칠게 나누면 노이즈 (Noise)를 검색하게 되고, 너무 잘게 나누면 문맥이 없는 파편 (Fragments)을 검색하게 됩니다. 적절한 크기는 콘텐츠에 따라 다르며, 오직 자신의 쿼리 (Queries)를 대상으로 테스트해 보아야만 알 수 있습니다. 실제 사용 사례를 확인한 후에는 이를 재조정 (Re-tune)해야 할 것이라고 예상하십시오.
임베딩은 노후화되며, 재임베딩 (Re-embedding)에는 비용이 발생합니다. 문서를 수정하면 기존의 청크 (Chunks)는 더 이상 새로운 텍스트와 일치하지 않으므로, 해당 소스를 다시 임베딩해야 합니다. 더 심각한 문제는 임베딩 모델 (Embedding Models)을 교체할 때입니다. 이전 모델과 새 모델의 벡터 (Vectors)는 서로 호환되지 않는 공간에 존재하므로 비교할 수 없습니다. 모델을 변경한다는 것은 코퍼스 전체를 다시 임베딩해야 함을 의미합니다. 모델 버전을 고정하고, 모델 변경을 단순한 설정 변경이 아닌 마이그레이션 (Migration)으로 취급하십시오.
정확한 것이 아니라 유사한 것을 반환합니다. 최근접 이웃 검색 (Nearest-neighbour search)은 쿼리와 의미상 가장 가까운 구절을 제공합니다. "가장 가까운 것"이 "정확한 것"은 아닙니다. 검색 결과 상위 항목이 문구는 자신감 넘치지만 시대에 뒤떨어진 정책일 수 있습니다. 임베딩은 어떤 문서가 권위 있거나 최신인지에 대한 개념이 없습니다. 팀의 실제 쿼리를 통해 순위 품질을 검증해야 하며, 소스 문서의 신뢰성을 유지해야 합니다. 쓰레기가 들어가면, 자신감 있게 순위가 매겨진 쓰레기가 나옵니다 (Garbage in, confidently-ranked garbage out).
때로는 이것이 전혀 필요하지 않을 수도 있습니다. 문서가 수십 개 정도라면? Ctrl+F나 Postgres의 전문 검색 (Full-text search)이 더 간단하고, 무료이며, 충분히 훌륭합니다. 임베딩 파이프라인 (Embedding pipeline)을 추가하는 것은 과잉 엔지니어링 (Over-engineering)입니다. 의미보다 정확한 일치가 더 중요하다면, 키워드 검색 (Keyword search)이 보조 수단이 아닌 적절한 기본 수단입니다. 말뭉치 (Corpus)가 너무 커서 직접 찾아보기 어렵고, 의역 (Paraphrase)이 실제적인 문제로 작용할 때 시맨틱 검색 (Semantic search)을 고려하십시오.
이 모든 것들은 관리 가능한 수준입니다. 미리 예상하고 접근한다면 그 어떤 것도 숨겨진 문제는 아닙니다.
검색은 문서를 반환합니다. 챗봇은 답변을 작성합니다.
이것은 명확하게 선을 그어야 할 지점입니다. 위의 모든 과정은 검색 (Retrieves) 합니다. 즉, 실제 구절과 링크를 돌려주며, 생성 (Generation) 단계가 없기 때문에 결코 무언가를 지어내지 않습니다. 이것은 하나의 기능입니다. 실제 반품 정책 페이지를 반환하는 검색창은 반품 정책을 환각 (Hallucinate)할 수 없습니다. "아무도 문서를 찾을 수 없다"는 문제에 대해서는 검색 (Retrieval)만으로도 문제가 해결됩니다.
만약 해당 구절들을 읽고 그 위에 자연어 답변을 작성하는 봇 — "배송된 주문을 취소하려면 관리자 패널에서 취소 처리를 생성한 다음..." — 을 원한다면, 그것은 다음 단계인 검색 증강 생성 (Retrieval-augmented generation, RAG)입니다. RAG는 LLM, 추측하는 대신 문제를 에스컬레이션할 수 있게 하는 신뢰도 임계값 (Confidence threshold), 출처 인용 (Source citation), 스트리밍 UI를 추가하며, 검색 전용 버전에는 없는 새로운 실패 모드(모델이 틀린 답변을 유창하게 표현할 수 있음)를 동반합니다. 저는 이커머스 고객 지원 데스크를 위해 이를 엔드 투 엔드 (End-to-end)로 구축했습니다. 25개 언어에 걸쳐 인간의 개입 없이 티켓의 70%를 해결했으며, 자세한 내용은 RAG 챗봇 아키텍처 기술 블로그에서 확인할 수 있습니다. 여기서의 시맨틱 검색은 해당 시스템의 검색 (Retrieval) 측면에 해당하며, 그 자체만으로도 충분히 유용합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기