AI 공부

10. LLM이 우리 회사 문서를 읽고 답하게 하려면? — 키워드 매칭에서 임베딩·벡터 DB·LangChain으로 확장해본 RAG 구현기

kdb1248 2026. 4. 19. 17:20

목차

1. RAG는 왜 필요한가
2. 단순 RAG ( 벡터 DB 없이 문자열 매칭을 통한 구현)

3. 벡터 임베딩(Vector Embedding) 이해하기
4. 문서를 벡터 DB에 넣기
5. LangChain으로 기본 RAG 파이프라인 완성
6. RAG의 한계 — 개발한 챗봇을 돌려보니 드러난 문제들
7. 마무리

 

“LLM이 우리 회사 내부 문서를 기반으로 답변하게 만들 수 있을까?”
이번 글에서는 이 질문을 출발점으로 삼아, 키워드 매칭 기반 검색에서 시작해 벡터 임베딩 검색과 LangChain 파이프라인까지 RAG의 기본 흐름을 직접 구현해봤다.
직접 만들어보고 돌려보면서, RAG 작동방식에 대한 이해와, 또 실제 적용 시 어디서 한계가 드러나는지도 함께 살펴봤다.

 


1. RAG는 왜 필요한가

1) LLM 프롬프트를 향상시키는 방법들

LLM이 더 똑똑한 답변을 하게 만드는 방법은 크게 세 가지

  • Multi-shot 프롬프팅 — 예시 여러 개를 미리 보여주기
  • 툴 사용 — 외부 API, 계산기, 검색 엔진 연결
  • 추가 컨텍스트 주입 — 질문과 관련된 배경 정보를 프롬프트에 끼워 넣기

1)-1. RAG의 자리

RAG(Retrieval Augmented Generation)  세 번째 방법인 "추가 컨텍스트 주입"을 자동화·지능화한 기법이다.

사람이 매번 관련 문서를 찾아서 붙여넣는 게 아니라, 질문이 들어오면 시스템이 알아서 관련 문서를 검색해 프롬프트에 끼워 넣는 방식

2) RAG의 핵심 3단계

  1. Knowledge Base 구축 — 전문 지식이 담긴 데이터베이스 만들기
  2. 검색(Retrieval) — 유저가 질문하면 DB에서 관련 내용 쿼리
  3. 쿼리 내용을 프롬프트에 주입 — "여기 추가 정보가 있고, 질문과 관련 있을 수 있어. 참고해서 답변해"라고 LLM에게 전달

LLM이 마치 그 분야 전문가처럼 답변하게 되는 것

 

rag 간단 작동방식

3) 실습 요약 — Insurellm 사내 RAG구축

- 가상의 보험 테크 스타트업 Insurellm.

- 회사의 공유 드라이브(직원 정보, 제품 정보, 계약서 등)를 Knowledge Base로 삼아
전문적인 질의응답이 가능한 AI Knowledge Worker를 만드는 게 목표

 


2. 단순 RAG ( 벡터 DB 없이 문자열 매칭을 통한 구현)

1) 설계 개요

- 가장 단순한 방식으로 시작

- 벡터 DB도, 임베딩 모델도 없이 Python dict + 문자열 매칭 만으로 RAG의 본질 체험 진행

- Insurelllm의 직원, 제품 정보를 답변해주는 전문가 rag 챗봇 구축

 

데이터 구조

  • knowledge-base/employees/ — 직원 정보 마크다운 파일
  • knowledge-base/products/ — 제품 정보 마크다운 파일

2) 세팅 코드

import os
import glob
from dotenv import load_dotenv
from pathlib import Path
import gradio as gr
from openai import OpenAI

load_dotenv(override=True)
openai_api_key = os.getenv('OPENAI_API_KEY')

MODEL = "gpt-4.1-nano"
openai = OpenAI()

3) Knowledge Base를 dict로 로드

knowledge = {}

filenames = glob.glob("knowledge-base/employees/*")

for filename in filenames:
    name = Path(filename).stem.split(' ')[-1]  # 파일명에서 성(last name) 추출
    with open(filename, "r", encoding="utf-8") as f:
        knowledge[name.lower()] = f.read()

# products도 동일하게 추가
filenames = glob.glob("knowledge-base/products/*")
for filename in filenames:
    name = Path(filename).stem
    with open(filename, "r", encoding="utf-8") as f:
        knowledge[name.lower()] = f.read()

key는 직원의 성 or 제품명, value는 문서 전체 내용인 dict가 만들어짐

 

예시
직원의 성이 key 값이 되어 직원문서 내용 검색 가능

4) 핵심: 관련 컨텍스트 찾기

def get_relevant_context(message):
    text = ''.join(ch for ch in message if ch.isalpha() or ch.isspace())
    words = text.lower().split()
    return [knowledge[word] for word in words if word in knowledge]

유저 질문을 단어 단위로 쪼개고, knowledge dict의 key와 일치하는 단어 있으면 해당 문서 가져옴. 단순 매칭임.  

(유저 질문 단어단위로 쪼갤 때 알파벳,공백만 남기고 소문자로 변환한뒤 단어 분리)

 

5) 컨텍스트를 프롬프트에 주입

SYSTEM_PREFIX = """
You represent Insurellm, the Insurance Tech company.
You are an expert in answering questions about Insurellm; its employees and its products.
You are provided with additional context that might be relevant to the user's question.
Give brief, accurate answers. If you don't know the answer, say so.

Relevant context:
"""

def additional_context(message):
    relevant_context = get_relevant_context(message)
    if not relevant_context:
        result = "There is no additional context relevant to the user's question."
    else:
        result = "The following additional context might be relevant in answering the user's question:\n\n"
        result += "\n\n".join(relevant_context)
    return result

관련 문서를 찾으면 프롬프트에 붙이고, 못 찾으면 "관련 정보 없음"을 명시해준다.

예시

 

 

6) 챗 함수 + Gradio

def chat(message, history):
    system_message = SYSTEM_PREFIX + additional_context(message)
    messages = [{"role": "system", "content": system_message}] + history + [{"role": "user", "content": message}]
    response = openai.chat.completions.create(model=MODEL, messages=messages)
    return response.choices[0].message.content

gr.ChatInterface(chat, type="messages").launch(inbrowser=True)

 

- "SYSTEM_PREFIX+ 검색된 컨텍스트" 를 시스템 메시지로 구성 

- 기존 대화 히스토리 + 유저이 새 발화 합쳐서 messages 로 만든 뒤 OPENAI_API 호출 

- Gradio ChatInterface로  챗봇 UI 로 테스트 진행

 

 

7) 실제로 돌려보니 발견한 사항들

앞 세션에선 avery가 누구냐고 질문했을 때 답변이 되는데, 신규 세션에선 불가능하다

질문: "Who is Avery?" (성이 아닌 이름만)

  • knowledge dict에는 성(last name)이 key로 들어 있음 (Avery langcaster 의 문서에선 "langcaster" 가 dict의 key값) 
    → get_relevant_context가 빈 리스트 반환
  • 2번째 신규세션에는 답변이 그래서 안나왔지만, 첫번째 세션 대화에선 답변이 나옴?!

원인은 코드에 있음.

chat 함수는 검색 컨텍스트(유저 질문에서 단어 추출해 해당 값과 일치하는 dict key 값 검색) 뿐 아니라 지금까지의 대화 기록(history) 을 전부 LLM에 전달하고 있음

 

이전 대화에서 "Avery Lancaster"가 언급된 적이 있다면, LLM이 그 히스토리를 보고 답하는 것

 신규 세션에서는 동일 질문에 답 못함. 검색 로직이 동작하지 않는다는 게 드러납니다.

 

8) 이 방식의 한계

  • 직원에 대해 성(dict 값의 key)이 아니라 이름으로 물으면 실패
  • 오타가 있으면 실패 (langcaster 가 아니라 rangcaster 물으면 실패)
  • 동의어/유사어 처리 불가

느낀 점:

이 단계에서 "왜 벡터 검색이 필요한가"가 온몸으로 이해됨.

글자가 똑같아야 매칭되는 게 아니라, 의미가 비슷한 걸 찾아야 실사용 수준의 RAG가 될 수 있다는 걸 파악하게 됨.

단순히 문자열 매칭이 아니라 성이 아닌 이름을 사용해도 작동되거나, 성에 오타있게 입력했어도 이거 바로잡고 매칭되면 좋을 것 

 


3. 벡터 임베딩(Vector Embedding) 이해하기

벡터 검색으로 가기 전에, 임베딩이 뭔지 개념을 정리

1) LLM에는 두 종류가 있다

① Auto-regressive LLM (자기회귀)

  • 입력 시퀀스를 받아 다음 토큰을 예측
  • 한 번에 한 토큰씩, 계속 반복
  • 학습 데이터: 입력 시퀀스와 다음 토큰 쌍이 수없이 많음
  • 예: GPT 계열

② Auto-encoding LLM (인코더 / 임베딩 모델/ 벡터 임베딩 모델)

  • 전체 입력 시퀀스를 받아 1개의 출력 생성 (full input base)
  • 용도:
    • 분류(Classification)
    • Sentiment Analysis
    • 벡터 임베딩 계산
      • 이 입력을 가장 잘 나타내는 숫자 집합 만들기 (입력의 의미 어떤식으로든 반영하려 하는 거)
      • 입력토큰을 받아 벡터로 매핑
      • 예: BERT, OpenAI Embeddings

2) 토큰 vs 벡터

텍스트를 토큰에 매핑, 그 후 벡터는 llm 이 이러한 토큰 가져온 후 반대편에서 나오는 거

  • 입력의 의미를 반영하는 일련의 숫자로 바꿈
  • 이게 모델의 출력이며 벡터
  토큰 벡터
역할 입력 출력
성격 텍스트를 숫자로 표현한 단순한 방법 모델 내부 변환을 거친 의미 표현

흐름: 텍스트 → 토큰(모델 입력) → 모델 내부 변환 → 벡터(의미를 담은 숫자 집합)

3) "벡터(숫자) 가 의미를 나타낸다"는 게 뭔 말?

Vectors mathematically represent the 'meaning' of an input

특징

  1. 단어, 개별 문자, 토큰, 전체 문서, 혹은 추상적인 걸 벡터로 표현 가능
    (예: Insurellm 문서에서 직원들의 경력도 벡터화 가능)
  2. 보통 수백~수천 차원
    (예: 텍스트를 1000차원의 점으로 나타내)
  3. 인풋에 대한 ‘understanding’ 을 나타내, 비슷한 인풋은 서로 close to each other 임
    예시:
    - "뉴욕→런던 항공권 가격"과 "JFK→히스로 항공권 가격" → 두개 점(벡터)는 가까울 것
    - 모델이 이런 식으로 수치를 만들어내도록 학습됨

4) Word2vec과 벡터 수학

2013년 Word2vec이 유명해진 건 이런 마법 같은 연산 때문

king - man + woman ≈ Queen
Paris - France + England ≈ London

의미 단위의 산수가 가능해진 것

벡터는 단순한 좌표가 아니라 "의미를 빼고 더할 수 있는 공간 위의 점"이라고 이해하면 편함

요즘 인코더 모델은 단어뿐 아니라 문단 전체에 대해서도 동일한 방식으로 벡터 공간에 매핑

텍스트 단락 전체에 대해 동일한 방식으로 작동해. 텍스트 단락을 벡터에 매핑한 다음 일부 의미를 제거하고 다른 의미를 추가할 수 있음

5) Fuzzy Lookup — RAG의 핵심 트릭

시나리오: 유저가 "히드로(런던의 공항)행 티켓 가격은?" 이라고 묻는데, 우리 DB에는 "런던행 가격" 만 있다.

단순 키워드 매칭은 실패. 하지만 벡터로 보면?

  • "히드로"의 벡터와 "런던"의 벡터는 공간적으로 매우 가까움
  • 질문 벡터 주변의 DB 벡터를 찾으면 "런던" 항목이 걸려나옴

이걸 Fuzzy Lookup(의미 기반 조회) 이라고 부릅니다.

6) RAG 파이프라인 정리

유저 질문
   ↓
[인코딩 LLM] 질문을 벡터로 변환
   ↓
[벡터 DB] 가장 가까운 벡터 찾기
   ↓
벡터에 연결된 "원문 텍스트" 꺼내기
   ↓
[답변 LLM] 자연어 프롬프트로 주입 → 최종 답변

중요:

- 인코딩 LLM과 답변 LLM은 별개입니다.

- 답변 LLM에게 가는 건 벡터가 아니라 자연어여야 함

- 인코더의 역할은 질문과 DB 데이터를 벡터로 바꾸는 것까지입니다.

 


4. 문서를 벡터 DB에 넣기 

이제 진짜 벡터 기반 RAG를 만들기. LangChain 1.0  사용

0) 작업 flow 요약 

- 문서들을 청크단위로 쪼갬 → 청크 벡터화→ 벡터 db에 저장 -> 시각화 

1) LangChain 소개 및 장단점

LangChain?

  • 오픈소스 프레임워크
  • 추상화 레이어 통해 다양한 모델과 연결하고 래그파이프라인 구성 가능

pros & cons

  • 엔터프라이즈에서 많이 사용
  • 처음등장했을 때는 각 모델별로 엔드포인트 형식이 달랐음 (이때 LangChain에선 추상화해서 호출 가능)
  • 지금은 오픈ai compatible 엔드포인트를 제공, python 클라이언트 라이브러리 사용해 다른 모델간 간단히 전환 가능
    (베이스 url 만 변경하면)
  • 툴, 프롬프트 템플릿 이런것도 많이 통합됐음
  • 상당히 무거운 추상화 계층 임 (litellm 과 달리)

2) 문서를 왜 청크(Chunk)로 쪼개야 하나

문서의 벡터스토어 저장을 위해 아래 처럼 진행예정

  1. 문서를 작은 청크로 나눔
  2. 이걸 벡터로 전환
  3. 크로마(오픈소스 벡터 db) 에 넣음
  4. 시각화

문서 하나를 통째로 벡터 하나에 매핑하면 왜 안 될까?

  • 문서에는 여러 주제가 섞여 있음 (예: 직원 인사 기록에는 이름, 직책, 급여, 평가 등 다양한 정보)
  • 유저 질문은 보통 문서의 특정 한 부분에만 관련됨
  • 전체문서와 연관된 하나의 벡터를 만들면 질문이 전체 문서와 직접적으로 일치할 가능성이 줄어듬
  • 그래서 각 문서를 어떻게 분할해서 하나의 특정조각이 몇가지 다른 질문을 해결할 수 있는 가장 좋은 기회를 가질 수 있는지, 그리고 그렇게 할 수 있는 적절한 세분성을 확보할 수 있는지 고민하는 것이 합리적

그래서 적절한 세분성(granularity) 으로 청크를 나눠야 함

문서를 질문에 매핑될 가능성 높은 청크로 나누는데 집중

하나의 청크가 하나의 질문 유형에 답할 수 있을 만큼 짧고 명확해야 함

3) 임포트

import os
import glob
import tiktoken
import numpy as np
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from sklearn.manifold import TSNE
import plotly.graph_objects as go

4) 지식 창고 전체 크기 확인

MODEL = "gpt-4.1-nano"
db_name = "vector_db"

files = glob.glob("knowledge-base/**/*.md", recursive=True)
entire_knowledge_base = ""
for file_path in files:
    with open(file_path, 'r', encoding='utf-8') as f:
        entire_knowledge_base += f.read() + "\n\n"

# 토큰 수 측정 (비용 가늠용)
encoding = tiktoken.encoding_for_model(MODEL)
tokens = encoding.encode(entire_knowledge_base)
print(f"Total tokens: {len(tokens):,}")

임베딩 비용을 미리 가늠할 때 유용

76개 파일, 303,434 character, 63,555 토큰 수

5) LangChain으로 문서 로드

folders = glob.glob("knowledge-base/*")

documents = []
for folder in folders:
    doc_type = os.path.basename(folder)  # 'employees', 'products' 등
    loader = DirectoryLoader(
        folder,
        glob="**/*.md",
        loader_cls=TextLoader,
        loader_kwargs={'encoding': 'utf-8'}
    )
    folder_docs = loader.load()
    for doc in folder_docs:
        doc.metadata["doc_type"] = doc_type  # 메타데이터 주입
        documents.append(doc)

- knowledge base/ 하위 각 폴더 (products, employees) 순회 

- 폴더명을 doc_type 메타데이터로 저장해두면, 나중에 시각화할 때 색깔로 구분하기 좋음

(product 문서인지, employee 문서 유형인지를 각 문서의 타입정보로 활용) 

- 76개 문서 로드

6) RecursiveCharacterTextSplitter로 청크 분할

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200
)
chunks = text_splitter.split_documents(documents)

print(f"Divided into {len(chunks)} chunks")
print(f"First chunk:\n\n{chunks[0]}")
  • chunk_size=1000: 각 청크 최대 1000자
  • chunk_overlap=200: 청크 경계에서 문맥이 끊기지 않도록 200자씩 겹치게

413개 청크로 전체 문서를 나눈 것을 볼 수 있음 

 

7) 임베딩 모델 선택 

텍스트를 인코더를 사용해 벡터로 변환해야함

 

embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
# embeddings = OpenAIEmbeddings(model="text-embedding-3-large")  # 유료 대안

선택지 비교

모델명 차원 비용 비고
HuggingFace all-MiniLM-L6-v2 384 무료(로컬) 가벼움, 실습용
OpenAI text-embedding-3-small 1,500 저렴 API 호출
OpenAI text-embedding-3-large 3,000 중간 고성능

이 외에도 google의 gemini-embedding-001 등 다양

 

8) Chroma db에 저장

if os.path.exists(db_name):
    Chroma(persist_directory=db_name, embedding_function=embeddings).delete_collection()

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory=db_name
)
print(f"Vectorstore created with {vectorstore._collection.count()} documents")

기존 DB가 있으면 지우고 새로 만듬. persist_directory에 저장하면 다음 세션에서 재사용 가능.

문서 임베딩 후 chroma 벡터스토어 생성/저장

9) 벡터 DB 현황 확인

collection = vectorstore._collection
count = collection.count()
sample_embedding = collection.get(limit=1, include=["embeddings"])["embeddings"][0]
dimensions = len(sample_embedding)
print(f"{count:,} vectors with {dimensions:,} dimensions")

벡터 개수와 각 벡터의 차원수

10) 벡터 스토어 선택지 비교

 

이름  성격
Chroma 오픈소스 벡터 DB (실습 적합)
FAISS DB는 아니고 in-memory 벡터 서치용 라이브러리
Elastic 벡터 검색 + 필터링 동시 지원
Pinecone / Weaviate 엔터프라이즈용 유료
Postgres pgvector 주류 DB에 벡터 기능 얹힌 형태

핵심 포인트:

대부분의 경우 진짜 중요한 건 임베딩 모델. DB 자체는 비용/성능/확장성/복원력/백업 등의 인프라 선택 문제에 가깝습니다.

11) t-SNE로 2D 시각화

벡터는 수백 차원이라 눈으로 볼 수 없으니, t-SNE(t-distributed stochastic neighbor embedding)로 2D/3D로 축소해서 시각화

result = collection.get(include=['embeddings', 'documents', 'metadatas'])
vectors = np.array(result['embeddings'])
documents = result['documents']
metadatas = result['metadatas']
doc_types = [metadata['doc_type'] for metadata in metadatas]
colors = [['blue', 'green', 'red', 'orange']
          [['products', 'employees', 'contracts', 'company'].index(t)]
          for t in doc_types]

tsne = TSNE(n_components=2, random_state=42)
reduced_vectors = tsne.fit_transform(vectors)

fig = go.Figure(data=[go.Scatter(
    x=reduced_vectors[:, 0],
    y=reduced_vectors[:, 1],
    mode='markers',
    marker=dict(size=5, color=colors, opacity=0.8),
    text=[f"Type: {t}<br>Text: {d[:100]}..." for t, d in zip(doc_types, documents)],
    hoverinfo='text'
)])
fig.update_layout(title='2D Chroma Vector Store Visualization', width=800, height=600)
fig.show()

왼쪽부터, 허깅페이스 임베딩 /오픈 ai 스몰/라지

doc_type에 따라 색상이 다름 (products(파랑),  employees(초록), contracts(빨강), company(주황) ) 

동일 문서 유형끼리 벡터들이 모여있는 걸 볼 수 있음 

12) 3D 버전

tsne = TSNE(n_components=3, random_state=42)
reduced_vectors = tsne.fit_transform(vectors)

fig = go.Figure(data=[go.Scatter3d(
    x=reduced_vectors[:, 0],
    y=reduced_vectors[:, 1],
    z=reduced_vectors[:, 2],
    mode='markers',
    marker=dict(size=5, color=colors, opacity=0.8),
    text=[f"Type: {t}<br>Text: {d[:100]}..." for t, d in zip(doc_types, documents)],
    hoverinfo='text'
)])
fig.show()

왼쪽 부터 허깅페이스모델/오픈AI large

doc_type에 따라 색상이 다름 (products(파랑),  employees(초록), contracts(빨강), company(주황) ) 

동일 문서 유형끼리 벡터들이 모여있는 걸 볼 수 있음 

13) 시각화로 관찰한 것

  • 초록색(employees), 파란색(products), 노랑색(contracts)이 각자 뚜렷한 클러스터를 형성
  • 같은 doc_type 문서들이 실제로 벡터 공간에서 뭉쳐 있음 → 임베딩이 의미를 잘 포착한다는 시각적 증거
  • 임베딩 모델을변경해 차원이 커질수록(384 → 1,500 → 3,000) 클러스터가 더 깔끔하게 분리
  • 단, contracts와 products 클러스터가 일부 겹치는 것이 관찰됨 → 계약서에 제품 정보가 섞여 있기 때문

14) 큰 임베딩 모델이 항상 더 좋을까?

  • 더 많은 차원 → 더 많은 자유도로 의미 표현 가능 → 잠재적으로 더 정교
  • 하지만 항상 좋은 건 아님
  • 텍스트의 의미를 더 잘 포착할 수 있는 더 고급 트랜스포머 모델이란 걸 의미하는
  • 많은 차원은 표현력의 "여지"를 줄 뿐

 

느낀 점:

시각화해서 보니 '같은 종류 문서는 정말 뭉쳐있다'가 눈으로 보인게 신기 했고,

더 고급 모델을 씀에 따라 뭉침정도가 더 깔끔하게 되는게 보이는게 신기했다.

실제 문서 업로드 -> 청킹 -> 임베딩 모델을 사용한 벡터화 -> 벡터 db에 저장 -> 저장된 결과물을 시각화해서 보는 일련의 과정을 진행해볼 수 있던 게 의미있었다.


5. LangChain으로 기본 RAG 파이프라인 완성 

 앞에 "4. 문서를 벡터 DB에 넣기" 에서 만든 vector_db를 불러와서 실제 챗봇을 완성합니다.

1) 전체 흐름

유저 질문
   ↓
임베딩 모델이 질문을 벡터로 변환
   ↓
벡터 DB에서 유사 벡터 검색
   ↓
연결된 원문 텍스트 꺼내기
   ↓
프롬프트에 주입 → LLM 답변 생성

2) 임포트와 세팅

from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.messages import SystemMessage, HumanMessage
from langchain_huggingface import HuggingFaceEmbeddings
import gradio as gr

MODEL = "gpt-4.1-nano"
DB_NAME = "vector_db"
load_dotenv(override=True)

3) Chroma db연결

embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = Chroma(persist_directory=DB_NAME, embedding_function=embeddings)

 "4. 문서를 벡터 DB에 넣기" 에서 저장한 DB를 그대로 재사용

중요: 저장할 때 썼던 것과 동일한 임베딩 모델  사용 (안 그러면 차원도 안 맞고 의미 공간도 달라서 엉뚱한 결과가 나옴)

저장시와 검색시 벡터공간이 일치해야하기 때문

4) LangChain의 핵심 추상화 2가지

랭체인 (없이도 rag 짤 수 있는데 좀 더 빠르게 짤 수 있음)

 

① llm — 모델 교체 자유

llm = ChatOpenAI(temperature=0, model_name=MODEL)

랭체인 이용해서 모델을 여러가지 교체해서 사용가능(추상화 이용해)

 

② retriever — 벡터 DB + 임베딩 + 검색 로직을 하나로 감싼 추상화

retriever = vectorstore.as_retriever()
  • 크로마, 데이터 스토어, 임베딩 모델에 대한 추상화
  • 특정 문서와 유사한 문서 검색하도록 리트리버에 요청가능

 

두 객체 모두 .invoke() 메서드로 호출하는 통일된 인터페이스를 가짐

retriever.invoke("Who is Avery?")  # 관련 문서 청크 리스트 반환
llm.invoke("Who is Avery?")         # 그냥 LLM에 질문 (컨텍스트 없이)

retriever.invoke() 시, 유사 청크 4개 반환이 기본값

5) Temperature 짧게 짚고 가기

llm = ChatOpenAI(temperature=0, model_name=MODEL)
  • temperature = 출력 다양성 제어
  • temperature=0: 항상 최고 확률 토큰 선택 (예측 가능)
  • temperature=1: 확률대로 샘플링 (다양성)
  • 흔히 "창의성"이라고 표현하지만 정확히는 토큰 선택 방식일 뿐
  • 창의성이 필요하면 System Prompt로 지시하는 게 맞음
  • 주의: temperature=0이라도 완전히 재현 가능한 건 아님 (random seed도 필요)

 

6) 답변 함수 — RAG의 핵심

SYSTEM_PROMPT_TEMPLATE = """
You are a knowledgeable, friendly assistant representing the company Insurellm.
You are chatting with a user about Insurellm.
If relevant, use the given context to answer any question.
If you don't know the answer, say so.
Context:
{context}
"""

def answer_question(question: str, history):
    docs = retriever.invoke(question)                            # 1) 관련 청크 검색
    context = "\n\n".join(doc.page_content for doc in docs)      # 2) 텍스트 합치기
    system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)  # 3) 프롬프트 포맷
    response = llm.invoke([                                      # 4) LLM 호출
        SystemMessage(content=system_prompt),
        HumanMessage(content=question)
    ])
    return response.content

이게 전부. RAG 파이프라인이 실질적으로 5줄짜리 함수로 끝남.

RAG 핵심 3단계

1. Retrieval 

retriever.invoke(question) 

-> 유저 질문과 관련있는 (벡터상 유사한) 청크 4개 검색 

2. Augmentation

검색된 청크들을 \n\n으로 연결해서 시스템 프롬프트의 {context}에 주입

3. Generation 

컨텍스트가 주입된 시스템 프롬프트 + 유저 질의를 llm 에 전달 

-> 답변 생성

7) Gradio 챗봇

gr.ChatInterface(answer_question).launch()

한 줄로 챗봇 UI 완성.

8) 모듈 구조로 정리 (실전 프로젝트 구조)

실습에서는 Jupyter로 한번에 다 돌렸지만, 실제 운영 코드로 만들 때는 이렇게 나눔

implementation/
├── ingest.py    # 문서 로드 → 청킹 → 벡터화 → Chroma 저장
├── answer.py    # 질문이 주어지면 retriever 통해 컨텍스트 가져옴 + llm 구성 → answer_question()
app.py           # Gradio 앱 (answer.py import)

실행:

# implementation 디렉토리에서 (최초 1회)
uv run ingest.py

# week5 디렉토리에서
uv run app.py

ingest.py는 DB 만들 때 한 번만 돌리고, 앱은 app.py만 재실행하면됨

 

9) 이전 "2. 단순 RAG ( 벡터 DB 없이 문자열 매칭을 통한 단순한 구현)" 대비 나아진 점 

1. 문서 dict의 key 인 "성" 이 아닌 "이름 (first name)" 으로 검색이 가능

Avery lancaster에 대해, who is lancaster가 아니라 who is Avery 라고 해도 답변이 나옴

 

2. 오타가 있어도 의미 기반 검색을 통해 검색이 가능

who is Aver"i" Lancaster? 

-> 오타가 있었음에도 벡터 유사도 기반 의미기반 검색이라 Avery Lancaster 정보 찾아냄 

 

3. 연속 질문 대응 가능 

avery 에 대해 물은 뒤 그녀의 salary에 대해 물었는데 답변 성공

 

10) LangChain을 꼭 써야 할까?

  • Pros: 파이프라인을 몇 줄로 끝낼 수 있음. 엔터프라이즈 환경에서 많이 사용
  • Cons: 추상화 계층이 상당히 무거움 (LiteLLM 같은 경량 대안에 비해)
  • 요즘은 대부분 모델이 OpenAI compatible 엔드포인트를 제공해서 base_url만 바꿔도 모델 교체 가능 → LangChain 없이도 RAG 구축은 충분히 가능
  • 선택 기준: 툴, 프롬프트 템플릿, 체인 기능을 적극 쓸지 여부

 


6. RAG의 한계 — 개발한 챗봇을 돌려보니 드러난 문제들

위에선 기존 키워드 기반 검색 챗봇 대비 나아진 점 (오타 대응, 이름으로도 검색 가능)을 이야기했지만

실제 만든 rag챗봇을 이용해 보다보니 몇가지 한계를 파악할 수 있었음

 

1) 문제 ① — 청크 경계 문제

해당 청크에 풀네임이 없어서, 풀네임을 가져오지 못함.

청킹 크기와 오버랩 설정, 원문의 구조에 따라 답변 가능 여부가 갈립니다.

3) 문제  — 복합 질문의 검색 품질 저하

시나리오: 한 채팅에 Avery와 IIOTY award 가 동시에 언급되면?

리트리버가 두 주제(avery, IIOTY)  모두에 대한 유사 문서를 찾으려 하면서

 검색 품질이 떨어짐. 데이터 저장소에서 엉뚱하게 많은 걸 끌어옵니다.

4) 핵심 교훈

RAG는 "한번에 완성되는 해법"이 아니라 지속적으로 개선해야 하는 실험적 파이프라인

  • 각 실패 케이스를 개별 문제로 보고 하나씩 해결해야 함
  • 한 문제 고치면 다른 문제가 생기기도 함 → 이게 정상
  • 청킹 전략, 임베딩 모델, 검색 파라미터(top-k), 쿼리 재작성(query rewriting), 하이브리드 검색 등 고도화할 요소가 많음

느낀 점:

- 단순하게 rag를 구축하는 것 자체는 쉽다. 

- 하지만, 실제 돌려봤을 때 이런 세부 case들에서 잘 안되는 걸 보니 왜 실제 b2b si 등에서 rag 챗봇 구축할 때 poc나 업체간 BMT 들을 많이 요구하는지 느꼈다. (실제 쓸 땐 생각보다 성능이 안나올 수 있으니까) 

- 또 이런 에러 케이스들을 한땀한땀 맞춤형으로 수정해나갈 수 밖에 없기에, 결국에 rag 구성하는데 완전 자동화는 쉽지 않고 사람 손을 다 타야한다 라고 하는지에 대한 이유를 좀 느낄 수 있었던 거 같다. 

- Advanced rag 라고 하면서 다양한 기법들이 나오게 된 이유도 조금은 알것 같기도 하고 


마무리


이번 파트에서는 RAG의 가장 기본적인 형태를 처음부터 끝까지 직접 구성해보면서,  
“LLM이 내부 문서를 참고해서 답하게 만든다”는 말이 실제로 어떤 구조로 구현되는지 감을 잡을 수 있었다.

처음에는 단순 문자열 매칭 기반으로 시작했지만,  
이름으로는 검색이 안 되거나, 오타에 취약하거나, 표현이 조금만 달라져도 실패하는 모습을 보면서  
왜 의미 기반 검색이 필요한지 자연스럽게 이해하게 됐다.  
그 다음 단계로 문서를 청킹하고, 임베딩하고, 벡터 DB에 저장하고,  
검색 결과를 프롬프트에 주입하는 흐름까지 구현해보니 RAG의 기본 구조가 훨씬 또렷해졌다.

다만 동시에, RAG는 생각보다 쉽게 “완성됐다”고 말하기 어려운 시스템이라는 점도 느꼈다.  
실제로 돌려보면 청크 경계, 복합 질문, 검색 품질 저하 같은 문제가 계속 나타나고,  
이런 부분을 하나씩 다듬어가며 개선해야 비로소 실사용에 가까워질수 밖에 없다는 것을 알게 됐다.

다음 글에서는 이런 한계를 어떻게 보완할 수 있는지,  
즉 Advanced RAG, RAG평가 등 다양한 RAG 개선 기법들을 중심으로 이어서 정리해볼 예정이다.

 

(꾸준히 공부하고 적을테니 많은 관심 부탁드립니다.)

 

Profile:
Linkedin