요즘 나는 GPT를 사용해서 나에대한 정보를 요약 정리할 일이 많아졌는데, 요청을 하다보면 나에대한 정보를 잊어버리는 것 같다. 이를 해결하기 위해 Rag 기법을 사용해서 나에대한 정보 요청을 할때마다 나의 기반 정보를 첨부하여 보내도록 해보자.
RAG란?
대형 언어 모델(LLM)은 학습 시점 이후에 나온 정보나 조직 내부의 비공개 자료를 알지 못한다는 한계가 있다. RAG는 “검색(Retrieval) → 생성(Generation)” 두 단계를 결합해 이 문제를 해결한다. 질의가 들어오면 벡터 DB 등에서 관련 문서를 찾아 LLM 프롬프트에 주입하고, 모델은 이를 근거로 답변을 생성한다.
벡터 DB란?
벡터 DB는 “문서 조각(chunk)”을 임베딩 벡터로 변환해 저장하고, 질의 임베딩과 유사도가 높은 벡터를 빠르게 검색해 주는 역할을 한다. 일반적인 RDS는 정확한 값을 기반으로 조회한다면 벡터 DB는 유사도를 기반으로 정보를 조회한다. Elasticsearch도 vector field를 지원해 벡터 검색이 가능하나, 본격적인 ANN 최적화(대규모·저지연)는 전용 벡터 DB 대비 떨어진다고 한다. 나는 이번 예제에서 벡터DB인 Qdrant를 사용해볼것이다.
실습
빠른 실습을 위해 python을 사용할 예정이다. 예제 코드는 아래와 같다.
Qdrant 실행
# - Docker에서 Qdrant 실행:
# docker run -p 6333:6333 qdrant/qdrant
파이썬 코드를 작성하기 전에 Qdrant를 Docker를 통해 띄워놓고 진행한다.
예제 코드 실행
벡터 DB 사용 예제
# 필요한 패키지 설치
# pip install qdrant-client sentence-transformers
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams
from sentence_transformers import SentenceTransformer
# 1. Qdrant 클라이언트 연결 (Docker로 로컬 실행 전제)
client = QdrantClient(url="http://localhost:6333")
collection_name = "kim_minsu_profile"
# 2. 인물 정보 데이터 (홍길동)
minsu_docs = [
("1", "홍길동은 1990년생으로, 서울대학교 컴퓨터공학과를 졸업했다."),
("2", "주요 경력은 카카오에서 백엔드 개발자로 5년, 네이버에서 데이터 엔지니어로 3년 근무했다."),
("3", "파이썬, 자바, 스칼라에 능숙하며, 분산 시스템과 머신러닝에 관심이 많다."),
("4", "2022년부터는 AI 스타트업에서 CTO로 재직 중이다."),
("5", "취미는 등산과 독서, 최근에는 마라톤 대회에도 참가했다."),
("6", "홍길동의 이메일 주소는 rag.test@email.com 이다."),
("7", "네이버 근무 당시 데이터 파이프라인 자동화 프로젝트를 리드했다."),
("8", "AI 스타트업에서는 챗봇 개발과 RAG 아키텍처 구축을 담당했다."),
("9", "서울특별시 강남구에 거주한다."),
("10", "최근 발표 논문은 'Scalable RAG Pipeline for Korean Language'이다.")
]
# 3. 컬렉션 생성 (이미 존재할 경우 자동 삭제 후 생성)
client.recreate_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=384, distance=Distance.COSINE)
)
# 4. 임베딩 모델 준비
embedder = SentenceTransformer('all-MiniLM-L6-v2')
ids, texts = zip(*minsu_docs)
embeddings = embedder.encode(list(texts), convert_to_numpy=True)
# 5. Qdrant에 인물 정보 삽입
points = [
PointStruct(id=int(_id), vector=vec.tolist(), payload={"text": txt})
for _id, vec, txt in zip(ids, embeddings, texts)
]
client.upsert(collection_name=collection_name, points=points)
# 6. 자연어 질의 기반 RAG 검색 함수 정의
def rag_search(query, k=3):
q_vec = embedder.encode([query], convert_to_numpy=True)[0].tolist()
hits = client.search(
collection_name=collection_name,
query_vector=q_vec,
limit=k
)
print(f"[Query] {query}")
print("-" * 40)
for hit in hits:
print(f"• {hit.payload['text']} (Score: {hit.score:.4f})")
print()
# 7. 예시 질의
rag_search("홍길동의 전공과 학교는?")
rag_search("홍길동은 어떤 회사에서 일했어?")
rag_search("홍길동의 최근 주요 프로젝트나 연구는?")
rag_search("이메일 알려줘")
rag_search("사는 곳은 어디야?")

해당 코드를 실행시켜본다면 위와 같은 결과를 볼 수 있다. 하지만 생각보다 적중률이 많이 낮은 것 같다. 그렇게 생각한 이유는 "이메일 알려줘"라는 쿼리에 대해서는 엉뚱한 답변이 돌아왔기 때문이다.

이는 벡터 DB의 임베딩 모델을 바꿔주면 어느정도 해소가 된다. 기본 모델인 'all-MiniLM-L6-v2' 보다는 한글의 특화된 모델인 'snunlp/KR-SBERT-V40K-klueNLI-augSTS'를 사용하니 이메일에 대한 질의를 잘 응답했다. 하지만 사는곳에대해 물어봤을때 docs에 거주하는곳으로 적혀있어서 그런지 유사도가 낮게 나왔다. 이는 질의와 docs의 데이터 양쪽의 데이터가 너무 짧아서 발생하는 일이다. 다른 한글 특화 모델인 'jhgan/ko-sroberta-multitask'를 써도 결과가 만족스럽지 못했다.
alt_queries = [
"사는 곳",
"거주하는 곳",
"거주지",
"주소",
"현재 거주 중인 지역"
]
이러한 경우에는 LLM을 활용해서 한가지 질의에 대해 다양한 질의로 확장하는 방법으로 해소 할 수 있다. 비슷한 방법으로 docs데이터에 사는곳과 거주지 정보를 중복으로 저장하는 방법도 있다. 이밖에도 청크 방식을 바꿔보거나, ES를 같이 사용하는 하이브리드 기법, 're-ranking'을 사용하는 방법이 있다.
re-ranking : 필요한 n개의 정보보다 더 많은 정보를 빠르게 가져와 cross-encoder를 사용하여 다시 순위를 매겨 상위 n개의 정보를 뽑아 사용하는 기법
Rag 적용
개선을 위한 다양한 기법들은 추후 다시 학습하도록 하고, 먼저 벡터DB와 Gemini와 연계해보도록 하자.
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams
from sentence_transformers import SentenceTransformer
import os
import google.generativeai as genai
# 1. Qdrant 클라이언트 연결 (Docker로 로컬 실행 전제)
client = QdrantClient(url="http://localhost:6333")
collection_name = "kim_minsu_profile"
os.environ["GOOGLE_API_KEY"] = "키값 입력"
# 이전 코드와 동일
def rag_ask_gemini(query, k=3):
# Qdrant에서 관련 문서 검색
q_vec = embedder.encode([query], convert_to_numpy=True)[0].tolist()
hits = client.search(
collection_name=collection_name,
query_vector=q_vec,
limit=k
)
retrieved = [hit.payload['text'] for hit in hits]
# 프롬프트 작성
context = "\n".join(retrieved)
prompt = f"""아래 내용을 참고하여 질문에 답변해줘.
[참고 내용]
{context}
[질문]
{query}
"""
# Gemini 호출
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
model = genai.GenerativeModel('gemini-2.0-flash')
response = model.generate_content(prompt)
print(f"\n[질문] {query}\n")
print(f"[AI 답변] {response.text.strip()}\n")
print("[참고문서]")
for doc in retrieved:
print(" -", doc)
# 질의
rag_ask_gemini("홍길동의 전공과 학교는?")
rag_ask_gemini("홍길동은 어떤 회사에서 일했어?")
rag_ask_gemini("홍길동의 최근 주요 프로젝트나 연구는?")
rag_ask_gemini("이메일 알려줘")
rag_ask_gemini("사는 곳은 어디야?")
간단하게 완성된 코드는 위와 같다.

질의를 하게되면 참고 문서 기반을 통해 Gemini가 잘 응답해 주는 것을 확인 할 수 있다.
PDF 정보 입력
내 정보를 저렇게 배열로 만들어서 넣지 않고 PDF를 통째로 기입하여 사용하고자 한다. 해당 방법은 아래와 같다.
import os
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams
from sentence_transformers import SentenceTransformer
import google.generativeai as genai
import pdfplumber
# 1. 환경설정: Qdrant, Gemini API 키
client = QdrantClient(url="http://localhost:6333")
collection_name = "pdf_chunks"
os.environ["GOOGLE_API_KEY"] = "키 입력"
# 2. PDF에서 텍스트 추출
pdf_path = "example.pdf" # PDF 파일명 입력
with pdfplumber.open(pdf_path) as pdf:
text = "\n".join([page.extract_text() for page in pdf.pages if page.extract_text()])
# 3. 텍스트를 chunk 단위로 분할
def chunk_text(text, chunk_size=300, overlap=50):
return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)]
chunks = chunk_text(text)
print(f"총 {len(chunks)}개 chunk 추출됨.")
# 4. 임베딩 모델 로드 (한국어 문서일 때 추천)
embedder = SentenceTransformer('snunlp/KR-SBERT-V40K-klueNLI-augSTS')
vector_dim = embedder.get_sentence_embedding_dimension()
# 5. Qdrant 컬렉션(벡터 DB) 초기화
if client.collection_exists(collection_name=collection_name):
client.delete_collection(collection_name=collection_name)
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=vector_dim, distance=Distance.COSINE)
)
# 6. chunk별 임베딩 및 Qdrant 등록
embeddings = embedder.encode(chunks, convert_to_numpy=True)
points = [
PointStruct(id=i, vector=vec.tolist(), payload={"text": chunk})
for i, (vec, chunk) in enumerate(zip(embeddings, chunks))
]
client.upsert(collection_name=collection_name, points=points)
# 7. 검색 + Gemini로 답변 생성 함수
def rag_ask_gemini(query, k=3):
q_vec = embedder.encode([query], convert_to_numpy=True)[0].tolist()
hits = client.search(
collection_name=collection_name,
query_vector=q_vec,
limit=k
)
retrieved = [hit.payload['text'] for hit in hits]
context = "\n".join(retrieved)
prompt = f"""아래 내용을 참고하여 질문에 답변해줘.
[참고 내용]
{context}
[질문]
{query}
"""
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])
# 최신 지원 모델명 자동 확인
model_name = "gemini-2.0-flash"
model = genai.GenerativeModel(model_name)
response = model.generate_content(prompt)
print(f"\n[질문] {query}\n")
print(f"[AI 답변] {response.text.strip()}\n")
print("-" * 40)
# 8. 질의 예시
rag_ask_gemini("이 문서의 핵심 주제를 요약해줘.")
rag_ask_gemini("문서에서 등장하는 주요 인물은 누구야?")
rag_ask_gemini("문서에서 다루는 기술적 내용이 뭐야?")
그것을 위한 코드는 위와 같다. 그 원리는 PDF로부터 텍스트를 추출하고 이를 chunk로 나눠 벡터DB에 저장하여 사용하는것이다. 아래 결과를 보면 알 수 있다. 아무래도 해당 기술의 핵심은 위에서 문제가 됐던 벡터DB의 검색 결과이다. 이를 위해 chunk_size와 overlap의 값 설정이 중요할텐데, 해당 값들은 다음과 같은 역할을 한다.
chunk_size :
- 한 번에 벡터 임베딩해서 DB에 저장할 “텍스트 조각”의 최대 길이(자/토큰 수)이다.
- 너무 작으면 문맥 정보가 부족해 질문-문서 매칭률이 낮아짐. “짧은 질의에만 반응하는” 쓸모없는 벡터가 많아져 검색 효율 저하
- 너무 크면 한 chunk 안에 너무 많은 정보가 들어가 “질문-정답 구간”이 멀어지고, 임베딩이 희석되어 관련성 점수도 낮아질 수 있음. LLM 입력 컨텍스트 용량 초과(메모리, 속도 저하 등)
overlap :
- chunk를 만들 때 앞 chunk의 끝부분 일부를 다음 chunk의 앞부분에도 포함시킨다.
- 너무 작으면정답이 두 chunk에 걸쳐 끊기면, 일부 질문에 답을 못 찾게 됨
- 너무 크면같은 내용이 중복으로 저장되어, DB 용량 증가, 중복 검색, 리턴 문서 중복률 상승
해당 값들은 직접 적용해보면서 최적의 값을 찾아 나가야겠다.
결과

코드를 실행시키면 질의에 따라 나에대한 정보를 출력시킬 수 있다.
'Web' 카테고리의 다른 글
| 카프카 리벨런싱 조건 (1) | 2025.07.16 |
|---|---|
| Rag를 사용해서 자신의 전문 비서를 만들어 보자 - Lang Chain (2) | 2025.07.08 |
| MSA에 꼭 필요한 Terraform 사용해보기 (2) | 2025.07.05 |
| ElasticSearch 인덱스 갱신을 위한 Debezium 사용해보기 (1) | 2025.06.22 |
| ElasticSearch를 이용한 검색 기능 만들어보기 - 1 (1) | 2025.06.20 |