
RAG를 처음부터 구축하기 — 수집, 정제, 임베딩, 거절
요약
환각 현상을 방지하기 위해 데이터 수집부터 정제, 임베딩, 그리고 답변 거절 로직까지 포함된 RAG 시스템 구축 과정을 다룹니다. Postgres와 Python을 활용하여 카탈로그 기반의 정확한 답변을 생성하는 실무적인 워크플로우를 설명합니다.
핵심 포인트
- 데이터 수집 시 멱등성을 보장하여 효율적인 다운로드 구현
- HTML 페이지 내 주석을 활용한 메타데이터 관리 방식
- 카탈로그에 없는 정보에 대해 답변을 거절하는 로직의 중요성
- 수집, 정제, 임베딩으로 이어지는 RAG 파이프라인 구축
이 시스템의 첫 번째 버전은 우리가 유압 굴착기를 판매한다고 말했습니다. 우리는 그렇지 않습니다. 시스템은 깨끗한 부품 번호, 가격, 깔끔한 설명과 함께 아주 자신 있게 그렇게 말했습니다. 모두 지어낸 이야기였습니다.
그 순간, 저는 "그냥 데이터를 프롬프트(prompt)에 넣으면 돼"라는 계획을 더 이상 신뢰하지 않게 되었습니다.
그래서 저는 단 하나의 규칙을 가지고 시스템을 다시 구축했습니다: 카탈로그를 바탕으로 답변하거나, 아니면 아예 답변하지 말 것. 카탈로그에는 모터, 펌프, 기어와 같은 약 411개의 산업용 제품이 포함되어 있습니다. 그 굴착기에 대해 물으면 없다고 답해야 합니다. 시를 써달라고 하면 정중히 거절해야 합니다.
이 포스트에서는 제가 구축한 순서대로 전체 과정을 살펴봅니다: 데이터를 **수집(collect)**하고, **정제(clean)**하고, **임베딩(embed)**한 다음, 실제 제품으로 뒷받침할 수 없는 모든 요청에 대해 어시스턴트가 **거절(refuse)**하도록 만드는 과정입니다. 거창한 것은 없습니다. 하나의 Postgres 테이블과 수백 줄의 Python 코드뿐입니다.
본격적으로 시작하기 전에 전체 시스템을 한 페이지로 정리하면 다음과 같습니다:
데이터 준비와 인덱싱(indexing)은 한 번만 수행됩니다. 구분선 아래의 모든 과정은 질문마다 실행됩니다.
RAG가 처음이신가요? RAG (Retrieval-Augmented Generation, 검색 증강 생성)란 모델이 답변하기 전에, 사용자의 데이터에서 관련 있는 부분을 검색하여 프롬프트(prompt)에 붙여넣는 것을 의미합니다. 모델은 기억(memory)에 의존하는 대신 해당 정보들을 바탕으로 답변합니다. "검색(retrieval)"은 탐색 과정을, "생성(generation)"은 모델이 답변을 작성하는 과정을 의미합니다.
수집 (Collect)
데이터는 웹사이트의 제품 페이지 형태로 존재했습니다. 파싱(parsing) 방식에 대한 생각이 바뀔 때마다 매번 사이트에 접속하고 싶지 않았기 때문에, 수집 과정을 두 단계로 나누었습니다: 한 번 다운로드하고, 원하는 만큼 여러 번 파싱하는 방식입니다.
첫 번째 단계는 HTML을 다운로드하여 각 페이지를 디스크에 저장합니다. 다음 두 가지 세부 사항 덕분에 이 과정은 수월했습니다:
- 멱등성 (Idempotent)을 가집니다. 파일이 이미 존재하고 크기가 아주 작지 않다면 건너뜁니다. 다시 실행하면 누락된 것만 가져옵니다.
- 별도의 매니페스트 (Manifest)가 없습니다. 목록 페이지는 이미 각 제품에 대한 몇 가지 정보(URL, 브랜드, 이름, 가격, ID)를 알고 있습니다. 이를 별도의 파일에 저장하는 대신, 각 HTML 페이지 상단의 주석으로 작성합니다:
def download_one(url, meta):
fpath = PAGES / slugify(url)
if fpath.exists() and fpath.stat().st_size > 1000:
...
따라서 저장된 모든 파일은 자체적인 메타데이터 (Metadata)를 포함합니다. 파서 (Parser)는 나중에 파일에서 이를 직접 읽습니다. 사이트는 한 번만 호출되며, 그 이후의 모든 작업은 오프라인에서 이루어집니다.
사소해 보일 수 있지만, 이는 대부분의 튜토리얼이 생략하는 부분입니다. 첫 번째 시도에서 파싱 (Parsing)을 틀릴 수도 있습니다. 네트워크를 다시 건드리지 않고도 문제를 수정하고 다시 실행할 수 있어야 합니다.
정제 (Clean)
두 번째 단계는 절대 온라인 상태로 진행되지 않습니다. 저장된 페이지를 읽어 하나의 깨끗한 catalog.json으로 변환합니다:
for fpath in sorted(PAGES.glob("*.html")):
h = fpath.read_text(encoding="utf-8")
meta = s.extract_meta(h) # 첫 번째 단계에서 생성한 주석 헤더
...
각 제품은 id, title, manufacturer, oem_pn, condition, price_eur, weight_kg, dimensions_cm, category, url, 그리고 description 형태의 평탄한 레코드 (Flat record)로 출력됩니다.
추측을 줄여준 유용한 습관 중 하나는 파싱 후에 각 필드 (Field)가 실제로 채워져 있는 레코드의 개수를 출력해 보는 것입니다.
--- field coverage ---
manufacturer 411/411
price_eur 411/411
...
만약 특정 필드가 대부분 비어 있다면, 사용자의 필터가 아무것도 반환하지 않는 상황이 발생하기 전에 지금 바로 알 수 있습니다. 깨끗한 데이터에 대해 이야기하는 것은 지루할 수 있지만, 실제 작업의 대부분은 바로 여기서 이루어집니다.
임베딩 (Embed)
이제 사람들이 "AI 부분"이라고 생각하는 단계가 나오지만, 사실 이 부분이 가장 작습니다.
각 제품은 제목, 브랜드, 부품 번호, 카테고리, 상태, 그리고 설명을 하나로 합친 하나의 텍스트 블록 (Text block)이 됩니다. 제품 하나당 청크 (Chunk) 하나입니다. 설명이 짧기 때문에 이를 굳이 나눌 필요는 없습니다.
def product_text(p):
parts = [p.get("title"), p.get("manufacturer"), p.get("oem_pn"),
p.get("category"), p.get("condition"), p.get("description")]
...```
그다음 저는 각 블록을 로컬에서 실행되는 모델인 **bge-m3**를 사용하여 벡터 (Vector)로 변환합니다. (벡터는 의미를 포착하는 숫자 리스트일 뿐입니다. 유사한 내용을 담은 두 텍스트는 같은 방향을 가리키는 벡터를 갖게 됩니다. 쿼리 (Query)와 유사한 제품을 찾으려면, 쿼리 또한 임베딩 (Embedding)하여 가장 가까운 벡터를 찾으면 됩니다.)
```python
def embed(texts):
return embedder().encode(texts, normalize_embeddings=True)
제가 bge-m3를 선택한 데에는 세 가지 이유가 있습니다. 무료이고, 제 컴퓨터에서 직접 실행할 수 있으며 (임베딩 API 키가 필요 없음), 다국어 (Multilingual)를 지원한다는 점입니다. 즉, 한 가지 언어로 질문해도 영어 제품 텍스트와 매칭할 수 있습니다. 이 모델은 제품당 1024개의 숫자를 출력하는데, 이는 다음 단계에서 매우 중요합니다.
벡터와 메타데이터 (Metadata)는 일반 컬럼 (Column) 바로 옆에 벡터를 저장하는 Postgres 확장 기능인 pgvector에 저장됩니다.
cur.execute(f"""
CREATE TABLE IF NOT EXISTS products (
url text PRIMARY KEY,
...
그다음 빠른 유사도 검색 (Similarity search)을 위해 HNSW 인덱스 (Index)를 생성합니다.
cur.execute("""
CREATE INDEX IF NOT EXISTS products_emb_idx
ON products USING hnsw (embedding vector_cosine_ops)
...
이것이 바로 1024 차원 (Dimension)이 중요한 이유입니다. pgvector의 HNSW 인덱스는 2000 차원까지 제한되어 있으므로, bge-m3는 여유 있게 들어갑니다. 삽입 (Insert) 시에는 ON CONFLICT ... DO UPDATE를 사용하여, 변경된 카탈로그에 대해 다시 실행하더라도 오류가 발생하는 대신 행 (Row)을 업데이트하기만 하면 됩니다.
벡터를 Postgres 내부에 두었을 때 얻는 큰 이점은 다음 단계에서 나타납니다.
검색 (Retrieve)
이제 진짜 질문입니다. _"2000유로 미만의 Siemens 모터"_와 같은 쿼리에 대해 어떻게 적절한 제품을 찾을 수 있을까요?
순수 벡터 검색 (Pure vector search)만으로는 여기서 어려움을 겪습니다. 제품 설명은 템플릿화되어 있어 서로 매우 비슷해 보이기 때문에, "motor"라는 단어는 수십 개의 항목과 매칭됩니다. 또한 모델은 "2000유로 미만"이라는 개념을 실제로 이해하지 못합니다. 그것은 의미가 아니라 숫자이기 때문입니다.
그래서 저는 두 가지 방식을 모두 사용합니다. 먼저 Claude에게 쿼리에서 구조화된 부분 (Structured parts)을 추출하여 일반 JSON 형식으로 반환하도록 요청합니다.
FILTER_SYSTEM = (
"사용자의 제품 쿼리에서 구조화된 검색 필터(structured search filters)를 추출하고 "
"오직 다음 JSON 형식으로만 반환하세요:\n"
...
"2000유로 미만의 Siemens 모터"라는 쿼리에 대해, 이 방식은 manufacturer: "Siemens", max_price: 2000을 반환하며, 모호한 부분은 남은 semantic_query로 처리합니다. 만약 모델이 실패하거나 쓰레기 값(junk)을 반환하더라도, 코드는 전체 쿼리를 일반적인 시맨틱 검색 (semantic search)으로 처리하는 폴백(fallback) 메커니즘을 갖추고 있습니다. 따라서 파싱(parse)이 잘못되더라도 시스템이 충돌하는 대신 성능이 조용히 저하될 뿐입니다.
그다음, 구조화된 필터와 벡터 검색은 하나의 SQL 쿼리로 실행됩니다. 필터는 WHERE 절이 되고, 벡터 유사도(vector similarity)는 ORDER BY가 됩니다:
sql = f"""
SELECT id, title, manufacturer, price_eur, weight_kg, url, description,
1 - (embedding <=> %s) AS sim
...
이것이 Postgres에 벡터를 보관했을 때 얻는 이점입니다. 정확한 필터(브랜드, 가격, 무게)가 엄격한 필터링을 수행하고, 벡터 순서가 "이 중 어떤 것이 가장 관련성이 높은가"라는 모호한 부분을 처리합니다. 단 하나의 문장으로 해결되며, 동기화해야 할 별도의 시스템이 필요 없습니다.
거절 (Refuse)
훌륭한 검색(retrieval)은 올바른 제품을 찾아줍니다. 하지만 어시스턴트는 적절한 제품이 없을 때를 인지하고 멈출 줄도 알아야 합니다. 이 지점이 대부분의 RAG 시스템이 조용히 환각 (hallucinate)을 일으키는 부분이며, 제가 가장 신경 썼던 부분이기도 합니다.
저는 두 가지 가드레일 (guardrails)을 사용했는데, 그중 첫 번째가 매우 중요합니다.
1. 모델 실행 전 작동하는 유사도 임계값 (similarity threshold). 모든 매칭 결과는 0과 1 사이의 sim 점수와 함께 반환됩니다. 특정 하한선(floor) 미만의 결과는 모두 제외됩니다:
results = [r for r in results if r["sim"] >= MIN_SIM] # MIN_SIM = 0.45
만약 기준치를 통과하는 결과가 없다면, 모델은 아예 호출되지 않습니다:
def answer(client, query, results):
if not results:
return "카탈로그에서 관련 제품을 찾을 수 없습니다."
...
그것이 핵심 비결입니다. 모델이 작성하도록 요청받지 않은 답변을 환각 (hallucination)할 수는 없습니다. 데이터에 없는 유압 굴착기를 요청하면, 검색 결과가 비어 있게 되고, 답변은 LLM (Large Language Model)을 개입시키지 않은 채 단순히 "찾을 수 없습니다"라고 출력됩니다. 이는 모델에게 제대로 행동하라고 '요청'하는 것보다 훨씬 강력한데, 잘못된 행동을 할 가능성 자체를 제거하기 때문입니다.
2. 모델을 주어진 정보에 고정시키는 시스템 프롬프트 (System Prompt). 일치하는 결과가 '있을' 때, 프롬프트는 엄격하게 작동합니다:
GEN_SYSTEM = (
"당신은 산업용 제품 카탈로그 어시스턴트입니다. 메시지에 제공된 "
"PRODUCTS 내에서만 답변하십시오. 관련 제품이 없다면, 다음과 같이 말하십시오..."
...```
따라서 "시를 써줘"라는 요청에는 정중한 거절이 돌아오며, 실제 답변은 항상 사용자가 확인할 수 있는 제품 ID와 URL을 인용합니다.
사람들은 보통 프롬프트를 먼저 시도합니다. 하지만 프롬프트는 두 방법 중 더 약한 방법입니다. 프롬프트는 좋은 행동을 '요청'하지만, 임계값 (threshold)은 나쁜 행동을 '불가능'하게 만듭니다. 두 가지를 모두 사용하되, 임계값에 더 의존하십시오.
## 작동 모습 확인하기
쿼리(query) 측면을 결합하면 단일 질문이 다음과 같은 경로를 거칩니다:
[](https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F972yxzg00oe9n4k8dlem.png)
프런트엔드는 작은 Streamlit 채팅창이지만, 제가 모든 RAG에 추가하고 싶은 한 가지 기능이 있습니다. 바로 '작업 과정'을 보여준다는 점입니다. 각 답변 아래에는 두 개의 패널이 있습니다. 하나는 질문에서 추출한 필터와 검색된 점수가 매겨진 제품 목록이고, 다른 하나는 모델에 전송된 정확한 프롬프트입니다.
이러한 투명성은 단순히 있으면 좋은 기능(nice-to-have)이 아닙니다. 답변이 틀려 보일 때, 어느 쪽이 잘못되었는지 알아야 합니다. 검색 (retrieval)이 잘못된 제품을 가져온 것인지, 아니면 모델이 올바른 제품을 잘못 읽은 것인지 말입니다. 검색 결과와 프롬프트를 보여주면 한눈에 파악할 수 있습니다.
몇 가지 실제 예시:
| 질문 내용 | 발생하는 일 |
| --- | --- |
| `300 kg 이상의 진공 펌프` | 무게 필터 + 의미론적 매칭 (semantic match) |
| ... | |
## 개선하고 싶은 점
이 시스템이 아직 '하지 못하는 것'을 말하는 것도 공정할 것입니다.
- **임계값(Threshold)은 단 하나의 숫자입니다.** `MIN_SIM = 0.45`가 이 카탈로그에 적합합니다. 너무 높으면 좋은 답변이 사라지고, 너무 낮으면 쓰레기 데이터가 섞여 들어옵니다. 더 똑똑한 버전이라면 감에 의존하는 대신 실제 쿼리(Query)를 통해 이를 조정할 것입니다.
- **리랭커(Reranker)가 없습니다.** 수백 개의 제품 정도라면 코사인 유사도(Cosine similarity) 순서만으로도 충분합니다. 규모를 확장한다면 상위 결과들을 정리하기 위해 리랭킹(Reranking) 단계를 추가해야 할 것입니다.
- **평가(Eval)가 수동입니다.** 저는 몇 가지 알려진 쿼리를 직접 손으로 확인합니다. 진정한 해결책은 소규모의 `(질문, 예상_id)` 쌍을 구축하는 것이며, 그래야 프롬프트(Prompt) 수정이 검색(Retrieval) 성능을 조용히 망가뜨리는 것을 방지할 수 있습니다.
그렇다고 해서 전체적인 구조가 변하는 것은 아닙니다. 신중하게 수집하고, 정직하게 정제하며, 단순하게 임베딩하고, 설계 단계부터 거절하십시오. 거절하는 기능이야말로 시스템을 신뢰할 수 있게 만드는 요소이며, 이는 모델에게 정중하게 부탁해서 얻어지는 것이 아니라 아키텍처(Architecture)로부터 나옵니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기