앞 장에서 우리는 LLM의 진화가 생성형 AI의 지형을 어떻게 바꾸었는지 살폈고, 그 함정도 함께 논했다. 이 장에서는 그 함정을 검색 증강 생성(Retrieval-Augmented Generation, RAG)으로 피하는 방법을 탐구한다. RAG가 무엇을 뜻하는지, 그 아키텍처는 어떤 모습인지, 그리고 더 나은 지능형 애플리케이션을 만드는 LLM 워크플로 안에 RAG가 어떻게 자리 잡는지를 본다.
기술 요건
이 장을 따라가려면 Python(3.6 이상 권장)과 딥러닝의 기초 개념에 익숙해야 한다. RAG를 만들고 실험하는 데 Hugging Face의 Transformers 라이브러리 같은 대중적인 AI 툴킷을 활용한다. 필수는 아니지만 Git 버전 관리의 기본을 알아두면 도움이 된다. 코드를 직접 찾거나 타이핑할 필요는 없다. 이 장의 실습 전체가 담긴 공개 저장소(github.com/PacktPublishing/…/ch2)가 마련되어 있고, RAG 모델 구현과 Neo4j 통합에 필요한 스크립트·파일·설정이 모두 들어 있다. 환경에 설치해야 할 Python 라이브러리는 다음과 같다.
모델 관련 기능을 다루는 Hugging Face 라이브러리.
연산 백엔드. 시스템에 맞는 버전을 공식 안내에 따라 설치한다.
유사도 계산에 쓴다.
수치 연산의 기본기.
특정 모델의 텍스트 토크나이제이션에 필요하다.
키워드 기반 검색을 위한 BM25 알고리즘 구현.
대규모 데이터셋을 최소 메모리로 로딩·처리·변환하는 Hugging Face 도구.
표 형태 데이터 조작. 데이터셋을 DataFrame으로 바꿔 전처리를 돕는다.
밀집 벡터의 효율적 유사도 검색·클러스터링. 추론 시 관련 지문을 가져오는 리트리버 구축에 쓴다.
분산 훈련·추론을 단순화하는 Hugging Face 라이브러리. CPU·GPU·멀티노드의 하드웨어 활용을 최적화한다.
이 장의 모든 절은 핵심 코드 조각에 집중한다. 전체 코드는 책의 GitHub 저장소를 참고하면 된다.
(:Section {n: 1})-[:GROUNDS]->(:Generation)
RAG의 힘을 이해하다
RAG는 2020년 Meta의 연구자들이 소개한 프레임워크다(arxiv.org/abs/2005.11401). 모델 훈련에 포함되지 않은 외부 데이터를 생성형 AI 모델이 활용해 출력을 강화하도록 하는 틀이다.
LLM이 환각(hallucination)에 시달린다는 것은 널리 알려진 사실이다. 실세계의 고전적 사례가 하나 있다.
뉴욕의 이 로펌은 콜롬비아 항공사 Avianca를 상대로 한 소송에서 OpenAI의 ChatGPT가 생성한 가짜 판례가 담긴 준비서면을 제출했다가 벌금을 부과받았다. 수천 달러의 벌금은 시작에 불과했고, 평판의 손상으로 잃은 것은 그보다 컸을 것이다. 자세한 내용은 Sky News 보도에서 확인할 수 있다.
LLM의 환각은 여러 요인에서 비롯된다.
- 훈련 데이터 과적합훈련 중 LLM이 훈련 데이터의 통계적 패턴에 과적합할 수 있다. 그 결과 모델은 사실적으로 정확한 콘텐츠를 생성하기보다 그 패턴을 복제하는 쪽을 우선하게 된다.
- 인과 추론의 결여LLM은 단어 사이의 통계적 관계를 찾는 데 탁월하지만 원인과 결과의 관계를 이해하는 데는 서툴 수 있다. 문법적으로는 옳지만 사실적으로는 그럴듯하지 않은 출력이 여기서 나온다.
- 온도 설정LLM에는
temperature라는 파라미터가 있다. 0과 1 사이의 수로 텍스트 생성의 무작위성을 제어한다. 온도가 높을수록 창의성이 커지지만, 모델이 기대되는 응답에서 이탈하면서 환각의 가능성도 함께 커진다. - 정보의 부재정확한 응답에 필요한 정보가 훈련 데이터에 없으면, 모델은 그럴듯하게 들리지만 틀린 답을 만들어낼 수 있다.
- 결함·편향 데이터훈련 과정의 품질이 큰 역할을 한다. 데이터셋에 편향이나 부정확성이 들어 있으면 모델은 그 문제를 영속화하고, 환각으로 이어진다.
환각은 중대한 도전이지만, 어느 정도 완화할 방법들이 있다.
완화법 하나 — 프롬프트 엔지니어링
프롬프트 엔지니어링(prompt engineering)은 LLM에 주는 지시나 질의를 신중히 다듬고 반복적으로 정제해 일관되고 정확한 응답을 끌어내는 작업이다. "Neo4j에 대해 말해 달라"는 광범위한 질의보다 "지식그래프를 위한 Neo4j의 핵심 이점 다섯 가지를 나열하라"는 질의가 더 많은 구조와 정밀성을 제공한다. 전자는 장황하거나 곁길로 새는 응답을 낳기 쉽지만, 후자는 기대 출력을 명시하므로 모델이 간결하고 적절한 이점 목록에 집중하게 된다. 프롬프트 엔지니어링은 모델이 원하는 정보 범위 안에 머물도록 이끌어, 무관하거나 조작된 출력을 만들 가능성을 줄인다. 기법과 모범 사례의 상세한 탐구는 Google Cloud의 가이드를 참고하면 된다.
완화법 둘 — 인컨텍스트 러닝
인컨텍스트 러닝(in-context learning), 곧 퓨샷 프롬프팅(few-shot prompting)은 프롬프트 안에 예시를 포함시켜 LLM을 정확하고 과업 특화된 응답으로 이끄는 방법이다. 제품 비교를 요청할 때 잘 구조화된 비교의 예시 몇 개를 프롬프트에 넣어 주면, 모델이 그 패턴을 모방한다. 주어진 예시로부터 문맥을 추론하고 응답을 조정하는 모델의 능력을 지렛대 삼는 접근이며, 도메인 특화 과업에 효과적이다.
완화법 셋 — 미세조정과 RLHF
미세조정(fine-tuning)은 이미 사전훈련된 LLM을 특정 데이터셋으로 추가 훈련해 전문 도메인이나 과업에 적응시키는 방법이다. 도메인 특화되고 적절하며 정확한 응답을 생성하는 능력이 이 과정에서 강화된다. 대표적 기법이 인간 피드백 기반 강화학습(Reinforcement Learning with Human Feedback, RLHF)이다. 인간 평가자가 모델의 출력에 점수를 매기고, 그 점수로 모델의 행동을 조정해 인간의 기대에 정렬시킨다. 회사 내부 문서로 미세조정한 LLM에 "신입 온보딩 절차를 설명하라"고 물으면, 그 회사의 정책에 부합하는 상세한 설명이 나온다. 범용 모델이라면 모호하거나 무관한 답을 내놓았을 것이다. RLHF가 응답을 어떻게 개선하는지, 시나리오 하나로 보자.
RLHF가 환각을 줄이는 데 특히 값진 이유는, 인간이 선별한 피드백으로부터의 학습을 강조하기 때문이다.
이 방법들은 상당한 개선을 가져다준다. 그러나 결정적인 한 지점에서 여전히 부족하다. 조직이 도메인 특화 지식을 활용해 정확하고 맥락적이며 설명 가능한 생성형 AI 애플리케이션을 신속하게 구축하도록 해 주지는 못한다는 것이다. 해법은 접지(grounding)에 있다. 모델의 응답을 실세계의 사실이나 데이터에 묶어 두는 개념이다. 이 접근이 텍스트 생성의 새로운 패러다임, 곧 RAG의 토대를 이룬다. 신뢰할 수 있는 지식 소스에서 사실 정보를 동적으로 검색함으로써, RAG는 출력이 정확하면서도 맥락에 정렬되도록 보장한다.
검색 증강 생성이라는 용어는 Facebook AI Research(FAIR)의 연구자들이 2020년 5월 제출한 논문 Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks에서 처음 도입했다. 논문은 RAG를 신경 리트리버와 시퀀스-투-시퀀스 생성기를 결합한 하이브리드 아키텍처로 제안했다(그림 2.1). 리트리버가 외부 지식 베이스에서 관련 문서를 가져오면, 생성기가 그 문서를 문맥으로 삼아 사실 데이터에 접지된 출력을 만든다. 이 접근은 오픈 도메인 질의응답과 대화 시스템 같은 지식 집약적 NLP 과업에서 성능을 크게 끌어올렸다. 모델 내부 지식에 대한 의존을 줄이고 사실 정확성을 높인 결과다.
RAG는 앞서 말한 LLM의 결함들을, 결정적인 요소 하나를 도입함으로써 해소한다. 보충적이거나 도메인 특화된 데이터 소스에서 관련 지식을 검색하는 능력이다. 덧붙여 RAG 파이프라인은 정확성을 유지하면서 모델 크기를 줄일 잠재력도 지닌다. 모든 지식을 모델 파라미터 안에 새겨 넣으려면 막대한 자원이 들지만, RAG는 모델이 정보를 동적으로 검색하게 함으로써 모델을 가볍고 확장 가능하게 유지한다.
(:Section {n: 2})-[:DECONSTRUCTS]->(:RAGFlow)
RAG 흐름의 해부
이제 RAG 모델의 구성 요소를 해부해 그것이 어떻게 작동하는지 이해할 차례다. 먼저 RAG가 없는 통상의 LLM 애플리케이션 흐름부터 본다.
사용자가 LLM에 프롬프트를 보내면 벌어지는 일은 이렇다.
- 1 · 프롬프트 전송사용자가 LLM 채팅 API에 프롬프트를 보내는 것으로 과정이 시작된다. 질문일 수도, 지시일 수도, 정보나 콘텐츠 생성을 요구하는 어떤 요청일 수도 있다.
- 2 · API의 처리LLM 채팅 API가 사용자의 프롬프트를 받아 LLM에 전달한다. LLM은 방대한 텍스트 데이터로 훈련된 AI 모델로, 폭넓은 프롬프트와 질문에 인간 같은 텍스트로 소통하고 생성한다.
- 3 · 응답 생성LLM이 프롬프트를 처리해 응답을 만들어낸다. 응답은 LLM 채팅 API로 돌아가고, API가 그것을 사용자에게 전달한다.
이 흐름에서 답을 내놓는 책임은 오롯이 LLM에 있고, 그 사이에 다른 과정은 없다. RAG 없는 요청-응답 흐름의 가장 흔한 형태다. 이제 이 워크플로의 어디에 RAG가 끼어드는지 보자.
그림 2.3에서 보듯, 실제 LLM 서비스 호출 앞에 LLM 요청의 문맥을 제공하는 중간 데이터 소스가 자리한다. 흐름을 단계별로 따라가면 이렇다.
1. 사용자가 프롬프트를 보낸다. 사용자가 채팅 인터페이스를 통해 프롬프트나 질문을 보내며 과정이 시작된다.
2. RAG 모델이 프롬프트를 처리한다. 채팅 API가 프롬프트를 받아 RAG 모델에 전달한다. RAG 모델은 함께 작동하는 두 주요 구성 요소를 가진다. 리트리버(retriever)와 인코더-디코더(encoder-decoder)다.
3. 리트리버. 이 구성 요소는 지식 저장소를 검색한다. 저장소에는 비구조화 문서와 지문뿐 아니라 표나 지식그래프 같은 구조화 데이터도 포함될 수 있다. 역할은 사용자의 프롬프트를 다루는 데 필요한 가장 관련성 높은 정보를 찾아내는 것이다. 간단한 리트리버 예제를 보자. 전체 코드는 ch2/dpr.py에 있다.
# 1) 데모용 문서 집합을 정의한다 documents = [ "The IPL 2024 was a thrilling season with unexpected results.", # ... "Dense Passage Retrieval is a state-of-the-art technique for information retrieval." ] # 2) 문서마다 임베딩을 생성해 콘텐츠 저장소에 담는다 def encode_documents(documents): inputs = tokenizer(documents, return_tensors='pt', padding=True, truncation=True) with torch.no_grad(): outputs = model(**inputs) return outputs.pooler_output.numpy() document_embeddings = encode_documents(documents) # 3) 질의 임베딩으로 저장소를 벡터 검색해 관련 문서를 가져온다 def retrieve_documents(query, num_results=3): inputs = tokenizer(query, return_tensors='pt', padding=True, truncation=True) with torch.no_grad(): query_embedding = model(**inputs).pooler_output.numpy() similarity_scores = cosine_similarity( query_embedding, document_embeddings).flatten() top_indices = similarity_scores.argsort()[-num_results:][::-1] top_docs = [(documents[i], similarity_scores[i]) for i in top_indices] return top_docs
Query: What is Dense Passage Retrieval?
Top Results:
Score: 0.7777, Document: Dense Passage Retrieval is a state-of-the-art technique for information retrieval. …
리트리버 구현은 꽤 복잡해질 수 있다. BM25나 TF-IDF 같은 효율적 검색 알고리즘을 쓸 수도 있고, Dense Passage Retrieval 같은 신경 리트리버를 쓸 수도 있다. 자세한 내용은 facebookresearch의 GitHub에서 더 읽을 수 있다.
4. 인코더-디코더 / 증강 생성. 인코더는 프롬프트를 검색된 정보(구조화든 비구조화든)와 함께 처리해 포괄적인 표현을 만든다. 디코더는 그 표현을 이용해 정확하고 맥락이 풍부하며 사용자의 프롬프트에 맞춘 응답을 생성한다. 입력 질의와 문맥 정보로 LLM API를 호출하는 과정이 여기 포함된다. Hugging Face의 T5 모델로 그 작동을 보자.
# 1) LLM 정의 — Hugging Face의 T5 tokenizer = T5Tokenizer.from_pretrained('t5-small', legacy=False) model = T5ForConditionalGeneration.from_pretrained('t5-small') # 2) 질의와 문서 정의 — 통상은 리트리버를 쓰지만 데모를 위해 하드코딩 query = "What are the benefits of solar energy?" retrieved_passages = """ Solar energy is a renewable resource and reduces electricity bills. ... """ # 3) 질의 + 검색 지문으로 LLM API를 호출하는 메서드 def generate_response(query, retrieved_passages): input_text = f"Answer this question based on the provided context: {query} Context: {retrieved_passages}" inputs = tokenizer(input_text, return_tensors='pt', padding=True, truncation=True, max_length=512).to(device) with torch.no_grad(): outputs = model.generate( **inputs, max_length=300, # 더 긴 응답 허용 num_beams=3, # 더 나은 결과를 위한 빔 서치 early_stopping=True ) return tokenizer.decode(outputs[0], skip_special_tokens=True)
여기서는 T5의 빔 서치(beam search) 디코딩으로 정확하고 맥락에 맞는 응답을 만든다. 빔 서치는 텍스트 생성에서 가장 그럴듯한 토큰 시퀀스를 찾는 탐색 알고리즘이다. 매 단계 가장 확률 높은 토큰 하나만 고르는 그리디 디코딩과 달리, 빔 서치는 여러 후보 시퀀스(빔)를 동시에 유지하며 탐색한다. 생성 초반에 차선의 선택에 성급히 갇히는 일을 피하므로 고품질 결과를 찾을 가능성이 커진다. Transformers의 빔 서치는 Hugging Face 블로그에서 더 배울 수 있다.
5. 채팅 API가 응답을 전달한다. 다음 코드가 generate_response를 호출해 입력 질의에 대한 채팅 응답을 전달한다.
response = generate_response(query, retrieved_passages) print("Query:", query) print("Retrieved Passages:", retrieved_passages) print("Generated Response:", response)
Query: What are the benefits of solar energy?
Passages: Solar energy is a renewable resource and reduces electricity bills. …
Generated Response: it is environmentally friendly and helps combat climate change
전체 코드는 ch2/augmented_generation.py에 있다.
6. 통합과 미세조정. 마지막으로 리트리버와 LLM 호출을 하나의 완전한 RAG 흐름으로 결합한 코드를 보자.
def rag_pipeline(query): retrieved_docs = retrieve_documents(query) response = generate_response(query, retrieved_docs) return response query = "How does climate change affect biodiversity?" generated_text = rag_pipeline(query) print("Final Generated Text:", generated_text)
흐름은 단순하다. 리트리버로 필요한 문서를 검색하고, 입력 질의와 검색된 문서를 LLM API 호출에 함께 넘긴다. 이번 RAG 아키텍처 심층 탐구에서 우리는 그 역학에 집중해 핵심 구성 요소의 작동을 확인했다. 효율적 정보 검색과 고급 언어 생성 모델을 결합함으로써, RAG는 맥락에 적절하고 지식이 풍부해진 응답을 만들어낸다. 다음 절에서는 검색 과정 자체를 논한다.
(:Section {n: 3})-[:RETRIEVES]->(:Knowledge)
외부 정보의 검색
RAG가 외부 지식을 어떻게 활용하는지 이해하는 것은, 사실적으로 정확하고 유익한 응답을 생성하는 그 능력을 제대로 음미하기 위한 열쇠다. 이 절은 다양한 검색 기법과 검색 정보의 통합 전략을, 실제 예제와 함께 논한다.
RAG 모델의 성패는 방대한 외부 지식 베이스에서 관련 정보를 검색하는 능력에 달려 있다. 이 검색 방법들은 대규모 데이터셋에서 관련 정보를 조달하는 데 필수적이다. 전통적 방법인 BM25부터 DPR 같은 현대적 신경 접근까지 흔히 쓰이는 기법이 여럿인데, 크게 세 범주로 나눌 수 있다. 벡터 유사도 검색, 키워드 매칭, 지문 검색이다. 하나씩 살펴본다.
벡터 유사도 검색
LLM에 전달하는 텍스트나 질의는 임베딩(embedding)이라는 벡터 표현으로 변환된다. 벡터 유사도 검색은 이 벡터 임베딩들을 비교해 가장 가까운 매치를 찾는다. 관련되고 유사한 텍스트는 유사한 임베딩을 가진다는 발상이 바탕에 깔려 있다. 기법의 작동은 이렇다.
- 입력 질의의 임베딩을 만든다. 입력 질의를 토크나이즈하고 그것의 벡터 임베딩 표현을 생성한다.
- 문서들의 임베딩을 만든다. 토크나이저로 문서마다 임베딩을 생성하고, 각 임베딩을 해당 문서와 연결한다.
- 내적 계산으로 유사 문서를 찾는다. 입력 질의 임베딩으로 문서 임베딩들을 검색해 유사한 결과를 얻는다:
torch.matmul(query_embeddings, doc_embeddings.T) - 관련도 점수로 정렬해 반환한다. 결과에는 매칭된 문서와 함께 입력 질의와의 유사도를 나타내는 점수가 담긴다. 가장 유사한 것부터 덜 유사한 순으로 정렬한다.
What are the benefits of solar energy?
Document: Solar energy is a renewable source of power., Score: 80.8264
…
Document: Graph databases like Neo4j are used to model complex relationships., Score: 52.8945
이 코드는 DPR로 질의와 문서 집합을 고차원 벡터 표현으로 인코딩하는 법을 보여준다. 질의 벡터와 문서 벡터의 내적 같은 유사도 점수를 계산해 각 문서의 관련성을 평가하고, 점수순으로 문서를 정렬해 가장 관련 있는 것을 맨 위에 놓는다. 관련 콘텐츠와 무관한 콘텐츠가 뒤섞인 다양한 문서 집합에서도 맥락상 적절한 정보를 효과적으로 가려내는 것, 그것이 벡터 기반 검색의 힘이다. 전체 예제는 ch2/vector_similarity_search.py에 있다.
키워드 매칭
키워드 매칭은 더 단순한 접근이다. 사용자 프롬프트에 들어 있는 키워드를 포함한 문서를 찾는다. 효율적이지만 노이즈에 취약하고, 관련 있는 동의어를 담은 문서를 놓친다. BM25는 키워드 기반의 확률적 검색 함수로, 각 문서에 질의어가 등장하는 정도를 어휘 빈도와 문서 길이를 고려해 점수화한다. 흐름은 이렇다.
# 1) 문서를 토크나이즈해 BM25 코퍼스를 만든다 tokenized_corpus = [doc.split() for doc in corpus] bm25 = BM25Okapi(tokenized_corpus, k1=1.5, b=0.75) # 2) 검색에 쓸 질의를 토크나이즈한다 tokenized_query = query.split() # 3) 토크나이즈된 질의로 코퍼스를 조회 — 매칭 문서의 점수 반환 scores = bm25.get_scores(tokenized_query) # 4) 점수로 문서를 정렬해 반환한다 ranked_docs = sorted(zip(corpus, scores), key=lambda x: x[1], reverse=True)
quick fox
Document: The quick brown fox jumps over the lazy dog., Score: 0.6049
…
Document: Artificial intelligence is transforming the world., Score: 0.0000
BM25는 어휘 빈도(키워드가 문서에 얼마나 자주 나오는가)와 문서 길이에 확률적 점수 함수를 적용해 질의와의 관련성으로 문서 순위를 매긴다. 질의와 문서를 고차원 공간의 밀집 수치 벡터로 표현하고 내적 같은 수학 함수로 유사도를 재는 벡터 유사도 검색과 달리, BM25는 이산적인 단어 매칭 위에서 직접 작동한다. 두 접근의 성격은 뚜렷이 갈린다.
| BM25 — 키워드 매칭 | DPR — 벡터 유사도 | |
|---|---|---|
| 작동 방식 | 이산적 단어 매칭에 직접 작동. 어휘 빈도와 문서 길이 기반의 확률적 점수화. | 질의·문서를 밀집 벡터로 표현하고 내적 등으로 유사도 측정. |
| 강점 | 효율적이고 해석 가능. 설명 가능성과 효율이 중요한 단순 키워드 과업에 적합. | 정확한 키워드가 달라도 개념적 유사성을 포착. 깊은 의미 이해가 필요한 과업에 적합. |
| 약점 | 동의어나 문맥적 의미를 인식하지 못해 의미적 관계에 약하다. | 계산 비용이 상대적으로 크다. |
전체 예제는 ch2/keyword_matching.py에 있다.
지문 검색
문서 전체를 검색하는 대신, RAG는 사용자의 질의를 직접 다루는 문서 안의 특정 지문(passage)에 집중할 수 있다. 더 정밀한 정보 추출이 가능해진다. 초기 흐름은 벡터 검색 접근과 매우 비슷하다. 벡터 검색으로 순위 매겨진 문서를 얻은 뒤, 다음 코드처럼 관련 지문을 추출한다.
# 리더를 위한 지문 추출 passages = [doc for doc, score in ranked_docs] # 리더 입력 준비 inputs = reader_tokenizer( questions=query, titles=["Passage"] * len(passages), texts=passages, return_tensors="pt", padding=True, truncation=True ) # 리더로 가장 관련 있는 지문 추출 with torch.no_grad(): outputs = reader(**inputs) # 점수가 가장 높은 지문 선택 max_score_index = torch.argmax(outputs.relevance_logits) most_relevant_passage = passages[max_score_index]
What are the benefits of solar energy?
Ranked Documents: Solar energy is a renewable source of power., Score: 80.8264 … It has low maintenance costs., Score: 57.9905
Most Relevant Passage: Solar panels help combat climate change and reduce carbon footprint.
이 예제가 보여주는 지문 검색 접근은 문서 수준 검색보다 세밀하다. 사용자 질의를 직접 다루는 특정 지문을 추출하는 데 집중한다. 리트리버에 리더(reader) 모델을 결합함으로써, 가장 관련 있는 문서뿐 아니라 그 안에서 질의에 가장 잘 답하는 정확한 지문까지 식별해 적합성과 특정성을 끌어올린다.
흥미로운 점이 있다. 리트리버 점수가 다소 낮은 지문이라도 리더가 우선시할 수 있다는 것이다. 리더는 문맥적 뉘앙스를 고려해 단어와 스팬(span) 수준에서 관련성을 더 정밀하게 평가하기 때문이다. 리트리버는 통상 질의와 지문 임베딩의 내적으로 유사도 점수를 계산한다.
여기서 q는 질의 임베딩, pi는 i번째 지문의 임베딩, d는 임베딩의 차원 수다.
리더는 여기서 한 걸음 더 나아가 각 지문의 텍스트 내용을 분석한다. 주어진 지문이 답을 담고 있을 가능성에 기반해 관련성 점수, 곧 로짓(logit, 신뢰도 점수)을 부여한다. 이 점수는 질의와 지문 사이의 단어·스팬 수준 상호작용을 고려하는 리더 모델의 원시 출력(로짓)에서 계산되며, 수식으로는 다음과 같다.
logits(pi)는 리더가 지문 pi에 부여한 원시 점수이고, softmax는 그 원시 점수를 확률로 변환해 가장 관련성 높은 지문을 부각한다.
두 단계를 결합함으로써, 시스템은 의미적으로 유사한(리트리버 단계) 동시에 질의의 의도와 문맥적으로 정렬된(리더 단계) 지문을 식별한다. 이 이중 단계 과정이야말로 정보 검색 파이프라인에서 고도로 표적화된 응답을 생성하는 지문 검색의 강점이다. 전체 예제는 ch2/passage_retrieval.py에 있다.
검색된 정보의 통합
RAG 흐름의 마지막 단계로, 리트리버의 정보를 생성 모델과 결합해 맥락에 맞고 일관된 응답을 합성하는 방법을 보자. 앞의 예제들과 달리 이 접근은 여러 개의 검색 지문을 질의와 함께 명시적으로 통합한다. 생성 모델을 위한 단일 입력을 만들어냄으로써, 지문을 고르거나 순위 매기는 데 그치지 않고 통합되고 풍부해진 응답을 합성하도록 하는 것이다.
def integrate_and_generate(query, retrieved_docs): # 질의와 검색 문서들을 단일 입력으로 결합 input_text = f"Answer this question based on the following context: {query} Context: {' '.join(retrieved_docs)}" # T5용 입력 토크나이즈 inputs = t5_tokenizer(input_text, return_tensors="pt", padding=True, truncation=True, max_length=512) # 응답 생성 with torch.no_grad(): outputs = t5_model.generate(**inputs, max_length=100) # 디코딩 후 반환 return t5_tokenizer.decode(outputs[0], skip_special_tokens=True)
이 코드는 검색 문서들을 T5 모델과 통합해 합성 응답을 생성한다. generate() 함수가 결합 입력(질의 + 지문들)을 인코더에 통과시켜 문맥 임베딩 h를 만들고, 디코더가 그 임베딩을 이용해 각 토큰을 확률에 따라 순차적으로 생성한다.
여기서 wt는 위치 t의 토큰, ht는 은닉 상태, W는 모델의 가중치 행렬이다. 빔 서치가 토큰 전체에 걸친 확률을 최대화해 가장 그럴듯한 시퀀스의 선택을 보장한다.
개별 지문을 고르거나 순위 매기던 앞의 예제들과 달리, 이 코드는 여러 검색 문서를 질의와 함께 단일 입력으로 명시적으로 결합한다. T5 모델이 결합된 문맥을 전체적으로(holistically) 처리해 여러 소스의 정보를 아우르는 일관된 응답을 만들 수 있게 되고, 여러 지문에 걸친 합성이나 요약을 요구하는 질의에 특히 효과적이다. 전체 코드는 ch2/integrate_and_generate.py를 참고하면 된다.
다양한 검색 기법과 그것의 생성 모델 통합을 탐구하면서, RAG 아키텍처가 외부 지식을 지렛대 삼아 정확하고 유익한 응답을 만드는 방식을 확인했다. 다음 절에서는 지금까지의 하드코딩된 문장 대신, 소스에서 입력 문서를 읽어 리트리버 흐름에 활용하는 전체적인(holistic) 흐름을 본다.
(:Section {n: 4})-[:ASSEMBLES]->(:Pipeline)
엔드투엔드 RAG 흐름의 구축
앞 절들에서 우리는 RAG 흐름의 각 단계를 단순한 데이터로 하나씩 파고들었다. 이제 한 걸음 물러나, 단순할지언정 실세계 데이터셋으로 전체 흐름을 완성해 볼 때다. GitHub 이슈 데이터셋(huggingface.co/datasets/lewtun/github-issues)을 사용한다. 이 데이터를 읽어 RAG 흐름에 활용하는 방법을 보는데, 이것이 이후 장들에서 완전한 엔드투엔드 RAG 흐름을 구현하는 토대가 된다. 이 예제에서는 GitHub 코멘트를 로딩해 "데이터를 오프라인으로 로딩하려면 어떻게 하는가" 같은 질문에 답할 수 있게 만든다. 데이터 로딩과 리트리버 설정은 다음 단계를 따른다.
- 데이터 준비. Hugging Face의 datasets 라이브러리로 데이터셋을 로딩하고, 풀 리퀘스트를 걸러낸 뒤 코멘트가 있는 이슈만 남긴다:
load_dataset("lewtun/github-issues", split="train") - 관련 컬럼 선택. 분석에 필요한 컬럼만 유지한다:
title,body,html_url,comments. 나머지는 제거한다. - pandas DataFrame으로 변환. 더 쉬운 조작을 위해 데이터셋을 DataFrame으로 바꾼다:
issues_dataset.set_format("pandas") - 코멘트 전개와 재변환. 코멘트를 개별 행으로 평탄화하고(
df.explode("comments")), DataFrame을 다시 데이터셋으로 바꾸고, 각 코멘트의 길이를 계산해 15단어 이하의 짧은 코멘트를 걸러낸다. 이 단계로 데이터가 리트리버 흐름에 쓰기 알맞아진다. - 임베딩용 텍스트 연결. 행마다
title + body + comments를 이어 붙여 그 행을 대표하는 문서 텍스트를 준비한다. 이 문서들이 리트리버 용도의 임베딩 저장소에 담긴다. - 모델과 토크나이저 로딩. 문서를 임베딩으로 변환할 모델을 로딩한다:
sentence-transformers/all-MiniLM-L6-v2 - 임베딩 함수 정의. 위 모델로 임베딩을 생성하는 함수를 정의한다. 문서 하나씩, 모든 문서에 반복 호출할 수 있다.
- 임베딩 계산. 정의한 함수를 코멘트 데이터셋의 모든 문서에 호출한다. 생성된 임베딩은 같은 데이터셋의
embeddings라는 새 컬럼에 저장된다. - 의미 검색 수행. 질문에 대한 리트리버 흐름을 실행한다. 질의 임베딩을 만들고 코사인 유사도로 가장 가까운 문서들을 찾아, 필요에 따라 응답을 다듬는 데 쓴다.
# 질의 정의 question = "How can I load a dataset offline?" # 질의 임베딩 계산 query_embedding = get_embeddings([question]).reshape(1, -1) # 가장 가까운 예시 찾기 embeddings = np.vstack(comments_dataset["embeddings"]) similarities = cosine_similarity( query_embedding, embeddings).flatten() # 결과 표시 top_indices = np.argsort(similarities)[::-1][:5] for idx in top_indices: result = comments_dataset[int(idx)] print(f"COMMENT: {result['comments']}") print(f"SCORE: {similarities[idx]}") print(f"TITLE: {result['title']}") print(f"URL: {result['html_url']}")
How can I load a dataset offline?
COMMENT: Yes currently you need an internet connection because the lib tries to check for the etag of the dataset script …
SCORE: 0.9054 · TITLE: Downloaded datasets are not usable offline · URL: github.com/huggingface/datasets/issues/761
COMMENT: Requiring online connection is a deal breaker in some cases …
SCORE: 0.9052 · TITLE: Discussion using datasets in offline mode · URL: …/issues/824
위 코드가 보여주는 것은 완결된 흐름이다. 리트리버의 기반이 될 데이터 저장소에 데이터를 로딩하는 일부터, LLM이 답을 생성할 때 더 많은 문맥을 제공하는 데 쓸 문서를 검색하는 일까지. 이 실습 예제는 강력한 검색 기법으로 언어 생성을 강화하는 엔드투엔드 RAG 아키텍처의 실제 응용을 보여주었다. 위 코드는 Hugging Face NLP 과정에서 각색한 것이며, 실행 방법의 상세한 설명이 딸린 전체 Python 파일은 ch2/full_rag_pipeline.py에 있다.
요약
이 장에서 우리는 RAG 모델의 세계로 깊이 들어갔다. RAG의 핵심 원리가 무엇이고 전통적 생성형 AI 모델과 어떻게 다른지 이해하는 데서 출발했다. 이 기초 지식은 RAG가 가져다주는 강화된 능력을 제대로 음미하기 위한 무대가 된다.
이어서 상세한 코드 예제로 RAG 모델의 아키텍처를 해부했다. 인코더, 리트리버, 디코더를 하나씩 검토하면서 이 모델들의 내부 작동과, 검색된 정보를 통합해 더 맥락에 맞고 일관된 출력을 만드는 방식에 대한 통찰을 얻었다. RAG가 정보 검색의 힘을 활용하는 방식도 탐구했다. 이 기법들은 RAG가 외부 지식 소스를 효과적으로 활용해 생성 텍스트의 품질을 끌어올리게 하며, 높은 정확성과 맥락 인식이 요구되는 애플리케이션에 특히 유용하다. Transformers와 Hugging Face 같은 대중적 라이브러리로 단순한 RAG 모델을 만드는 법도 배웠다.
다음 장에서는 이 토대 위에 쌓아 올린다. 그래프 데이터 모델링을 배우고, Neo4j로 지식그래프를 만드는 법을 익힌다.