2026. 6. 21. 09:15ㆍAI/LLM
1. 기본 RAG의 문제 및 한계

결론은 RAG의 중요한 핵심은 LLM이 아니라, Retrieval (검색)이라는 것이다.
2. 해결 방안
그전에 사용할 기능들을 모두 함수로 만들어 줄 것이다.
# pdf -> chunks 반환 함수
def create_chunks_from_pdf(pdf_path, chunk_size=500, chunk_overlap=50):
loader = PyPDFLoader(pdf_path)
splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
chunks = splitter.split_documents(loader.load())
# 공백 제거
chunks = [chunk for chunk in chunks if chunk.page_content.strip()]
return chunks
def create_vector_store(chunks, embeddings, collection_name, persist_directory='./db/chroma_db'):
vector_store = Chroma.from_documents(chunks=chunks, embedding=embeddings, persist_directory=persist_directory, collection_name=collection_name)
return vector_store
def create_retriever(vector_store, search_type='similarity', k=3, fetch_k=20, lambda_mult=0.5):
kwargs = {"k": k}
if search_type == 'mmr':
kwargs['fetch_k'] = fetch_k
kwargs['lambda_mult'] = lambda_mult
return vector_store.as_retriever(search_type=search_type, search_kwargs=kwargs)
def print_retrieved_docs(title, retriever, query):
docs = retriever.invoke(query)
print("\n" + "=" * 50)
print(title)
print("=" * 50)
for i, doc in enumerate(docs):
print(f"\n[chunk {i}]")
print(doc.page_content)
1) 임베딩 모델, 청크 사이즈, 오버랩 사이즈 변경
청크 사이즈와 오버랩 사이즈를 변경하고, 자르고 나서 청크 수가 차이가 어떻게 나는지 비교한다.
chunks1 = create_chunks_from_pdf("../data/Summary of ChatGPTGPT-4 Research.pdf", 1000, 100)
chunks2 = create_chunks_from_pdf("../data/Summary of ChatGPTGPT-4 Research.pdf", 300, 30)
print(f"분할된 청크 수 : {len(chunks1)}")
print(f"분할된 청크 수 : {len(chunks2)}")

하지만 이것만으로는 검색 성능을 확인할 수 없으므로, 아래와 같이 비교해보자.
vec_store1 = create_vector_store(chunks1, ollama_embeddings, collection_name="gpt_research_ollama_1")
vec_store2 = create_vector_store(chunks2, ollama_embeddings, collection_name="gpt_research_ollama_2")
ollama1_retriever = create_retriever(vec_store1)
ollama2_retriever = create_retriever(vec_store2)
query = 'where can i use chatGPT?'
print_retrieved_docs('chunk=1000,overlap=100', ollama1_retriever, query)
print_retrieved_docs('chunk=300,overlap=30', ollama2_retriever, query)



결과에 따르면 청크 사이즈에 따라서 청크 사이즈가 클 경우,
문맥이 풍부하고 자연스러운 답변이 나오는 반면,
청크 사이즈가 작을 경우, 검색 정확도는 명확해지나,
문맥이 이어지지 못하는 단점이 있기는 하다.
그러나 청크 전략은 직접 테스트하면서 조절해야 한다. 위 이미지처럼 같은 결과가 나올 수도 있기 때문이다.

2) MMR Retriever (Maximal Marginal Relevance)
관련성(Relevance)과 다양성(Diversity) 고려하는 개념으로,
법률 문서, 기술 메뉴얼 처럼 유사 내용이 반복되는 문서에서 효과적이다.
동작 과정은 다음과 같다.


이제 MMR 방식과 Similarity 방식을 비교해본다.
chunks_samsung = create_chunks_from_pdf("../data/2026 상 삼성전자 DX부문 직무기술서.pdf")
vec_store_samsung = create_vector_store(chunks_samsung, ollama_embeddings,persist_directory="./db/ollama_db", collection_name="samsung_ollama")
mmr_retriever = create_retriever(vec_store_samsung, search_type="mmr", k=5)
sim_retriever = create_retriever(vec_store_samsung, k=5)
query = '마케팅 - 제품/서비스 마케팅 포지션은?'
print_retrieved_docs("MMR", mmr_retriever, query)
print_retrieved_docs("Similarity", sim_retriever, query)




MMR 기능은 Similarity 기능에다가 다양성을 확보하는 기능임을 확인할 수 있었다.
다양성 확보 정도에 따라 다음과 같이 파라미터를 튜닝할 수 있다.

3) SelfQuery Retriever
자연어로 질문했을 때 분석하여 시맨틱 검색 쿼리와 메타 데이터 필터를 LLM을 이용해 자동으로 생성하게 하는 고급 기능이다.
실제로 이 기능을 이용하려면, 필터링할 내용을 메타데이터에 담아주어야 한다.

docs = [
Document(
page_content="삼성전자 제품 마케팅 직무입니다.",
metadata= {
"year": 2025,
"department": "marketing"
}
),
Document(
page_content="AI 연구 개발 직무입니다.",
metadata= {
"year": 2024,
"department": "ai"
}
),
Document(
page_content="백엔드 개발 직무입니다.",
metadata= {
"year": 2025,
"department": "developer"
}
)
]
metadata_field_info = [
AttributeInfo(name="year", description="문서 작성 연도", type="integer"),
AttributeInfo(name="department", description="직무 부서", type="string")
]
document_content_description = "회사 내부 문서 및 직무 자료"
doc_vec_store = create_vector_store(docs, ollama_embeddings, collection_name="self_query", persist_directory="./db/ollama_db")
self_query_retriever = SelfQueryRetriever.from_llm(
qwen_llm,
vectorstore=doc_vec_store,
document_contents=document_content_description,
metadata_field_info=metadata_field_info,
verborse=True,
enable_limit=True,
structed_query_translator=ChromaTranslator()
)
question = "2024년 이후 ai 부서 직무를 찾아줘"
self_query_retriever.invoke(question)

docs = [
Document(
page_content="수분 가득한 히알루론산 세럼으로 피부 속 깊은 곳까지 수분을 공급합니다.",
metadata= {
"year": 2024,
"category": "스킨 케어",
"user_rating": 4
}
),
Document(
page_content="24시간 지속되는 매트한 피니시의 파운데이션, 모공을 커버하고 자연스러운 피부 표현이 가능합니다.",
metadata= {
"year": 2023,
"category": "클렌징",
"user_rating": 5
}
),
Document(
page_content="비타민 C 함유 브라이트닝 크림, 칙칙한 피부톤을 환하게 밝혀줍니다.",
metadata={
"year": 2023,
"category": "스킨케어",
"user_rating": 2
}
),
Document(
page_content="롱래스팅 립스틱, 선명한 발색과 촉촉한 사용감으로 하루종일 편안하게 사용 가능합니다.",
metadata={
"year": 2024,
"category": "메이크업",
"user_rating": 4
}
),
Document(
page_content="자외선 차단 기능이 있는 톤업 선크림, SPF50+/PA+++ 높은 자외선 차단 지수로 피부를 보호합니다",
metadata={
"year": 2025,
"category": "선케어",
"user_rating": 5
}
)
]
# 메타데이터 필드 정보 생성
metadata_field_info = [
AttributeInfo(
name="year", description="화장품 출시 연도", type="integer"
),
AttributeInfo(
name="category", description="화장품 카테고리 ['스킨케어','메이크업','클렌징','선케어']", type="string"
),
AttributeInfo(
name="user_rating", description="화장품 평점 (1~5)", type="integer"
)
]
document_content_description = "화장품 제품 정보"
doc_vec_store = create_vector_store(docs, ollama_embeddings, collection_name="self_query2", persist_directory="./db/db_ollama")
self_query_retriever = SelfQueryRetriever.from_llm(
qwen_llm,
vectorstore=doc_vec_store,
document_contents=document_content_description,
metadata_field_info=metadata_field_info,
verborse=True,
enable_limit=True,
structed_query_translator=ChromaTranslator()
)
self_query_retriever.invoke("2024년 이후로 평점이 4 이상인 제품을 추천해줘.")

4) OCR
PDF를 읽어왔을 때 이미지화 되어있는 부분은 PDFLoader()로 읽어올 수가 없다.
이럴 땐 어떻게 해야 할까? 예시로 PDF에서 2 페이지만 내용을 추출해보자.
pdf_path = "../data/2026 상 삼성전자 DX부문 직무기술서.pdf"
chunks = create_chunks_from_pdf(pdf_path)
for i in range(2):
print("=" * 50)
print(chunks[i].page_content[:500])

첫번째 회로 개발 부분은 텍스트라서 제대로 읽어왔지만,
두번째 이미지 부분은 아예 읽어오지 못한 모습이다. (아마도 불릿 기호만 읽어온 것 같다)
이 부분을 읽어오기 위해 OCR 라이브러리를 설치한다.
!pip install easyocr pymupdf
OCR의 원리는 PDF를 각 장마다 이미지로 만든 후 이미지에서 텍스트로 추출하는 방식이다.
이미지로 만들어주는 라이브러리가 pymupdf이고, 이미지에서 텍스트를 추출해주는 것이 easyocr인 것이다.
이미지로 변환해주는 코드는 아래와 같다.
# PDF를 이미지로 변환
import fitz
pdf = fitz.open(pdf_path)
for page_num in range(len(pdf)):
page = pdf[page_num]
pix = page.get_pixmap(dpi=300)
pix.save(f"./convert/page_{page_num}.png")
이 이미지에서 텍스트를 추출하는 코드는 아래와 같다.
# 이미지에서 텍스트를 추출
import easyocr
reader = easyocr.Reader(['ko','en'])
result = reader.readtext("./convert/page_19.png",detail=0,paragraph=True)
print("\n".join(result))

import json
pages = []
for page_num in range(31):
image_path = f"./convert/page_{page_num}.png"
result = reader.readtext(image_path,detail=0,paragraph=True)
text="\n".join(result)
pages.append({"page": page_num + 1, "content": text })
with open("../data/samsung_ocr.json","w", encoding="utf-8") as f:
json.dump(pages, f, ensure_ascii=False, indent=2)

5) BM25 Hybrid Retriever
유사도 검색은 의미적 유사성 위주인데,
정확한 제품명, 코드, 버전, 고유 명사 등의 키워드 매칭에는 약하다. 그래서 유사도 검색의 단점을 보완하기 위해 등장했다.
TF-IDF 알고리즘 기반이며 Semantic + BM25 방식이 혼합된 것.

예를 들어, 환불 가능한 기간이 어떻게 되나요? 라고 질의했다.
정확히 환불 가능한 기간을 찾아야 하므로, 이런 것은 의미 유사도 검색이 유리하다.
그런데, ERR_CONNECTION 오류 같은 키워드 검색의 경우 SParse 방식이 유리하다.
그래서 상황에 따라 섞어서 쓰자는 내용이다.
먼저 다음 라이브러리를 설치한다.
!pip install rank-bm25
그리고 위에서 .json 파일로 변환한 파일로 Document 객체를 이용해서 문서화 시킨다.
이 과정은 RAG 과정에서 pdf 파일을 로드했던 과정과 동일하다.
with open("../data/samsung_ocr.json","r", encoding="utf-8") as f:
pages = json.load(f)
docs = []
for page in pages:
docs.append(
Document(
page_content=page['content'],
metadata={
"page": page["page"],
"source": "samsung_ocr"
}
)
)
다음 단계로 스플릿 하는 과정이 필요하다.
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
그 다음, 벡터 스토어를 생성한다.
vectorstore = create_vector_store(chunks=chunks, embeddings=ollama_embeddings, persist_directory="./db/chroma_db", collection_name="samsung_dx")
그리고 벡터 스토어에서 유사도 검색만 할 거였다면 다음과 같이 작성하면 됐었다.
vectorstore.similarity_search("삼성이란 뭐야?", k=3)
그러나, 다양한 방식으로 확장하기 위해서 as_retriever로 변환하여 Langchain과 연동하도록 만든다.
그리고 여기서 유사도 검색과 SParse 방식을 섞는다. (EnsembleRetriever)
여기서 weight 셋팅은 유사도 검색과 키워드 검색의 비중을 나누는 부분이라고 보면 된다.
# 유사도 검색
dense_retriever = create_retriever(vectorstore)
# 키워드 검색
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4
# 검색 전략 - 비중 나누기
hybrid = EnsembleRetriever(retrievers=[dense_retriever, bm25_retriever], weights=[0.5,0.5])
query = "시스템 소프트웨어 자격 요건에 운영체제 개념도 포함되어 있어?"
print_retrieved_docs("hybrid", hybrid, query)

6) ReRanking & Contextual Compression
Retriever가 유사도 점수가 높은 순으로 가져오는데 여기에다가 질문을 추가해서 다시 ReRank 작업을 하는 것이다.
그 ReRank된 순위를 기반으로 다시 뽑아오는 것이 ReRanking이다.
일반 유사도 검색은 Bi-Encoder를 사용하는데, 쿼리(질문)와 문서를 각각 벡터로 변환 후에 계산하는 방식이다.
ReRanking은 Cross-Encoder를 사용하며, 쿼리(질문)와 문서를 묶어서 벡터로 변환 후에 계산하는 방식으로써
일반 유사도 검색에 비해 정확도가 높으나 매우 느린 단점이 있다.
단, 처음부터 Cross-Encoder 사용은 불가하며, 먼저 Bi-Encoder로 후보를 추출하고 재정렬 해야한다.

먼저 다음과 같은 라이브러리를 설치한다.
!pip install cohere langchain-cohere sentence-transformers
cohere는 API_KEY가 필요하므로 접속해서 API_KEY 발급 받아야 한다.
엔터프라이즈 AI: 개인, 보안, 맞춤형 | Cohere
Cohere는 기업이 프로세스를 자동화하고 직원을 역량 강화하며 파편화된 데이터를 실행 가능한 통찰력으로 전환할 수 있도록 강력한 모델과 AI 솔루션을 구축합니다.
cohere.com
load_dotenv()
COHERE_API_KEY = os.getenv("COHERE_API_KEY")
한번 검색 결과가 나온 다음에 써야 하므로, 먼저 유사도 검색으로 해야 한다.
# 유사도 검색
dense_retriever = create_retriever(vectorstore, k=20)
query = "원격근무 정책은 어떻게 되나요?"
docs = dense_retriever.invoke(query)
이것을 바탕으로 아래와 같이 ReRanking을 시킨다.
# reranking
reranker = CohereRerank(model='rerank-v4.0-pro', top_n=5)
compression_retriever = ContextualCompressionRetriever(base_compressor=reranker, base_retriever=dense_retriever)
docs = compression_retriever.invoke(query)
print_retrieved_docs("Rerank", compression_retriever, query)

7) LLMChainExtractor
질문과 문서를 같이 LLM에 보내서 질문과 관련된 내용만 추출하는 기술이다.
예시로 다음과 같은 질의를 했을 때 수많은 청크 페이지들이 나온다.
# 문서 로드 / 청크 추출
chunks = create_chunks_from_pdf("../data/제주관광가이드.pdf")
# 인덱싱 - vectorstore
vectorstore = create_vector_store(chunks, ollama_embeddings, collection_name="jeju_guide")
# 질의
base_retriever = create_retriever(vectorstore, k=20)
docs = base_retriever.invoke("생활 속 제주어에서 엄불랑하다는 무슨 뜻이야?")
for doc in docs:
print(doc.page_content[:500])
print('-'* 10)

여기에서 간략하게 청크 파일을 줄이고 싶다는 것에 등장한 것이다. (답변만 축약)
# 답변만 축약
extractor = LLMChainExtractor.from_llm(qwen_llm)
compression_retriever = ContextualCompressionRetriever(base_compressor=extractor, base_retriever=base_retriever)
docs = compression_retriever.invoke("생활 속 제주어에서 엄불랑하다는 무슨 뜻이야?")
for doc in docs:
print(doc.page_content[:500])
print('-'* 10)

8) EmbeddingFilter
임계값을 기준으로 미달한 문서를 제외하는 기능이다.
'AI > LLM' 카테고리의 다른 글
| 에이전트(Agent) - 리서치 자동화 에이전트 구현 (2) (0) | 2026.06.22 |
|---|---|
| 에이전트(Agent) - 개념 (1) (0) | 2026.06.22 |
| 검색 증강 생성(RAG) - PDF RAG 학습 앱 (2) (0) | 2026.06.21 |
| 검색 증강 생성(RAG) - 개념 (1) (0) | 2026.06.20 |
| 랭체인(LangChain) - 심화 내용 (3) (0) | 2026.06.20 |