[RAG Playground] Quest 3: Naive RAG 파이프라인 구축 (feat. Qdrant)
RAG Playground (9부작)
- 1
- 2
- 3
- 4 [RAG Playground] Quest 3: Naive RAG 파이프라인 구축 (feat. Qdrant) (현재 글)
- 5
- 6
- 7
- 8
- 9
지난 Quest 2에서 부산 가족사랑카드 데이터를 수집했으니, 이제 진짜 RAG 파이프라인을 만들어볼 차례예요. 수집한 2,667건의 업체 데이터를 벡터 DB에 넣고, 자연어로 검색하고, LLM이 답변까지 생성하는 end-to-end Naive RAG를 완성해 보겠습미다 🔥
1. 구조 설계
RAG 파이프라인을 만들면서 처음 한 일은 어댑터 패턴으로 구조를 잡았습니다.
src/rag_playground/
├── adapters/
│ ├── data_go_kr/ # 공공데이터 API 클라이언트
│ ├── llm/ # LLM 어댑터 (OpenAI Chat)
│ └── vectorstore/ # 벡터 DB 어댑터
├── application/ # 유스케이스 (수집, 인덱싱, 질의응답)
├── config/ # 환경 설정
├── domain/ # 도메인 모델 (Document)
└── app/ # CLI 인터페이스
핵심은 어댑터를 교체해도 나머지 코드가 변하지 않는다는 점이에요. 사실 쓸데없는 짓이긴 합니다. 어댑터를 교체할 일이 없어요 그래도 멋있으니까..ㅎㅎ 그리고 지난 번에 ChromaDB로 하려고 했는데, Qdrant Cloud 를 사용하면 무료로 사용이 가능하고 제 로컬에 안해도 되니까 그냥 Qdrant 로~~!
2. Document 모델
벡터스토어에 저장하기 전에, 업체 JSON을 RAG 친화적인 형태로 변환해 보겠습니다.
@dataclass(slots=True)
class Document:
page_content: str # 임베딩/검색용 자연어 텍스트
metadata: dict[str, Any] # 필터링/표시용 구조화 정보 -> 페이로드
page_content는 임베딩이 들어가는 텍스트이고, metadata는 검색 결과를 표시할 때 사용해요.
입력 JSON:
{
"shop_name": "이투스247부산서면점",
"district": "부산진구",
"category": "학원",
"benefit": "세자녀 이상일 경우 수강료 20% 할인"
}
출력 Document:
page_content = "[부산진구 / 학원] 이투스247부산서면점
주소: 부산광역시 부산진구 동천로24번길 16
연락처: 051-803-0247
혜택: 세자녀 이상일 경우 수강료 20% 할인"
metadata = { shop_name, district, category, address, phone, benefit }
여기서 포인트는 page_content에 지역구, 업종, 업체명, 혜택을 자연어 문장처럼 조합해 넣는 겁니다! 임베딩 모델이 이 텍스트를 통째로 벡터화하기 때문에, 어떤 정보를 넣느냐가 검색 품질에 직접적으로 영향을 주거든요 👍
3. 핵심 코드 walkthrough
3-1. Qdrant 어댑터 (adapters/vectorstore/qdrant.py)
def embed_texts(texts: list[str]) -> list[list[float]]:
"""텍스트 리스트를 임베딩 벡터로 변환한다."""
client = OpenAI(api_key=OPENAI_API_KEY)
response = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [item.embedding for item in response.data]
이 함수가 인덱싱과 검색 양쪽에서 호출돼요. 인덱싱할 때는 문서 텍스트를, 검색할 때는 사용자 쿼리를 임베딩하는 거죠. 임베딩 모델은 text-embedding-3-small을 사용했는데, 1536차원이고 비용 대비 성능이 괜찮아서 실험용으로 딱입니다
3-2. 인덱싱 (application/index.py)
def run_index(json_path):
documents = load_shop_documents(path) # JSON → Document 리스트
collection_name = get_or_create_collection() # Qdrant 컬렉션 확인/생성
return index_documents(documents, collection_name) # 배치 인덱싱
각 함수를 좀 더 살펴보면:
load_shop_documents() — JSON 파일을 읽어서 Document 리스트로 변환을 해줍니다.
def load_shop_documents(json_path: str | Path) -> list[Document]:
path = Path(json_path)
with path.open("rb") as file:
# orjson 성능이 json 보다 Good
shops: list[dict[str, Any]] = orjson.loads(file.read())
return [shop_to_document(shop) for shop in shops]
get_or_create_collection() — Qdrant에 컬렉션이 이미 있으면 그대로 쓰고, 없으면 새로 생성해요. 벡터 차원(1536)과 거리 함수(Cosine) 설정도 여기서 잡아줍니다.
def get_or_create_collection(
client: QdrantClient | None = None,
collection_name: str = COLLECTION_NAME,
) -> str:
if client is None:
client = get_qdrant_client() # get_qdrant_client 는 싱글톤으로
existing = [c.name for c in client.get_collections().collections]
if collection_name not in existing:
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=EMBEDDING_DIMENSION, distance=Distance.COSINE),
)
return collection_name
index_documents() — Document 리스트를 100건씩 배치로 나눠서, embed_texts()로 임베딩한 뒤 Qdrant에 upsert해요.
def index_documents(
documents: list[Document],
collection_name: str = COLLECTION_NAME,
client: QdrantClient | None = None,
batch_size: int = 100,
) -> int:
if client is None:
client = get_qdrant_client()
total = len(documents)
for start in range(0, total, batch_size):
batch = documents[start : start + batch_size]
texts = [doc.page_content for doc in batch]
vectors = embed_texts(texts)
points = [
PointStruct(
id=start + idx,
vector=vectors[idx],
payload={"page_content": texts[idx], **batch[idx].metadata},
)
for idx in range(len(batch))
]
client.upsert(collection_name=collection_name, points=points)
return total
전체 인덱싱에 약 64초 정도 걸렸는데, 대부분 OpenAI API 호출 대기 시간입니다. 비용도 얼마 안드니까 걱정마세요
3-3. 질의응답 (application/answer.py)
def answer_query(query, collection_name, n_results=5):
hits = search(query, collection_name=collection_name, n_results=n_results)
answer = generate_answer(query, hits) # GPT-4o-mini로 답변 생성
return hits, answer
흐름은 단순해요: 벡터 검색 → 컨텍스트 조합 → LLM 호출. 사용자 쿼리를 임베딩해서 Qdrant에서 가장 유사한 문서 5개를 가져오고, 그걸 프롬프트에 넣어서 GPT-4o-mini에게 답변을 생성하게 합니다 👀
4. 테스트 결과
인덱싱
총 2,667건 문서 변환 완료
Qdrant 컬렉션 'family_card_shops' 생성 완료
임베딩 & 인덱싱 시작...
인덱싱 진행: 100/2667
...
인덱싱 진행: 2667/2667
=== 인덱싱 완료: 2667건, 64.1초 소요 ===
2,667건 전부 무사히 인덱싱 완료! Qdrant Cloud 대시보드에서도 포인트 수가 정확히 찍히는 걸 확인했습니다 👍
검색 테스트
예시 쿼리: "서면에서 할인되는 병원"
1. 장림한서병원 (사하구)
혜택: 비급여 항목에 대해서만 10% 할인
거리: 0.4096
2. 박성인 안과의원 (동래구)
혜택: 진료비 및 치료비 20% 할인
거리: 0.4172
3. 상쾌한병원 (수영구)
혜택: 비급여 진료비 10% 할인(다자녀 기준: 3인)
거리: 0.4281
결과를 보면 꽤나 흥미로운데요 “서면”이라는 지역 키워드가 직접 매칭되진 않았지만, “할인 + 병원”이라는 시맨틱 의미로 병원 업체들을 잘 찾아냈거든요 👀 이게 바로 벡터 검색의 장점이자 한계인 것 같아요:
- 장점: “할인되는 병원”이라는 의미를 이해해서 관련 업체를 찾아냄
- 한계: “서면”이라는 정확한 지역명을 무시함 — 결과가 사하구, 동래구, 수영구로 흩어짐
실제로 업무에서도 비슷한 경험을 했는데, dense 검색(벡터)만으로는 고유명사나 지역명 같은 키워드를 정확히 잡아내기 어렵더라고요. 결국 sparse 검색(BM25 같은 키워드 매칭)을 함께 써야 하는 이유가 여기 있는 것 같아요. 다음 퀘스트에서 Hybrid Search로 이 한계를 넘어보겠습니다 😏
5. 다음 퀘스트
Naive RAG가 돌아가는 것을 확인했으니, 이제 개선을 해볼 차례입니다
Quest 4: Hybrid Search — 키워드 검색(BM25) + 벡터 검색을 결합해서, “서면” 같은 지역명도 정확히 매칭되도록 만들 예정입니다. 오늘 테스트에서 드러난 한계를 정면으로 돌파해 보겠습니다 🏃♂️
공공데이터로 RAG 뚝딱거려보기 시리즈
- 프롤로그
- Quest 1: 프로젝트 환경 세팅
- Quest 2: 공공데이터 API 연동
- Quest 3: Naive RAG 파이프라인 구축 ← 현재