AX에이전트 실습 노트
DAY 2

문서를 찾아 읽고 답하는 RAG

모델이 읽은 적 없는 사내 문서는 먼저 찾아서 프롬프트에 붙여 주면 됩니다. 문서를 자르고, 벡터로 바꾸고, 검색하고, 근거와 함께 답하게 만드는 과정을 처음부터 끝까지 조립합니다.

RAG청킹메타데이터임베딩코사인 유사도ChromaRetrieverBM25하이브리드 검색재랭킹평가 세트

이 날 배우는 것

1일차에 확인한 첫 번째 한계, 곧 모델이 사내 규정을 모른다는 문제를 푸는 날입니다. 해법은 모델을 고치는 것이 아니라 질문과 관련된 문서 조각을 검색해서 프롬프트에 함께 넣어 주는 것이고, 이 방식을 RAG라고 부릅니다. 사내 규정 문서 세 편을 재료로 로드, 청킹, 임베딩, 벡터 DB 저장, 검색, 답변 생성까지 한 단계씩 만들고, 검색이 틀리는 경우를 일부러 재현한 뒤 하이브리드 검색과 재랭킹으로 보완합니다. 마지막에는 '좋아진 것 같다'는 느낌 대신 평가 세트로 점수를 매깁니다.

  • RAG를 색인 단계(문서를 미리 준비)와 질의 단계(질문마다 실행)로 나누어 설명할 수 있다
  • chunk_size와 chunk_overlap이 검색 결과에 미치는 영향을 알고, 청크에 메타데이터를 붙이는 이유를 안다
  • 임베딩이 글을 벡터로 바꾸는 일이며 코사인 유사도와 거리가 무엇을 재는지 설명할 수 있다
  • Chroma에 청크를 저장하고 Retriever로 꺼내 검색 | 프롬프트 | 모델 | 파서 형태의 RAG 체인을 조립한다
  • 벡터 검색이 약한 질문 유형을 재현하고 BM25 하이브리드 검색과 재랭킹이 각각 무엇을 보완하는지 안다
  • 평가 세트(질문, 기대 출처, 유형)를 만들어 검색과 거절 동작을 자동으로 채점한다

출발점: 모델은 우리 회사 문서를 읽은 적이 없다

1일차에서 본 첫 번째 한계를 다시 꺼냅니다. 모델에게 사내 규정을 물으면 어떻게 될까요. 오늘의 모든 실습은 이 질문 두 개에서 출발합니다.

hallucination.py 12–21줄
12def get_text(message):
13    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
14    content = message.content
15    if isinstance(content, list):
16        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
17    return content
18
19
20print(get_text(llm.invoke("삼성SDS 국내 출장 식비 한도는 하루 얼마인가요? 정확한 금액만 답해주세요.")))
21print(get_text(llm.invoke("삼성SDS 재택근무는 주 며칠까지 가능한가요? 규정 조항 번호와 함께 알려주세요.")))
검색도 문서도 없이 모델에게 바로 묻습니다. 질문이 일부러 '정확한 금액만', '조항 번호와 함께'를 요구한다는 점을 보세요.

모델은 인터넷에 공개된 글로 학습했을 뿐, 회사 안의 출장 규정이나 재택근무 규정을 본 적이 없습니다. 그런데 질문은 정확한 금액과 조항 번호를 요구합니다. 이때 모델이 할 수 있는 일은 둘 중 하나입니다. "알 수 없다"고 답하거나, 그럴듯한 금액과 조항 번호를 지어내거나. 앞의 것은 쓸모가 없고 뒤의 것은 위험합니다. 사실이 아닌 내용을 그럴듯하게 써내는 뒤쪽 현상이 환각(hallucination)입니다.

get_text 는 왜 있나

ChatBedrockConverse 의 응답 content 는 문자열일 때도 있고, {"type": "text", "text": ...} 같은 블록의 리스트일 때도 있습니다. 12~17행의 get_text 는 어느 쪽이든 글자만 모아 돌려주는 작은 함수입니다. 체인 끝에 StrOutputParser 를 달면 같은 일을 대신해 주므로, 이 함수는 모델을 직접 부르는 hallucination.py 와 verify.py 에만 나옵니다.

모델에게 사내 지식을 알려 주는 세 가지 길

모델을 다시 학습시킨다

사내 문서로 모델을 추가 학습(파인튜닝)하는 방법입니다. 비용과 시간이 들고, 규정이 바뀔 때마다 다시 해야 하며, 답의 근거가 어느 문서인지 모델이 대지 못합니다.

문서를 전부 프롬프트에 넣는다

문서가 몇 쪽이면 가능합니다. 하지만 호출할 때마다 문서 전체가 입력 토큰으로 계산되고, 문서가 수천 건이 되면 모델이 한 번에 읽을 수 있는 양을 넘습니다.

관련된 부분만 찾아서 넣는다

질문이 들어오면 관련 있는 문서 조각 몇 개만 검색해서 프롬프트에 붙입니다. 문서가 바뀌면 검색 대상만 갱신하면 되고, 어느 문서를 보고 답했는지도 남습니다. 이것이 RAG입니다.

RAG = 검색(Retrieval) + 덧붙이기(Augmented) + 생성(Generation)

모델은 그대로 두고, 모델이 읽을 글을 바꿉니다. 질문과 관련된 문서 조각을 검색해서 프롬프트에 덧붙인 다음 모델에게 답을 생성하게 합니다. 모델의 역할은 '기억에서 꺼내 말하기'에서 '눈앞의 글을 읽고 정리하기'로 바뀝니다.

오늘 쓰는 문서는 세 편을 합쳐도 1,000자가 안 되므로 사실 전부 프롬프트에 넣어도 됩니다. 실습의 목적은 문서가 수천 건일 때도 그대로 통하는 절차를 작은 재료로 익히는 데 있습니다. 재료가 작으면 중간 결과를 눈으로 다 확인할 수 있다는 이점도 있습니다.

PYday2_practice/hallucination.pyRAG 없이 사내 규정을 물었을 때 모델이 어떻게 답하는지 확인21줄
뒤의 검증 실습에서 같은 질문을 RAG 체인에 다시 던져 답을 비교합니다. 참고로 실습 문서에는 '조항 번호'가 아예 없습니다. 모델이 조항 번호를 댄다면 그것은 문서가 아닌 다른 곳에서 나온 것입니다.
 1# hallucination.py - RAG 없이 사내 규정 질문
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse
 4
 5load_dotenv()
 6llm = ChatBedrockConverse(
 7    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
 8    region_name="us-east-1",
 9    temperature=0,
10)
11
12def get_text(message):
13    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
14    content = message.content
15    if isinstance(content, list):
16        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
17    return content
18
19
20print(get_text(llm.invoke("삼성SDS 국내 출장 식비 한도는 하루 얼마인가요? 정확한 금액만 답해주세요.")))
21print(get_text(llm.invoke("삼성SDS 재택근무는 주 며칠까지 가능한가요? 규정 조항 번호와 함께 알려주세요.")))

RAG의 큰 그림: 색인 단계와 질의 단계

RAG 코드는 실행 시점이 다른 두 부분으로 나뉩니다. 문서를 검색할 수 있는 형태로 미리 준비해 두는 단계와, 질문이 들어올 때마다 찾아서 답하는 단계입니다. 이 구분이 머리에 있으면 오늘의 파일 17개가 각각 어디에 속하는지 바로 보입니다.

색인 단계 · 문서가 바뀔 때만 한 번 (오프라인) 원본 문서docs/*.md 로드Document 청킹청크 여러 개 임베딩청크 → 벡터 vectorstore.py 가 하는 일. 청크 수만큼 임베딩을 계산합니다. 벡터 DB (Chroma)청크 + 벡터 + 메타데이터 저장 질의 단계 · 질문이 올 때마다 (온라인) 질문문자열 임베딩질문 → 벡터 유사도 검색가까운 청크 k개 프롬프트청크 + 질문 LLM답변 + 출처 청크 조회 질문 원문은 프롬프트에도 그대로 들어갑니다
두 단계가 만나는 곳은 벡터 DB 하나뿐입니다. 색인 단계의 결과물이 디스크에 남아 있으면 질의 단계는 문서 원본을 다시 읽지 않습니다.
단계하는 일오늘의 파일핵심 부품
색인 로드파일을 읽어 본문과 출처 정보를 가진 객체로 만듭니다loaders.pyTextLoader, DirectoryLoader
색인 청킹문서를 검색 단위로 자르고 꼬리표를 붙입니다chunking.py, chunking_md.py, metadata.pyRecursiveCharacterTextSplitter
색인 임베딩글을 숫자 벡터로 바꿉니다embedding.py, similarity.pyBedrockEmbeddings
색인 저장청크와 벡터를 DB에 넣어 디스크에 남깁니다vectorstore.py (load_db.py)Chroma
질의 검색질문과 가까운 청크를 꺼냅니다retriever.pyas_retriever()
질의 생성청크를 프롬프트에 끼워 모델에게 답하게 합니다rag_chain.pyLCEL 체인
점검잘 되는지 확인하고, 안 되는 경우를 찾아 보완합니다verify.py, verify_sources.py, failures.py, hybrid.py, eval.py, rerank_demo.pyBM25, 앙상블, CrossEncoder

검색 대상: 사내 규정 문서 세 편

오늘 검색하는 문서는 day2_practice/docs/ 의 마크다운 파일 세 개입니다. 모두 "교육용 더미 데이터"라고 본문에 적혀 있는 가상의 규정이고, 한 편이 300자를 조금 넘습니다. 제목(#) 아래에 소제목(##) 세 개가 있는 같은 구조입니다. 뒤의 실습에 나오는 질문은 모두 이 세 문서의 내용을 묻거나, 일부러 여기에 없는 것을 묻습니다. 먼저 한 번 읽어 두면 검색 결과가 맞는지 스스로 판단할 수 있습니다.

MDday2_practice/docs/leave_policy.md연차 규정: 부여 기준, 이월, 신청 절차14줄
"월차", "휴가 일수" 같은 말은 본문에 없습니다. 뒤에서 이런 표현으로 물었을 때도 이 문서가 검색되는지를 확인합니다. 신청서 양식 번호 HR-103 이 들어 있습니다.
 1# 연차 규정
 2
 3본 문서는 교육용 더미 데이터입니다.
 4
 5## 부여 기준
 6연차 휴가는 연 15일이 부여됩니다. 입사 1년 미만은 매월 1일씩 최대 11일까지 받을 수 있습니다.
 73년 이상 근속자는 매 2년마다 1일이 더해져 최대 25일까지 받을 수 있습니다.
 8
 9## 이월
10미사용 연차는 5일까지 다음 해로 이월할 수 있습니다. 이월한 연차는 다음 해 6월 말까지 먼저 사용해야 합니다.
11
12## 신청 절차
13연차는 사용 5영업일 전까지 인사 포털에서 연차 신청서(양식 HR-103)로 신청합니다.
14부서장 승인 후 확정되며 긴급한 경우 사후 신청은 다음 영업일 오전까지 마쳐야 합니다.
MDday2_practice/docs/remote_work_policy.md재택근무 규정: 운영 기준, 보안 수칙, 근태15줄
양식 번호는 RW-201 입니다. "집에서 일한다"는 표현은 본문에 없습니다.
 1# 재택근무 규정
 2
 3본 문서는 교육용 더미 데이터입니다.
 4
 5## 운영 기준
 6재택근무는 주 2일까지 사용할 수 있습니다. 팀 필수 출근일(수요일)에는 재택근무를 사용할 수 없습니다.
 7신청은 전주 목요일까지 인사 포털에서 재택근무 신청서(양식 RW-201)로 제출합니다.
 8
 9## 보안 수칙
10재택근무 중 사내망 접속은 VPN 사용이 필수입니다.
11개인 PC를 사용할 경우 보안 점검 프로그램을 설치하고 보안 서약서를 제출해야 합니다.
12사내 자료는 승인 없이 개인 저장소나 외부 서비스에 올릴 수 없습니다.
13
14## 근태
15재택근무 시작과 종료 시 협업 메신저에 상태를 등록합니다. 근무 시간은 사무실 근무와 동일하게 적용됩니다.
MDday2_practice/docs/travel_policy.md출장 규정: 신청과 승인, 경비 기준, 정산15줄
양식 번호 TR-102, 식비·숙박비 한도 금액, 정산 기한이 들어 있습니다. "법카"가 아니라 "법인카드"라고 적혀 있습니다.
 1# 출장 규정
 2
 3본 문서는 교육용 더미 데이터입니다.
 4
 5## 신청과 승인
 6출장은 출발 3영업일 전까지 인사 포털에서 출장 신청서(양식 TR-102)로 신청하고 부서장 승인을 받아야 합니다.
 7항공권과 숙박은 출발 전에 사내 출장 시스템에서 미리 예약해야 합니다.
 8
 9## 경비 기준
10국내 출장 식비 한도는 하루 5만원, 해외 출장 식비 한도는 하루 8만원입니다.
11숙박비는 국내 1박 12만원, 해외 1박 20만원 한도입니다.
12
13## 정산
14출장비는 복귀 후 7영업일 안에 영수증을 첨부해 정산해야 합니다.
15법인카드 사용 내역은 매월 말일까지 경비 시스템에 등록합니다.

실행 위치에 대한 약속

오늘의 스크립트는 day2_practice 폴더 안에서 실행합니다

metadata.py 는 문서를 "docs/leave_policy.md" 처럼, 벡터 DB를 여는 파일들은 "./chroma_db" 처럼 상대 경로로 적었습니다. 상대 경로는 스크립트가 있는 폴더가 아니라 터미널의 현재 폴더를 기준으로 풀립니다. 레포 루트에서 python day2_practice/vectorstore.py 로 실행하면 docs/ 를 찾지 못합니다. (loaders.py 와 chunking.py 만은 Path(__file__).parent 로 경로를 만들어 어디서 실행해도 됩니다.)

실행 순서
1cd day2_practice
2python vectorstore.py      # 색인 단계: chroma_db 폴더 생성 (임베딩 API 호출)
3python retriever.py        # 질의 단계: 이후 파일들은 chroma_db 를 열어서 씁니다
4python rag_chain.py
vectorstore.py 를 한 번 돌려 chroma_db 폴더가 생겨야 그 뒤의 파일들이 동작합니다. 이 폴더는 실행 산출물이라 git에는 올라가 있지 않습니다.

.env 는 1일차와 같이 레포 루트에 둡니다. load_dotenv() 는 스크립트가 있는 폴더에서 위쪽 폴더로 올라가며 .env 를 찾습니다.

문서 로드: 파일을 Document로

첫 단계는 파일을 읽는 일입니다. LangChain에서는 읽어 들인 글을 그냥 문자열로 두지 않고 Document라는 객체에 담습니다. 뒤의 모든 단계가 이 객체를 주고받습니다.

page_content

본문 문자열입니다. 임베딩되고, 검색되고, 마지막에 프롬프트에 들어가는 것이 이 글입니다.

metadata

본문에 대한 정보를 담는 dict입니다. 로더가 넣어 주는 기본값은 파일 경로인 source 하나입니다. 검색에는 쓰이지 않지만 "이 글이 어디서 왔는가"를 끝까지 들고 다닙니다.

loaders.py 9–14줄
 9# 1) 파일 하나 로드
10loader = TextLoader(str(DOCS_DIR / "leave_policy.md"), encoding="utf-8")
11docs = loader.load()
12print(f"문서 수: {len(docs)}")
13print(f"metadata: {docs[0].metadata}")
14print(f"본문 앞 80자: {docs[0].page_content[:80]}")
파일 하나를 읽습니다. load() 는 파일이 하나여도 항상 Document의 리스트를 돌려줍니다.
loaders.py 16–26줄
16# 2) 폴더 통째로 로드
17dir_loader = DirectoryLoader(
18    str(DOCS_DIR),
19    glob="**/*.md",
20    loader_cls=TextLoader,
21    loader_kwargs={"encoding": "utf-8"},
22)
23all_docs = dir_loader.load()
24print(f"\n전체 문서 수: {len(all_docs)}")
25for d in all_docs:
26    print(f"  - {d.metadata['source']} ({len(d.page_content)}자)")
폴더를 통째로 읽습니다. glob 패턴에 맞는 파일마다 loader_cls 로 지정한 로더를 적용합니다.
  1. 로더는 파일 형식마다 따로 있습니다TextLoader 는 파일을 글자 그대로 읽는 가장 단순한 로더입니다. 마크다운의 # 기호도 해석하지 않고 본문에 그대로 남깁니다. PDF나 웹 페이지에는 그에 맞는 다른 로더를 쓰지만, 어떤 로더든 결과는 Document 리스트라는 점이 같습니다.
  2. DirectoryLoader 는 로더를 여러 파일에 돌려 주는 껍데기입니다glob="**/*.md" 는 하위 폴더까지 포함해 .md 파일 전부를 뜻합니다. loader_kwargs 는 각 파일을 읽을 TextLoader 에 그대로 전달됩니다.
  3. 마지막 반복문은 문서별 글자 수를 찍습니다세 파일이 Document 세 개로 들어왔는지, 본문 길이가 얼마인지 확인하는 용도입니다. 이 길이가 다음 절의 chunk_size 를 정할 때 기준이 됩니다.
encoding="utf-8" 을 빼면 Windows에서 깨집니다

인코딩을 지정하지 않으면 파이썬은 운영체제의 기본 인코딩으로 파일을 엽니다. 한글 Windows의 기본값은 UTF-8이 아니어서, UTF-8로 저장된 한글 문서를 읽다가 UnicodeDecodeError 가 나거나 글자가 깨집니다. 코드가 TextLoader 와 loader_kwargs 양쪽에 인코딩을 적어 둔 이유입니다.

source 에 무엇이 들어가는지는 로더에 넘긴 경로가 정합니다

이 파일은 Path(__file__).parent / "docs" 로 경로를 만들었으므로 source 에는 실행한 PC의 전체 경로가 들어갑니다. 반면 뒤에 나올 metadata.py 는 "docs/leave_policy.md" 라는 상대 경로를 넘기므로 source 도 그 문자열 그대로입니다. 오늘 마지막의 평가 스크립트는 source 를 문자열로 비교하기 때문에 이 차이가 채점 결과를 바꿉니다.

PYday2_practice/loaders.pyTextLoader로 파일 하나, DirectoryLoader로 폴더 전체를 Document로 읽기26줄
 1# loaders.py - 문서 로더
 2from pathlib import Path
 3
 4from langchain_community.document_loaders import TextLoader, DirectoryLoader
 5
 6# 실행 위치와 무관하게 이 파일 옆의 docs 폴더를 사용합니다
 7DOCS_DIR = Path(__file__).parent / "docs"
 8
 9# 1) 파일 하나 로드
10loader = TextLoader(str(DOCS_DIR / "leave_policy.md"), encoding="utf-8")
11docs = loader.load()
12print(f"문서 수: {len(docs)}")
13print(f"metadata: {docs[0].metadata}")
14print(f"본문 앞 80자: {docs[0].page_content[:80]}")
15
16# 2) 폴더 통째로 로드
17dir_loader = DirectoryLoader(
18    str(DOCS_DIR),
19    glob="**/*.md",
20    loader_cls=TextLoader,
21    loader_kwargs={"encoding": "utf-8"},
22)
23all_docs = dir_loader.load()
24print(f"\n전체 문서 수: {len(all_docs)}")
25for d in all_docs:
26    print(f"  - {d.metadata['source']} ({len(d.page_content)}자)")

청킹: 문서를 검색 단위로 자르기

문서를 통째로 검색 단위로 삼지 않고 잘게 나눕니다. 이렇게 나눈 조각을 청크(chunk), 나누는 일을 청킹이라고 합니다. RAG의 품질은 모델보다 이 단계에서 더 많이 갈립니다.

왜 통째로 넣지 않고 자르나

이유는 세 가지입니다. 첫째, 다음 절에서 볼 임베딩은 글 한 덩어리를 벡터 하나로 바꿉니다. 덩어리 안에 연차 부여, 이월, 신청 절차가 섞여 있으면 벡터는 그 평균 어딘가를 가리키게 되어 어느 질문에도 딱 맞지 않습니다. 둘째, 검색된 덩어리는 프롬프트에 그대로 들어가므로 덩어리가 클수록 입력 토큰이 늘어납니다. 셋째, 모델에게 관련 없는 글을 많이 보여 줄수록 답이 흐려집니다.

크기와 겹침

RecursiveCharacterTextSplitter 는 가장 널리 쓰는 분할기입니다. 설정값은 두 개입니다. chunk_size 는 청크 하나의 최대 길이, chunk_overlap 은 이웃한 청크끼리 겹치게 둘 길이입니다. 둘 다 기본 단위는 토큰이 아니라 글자 수입니다.

원문 문서 한 편의 글자들 (왼쪽에서 오른쪽으로 이어집니다) 겹침 없음 overlap = 0 청크 0 청크 1 청크 2 겹침 있음 overlap > 0 청크 0 청크 1 청크 2 청크 3 같은 글자가 양쪽 청크에
겹침이 없으면 경계에 걸린 문장은 앞 청크에 절반, 뒤 청크에 절반이 들어갑니다. 겹침을 두면 경계 부근의 글이 양쪽에 온전히 들어갈 가능성이 높아지는 대신 청크 수와 저장량이 늘어납니다.
chunking.py 14–21줄
14docs = TextLoader(str(DOCS_DIR / "leave_policy.md"), encoding="utf-8").load()
15
16for size, overlap in [(100, 0), (100, 50), (300, 50)]:
17    splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap)
18    chunks = splitter.split_documents(docs)
19    print(f"\n=== chunk_size={size}, overlap={overlap} -> {len(chunks)}개 청크 ===")
20    for i, c in enumerate(chunks):
21        print(f"[{i}] ({len(c.page_content)}자) {c.page_content[:40].replace(chr(10), ' ')}...")
같은 문서(연차 규정)를 세 가지 설정으로 잘라 청크 수, 각 청크의 글자 수와 앞 40자를 찍습니다.

이름의 'Recursive'는 자르는 방식을 가리킵니다. 이 분할기는 구분자 목록을 가지고 있고(기본값은 빈 줄, 줄바꿈, 공백, 글자 순서), 먼저 가장 큰 구분자인 빈 줄로 나눠 봅니다. 나눈 조각이 chunk_size 보다 크면 그 조각만 다음 구분자로 다시 나눕니다. 그런 다음 작은 조각들을 chunk_size 를 넘지 않는 선에서 다시 이어 붙여 청크를 만듭니다. 결과적으로 문단 → 줄 → 단어 순으로, 가능한 한 큰 의미 단위를 지키며 자릅니다.

연차 규정 문서(323자)에 같은 분할기를 적용하면 세 설정의 결과는 다음과 같이 갈립니다. 모델 호출이 없는 단계라 비용 없이 몇 번이든 돌려 볼 수 있습니다.

설정청크 수청크별 글자 수눈여겨볼 점
(100, 0)629, 66, 45, 69, 58, 48'부여 기준' 문단이 줄 단위로 갈라집니다. "3년 이상 근속자는 … 최대 25일까지 받을 수 있습니다"가 혼자 청크 하나가 되는데, 이 조각에는 소제목도 '연차'라는 단어도 없습니다. 청크만 읽어서는 무엇이 25일인지 알 수 없습니다
(100, 50)629, 66, 45, 69, 58, 98겹침을 50으로 줬는데 달라진 것은 마지막 청크 하나뿐입니다. 앞 청크의 마지막 줄(50자 이하)이 다음 청크 앞에 한 번 더 들어갔습니다
(300, 50)2214, 107'제목 + 부여 기준 + 이월'이 한 청크, '신청 절차'가 한 청크가 됩니다. 겹침은 전혀 생기지 않습니다. 오늘 벡터 DB를 만들 때 쓰는 설정이 이것입니다

너무 작게 자르면

"최대 25일까지 받을 수 있습니다" 같은 조각만 남아 무엇이 25일인지 알 수 없게 됩니다. 검색은 되어도 모델이 답을 만들 문맥이 부족합니다.

너무 크게 자르면

한 청크에 여러 주제가 섞여 벡터가 흐려지고, 질문과 무관한 글이 프롬프트를 채웁니다. 문서가 길어질수록 이 문제가 커집니다.

겹침은 설정한 글자 수만큼 정확히 생기지 않을 수 있습니다

위 표에서 본 대로입니다. 분할기는 글자를 하나씩 세어 자르는 것이 아니라 구분자로 나눈 조각(문단, 줄)을 이어 붙입니다. 겹침도 조각 단위로 채워지므로, 바로 앞 조각이 chunk_overlap 보다 길면 겹침이 아예 들어가지 않습니다. 위 그림은 원리를 보여 주는 것이고 실제 결과는 문서의 문단 길이에 달려 있습니다. 설정값을 믿지 말고 이 파일처럼 결과를 찍어서 확인하는 것이 맞는 순서입니다.

구조를 따라 자르기: 마크다운 헤더 분할

글자 수로 자르는 방법은 문서의 구조를 모릅니다. 규정 문서처럼 소제목이 곧 주제의 경계인 글이라면 소제목을 기준으로 자르는 편이 자연스럽습니다. MarkdownHeaderTextSplitter 가 그 일을 합니다.

chunking_md.py 5–11줄
 5docs = langchain_community.document_loaders.TextLoader("docs/leave_policy.md", encoding="utf-8").load()
 6
 7headers = [("#", "title"), ("##", "section")]
 8md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
 9md_chunks = md_splitter.split_text(docs[0].page_content)
10for c in md_chunks:
11    print(c.metadata, "->", c.page_content[:30])
(헤더 기호, 메타데이터에 쓸 키 이름) 쌍의 목록을 넘깁니다. 분할 결과의 metadata에 그 청크가 속한 제목과 소제목이 들어갑니다.
출력 (예시)
1{'title': '연차 규정'} -> 본 문서는 교육용 더미 데이터입니다.
2{'title': '연차 규정', 'section': '부여 기준'} -> 연차 휴가는 연 15일이 부여됩니다. 입사 1년 미만은
3{'title': '연차 규정', 'section': '이월'} -> 미사용 연차는 5일까지 다음 해로 이월할 수 있습니다.
4{'title': '연차 규정', 'section': '신청 절차'} -> 연차는 사용 5영업일 전까지 인사 포털에서 연차 신청서
11행의 print가 찍는 모양입니다. 청크마다 어느 제목, 어느 소제목 아래의 글인지가 metadata로 붙고, 헤더 줄 자체는 본문에서 빠집니다. 첫 소제목이 나오기 전의 안내문은 title 만 가진 청크가 됩니다.

이 방식의 이점은 청크 본문에 없는 정보가 메타데이터로 보존된다는 것입니다. "5일까지 다음 해로 이월할 수 있습니다"라는 문장만으로는 무엇을 이월하는지 알 수 없지만, title 이 '연차 규정'이라는 꼬리표가 붙어 있으면 복원할 수 있습니다.

split_text 는 문자열을 받으므로 source 가 사라집니다

9행은 Document가 아니라 docs[0].page_content, 곧 본문 문자열만 넘깁니다. 그래서 결과 청크의 metadata에는 헤더 정보만 있고 로더가 넣어 준 source 는 없습니다. 출처까지 유지하려면 분할한 뒤 원래 metadata를 각 청크에 다시 합쳐 줘야 합니다. 또 소제목 하나의 본문이 아주 긴 문서라면 헤더로 먼저 나눈 뒤 글자 수 기준 분할기를 한 번 더 적용하는 식으로 둘을 함께 씁니다.

참고로 이 파일은 "docs/leave_policy.md" 를 상대 경로로 열기 때문에 day2_practice 폴더 안에서 실행해야 합니다. chunking.py 의 5~6행에 있는 경고 끄기 구문은 langchain_community 를 import 할 때 뜨는 지원 종료 안내를 숨기는 것으로, 동작과는 무관합니다.

PYday2_practice/chunking.pychunk_size와 chunk_overlap을 세 가지로 바꿔 가며 분할 결과 비교21줄
 1# chunking.py - 청킹 설정 비교
 2import warnings
 3from pathlib import Path
 4
 5# langchain-community 지원 종료 안내 경고는 import 시점에 뜨므로 import 전에 꺼야 합니다
 6warnings.filterwarnings("ignore", message=".*langchain-community.*", category=DeprecationWarning)
 7
 8from langchain_community.document_loaders import TextLoader
 9from langchain_text_splitters import RecursiveCharacterTextSplitter
10
11# 실행 위치와 무관하게 이 파일 옆의 docs 폴더를 사용합니다
12DOCS_DIR = Path(__file__).parent / "docs"
13
14docs = TextLoader(str(DOCS_DIR / "leave_policy.md"), encoding="utf-8").load()
15
16for size, overlap in [(100, 0), (100, 50), (300, 50)]:
17    splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap)
18    chunks = splitter.split_documents(docs)
19    print(f"\n=== chunk_size={size}, overlap={overlap} -> {len(chunks)}개 청크 ===")
20    for i, c in enumerate(chunks):
21        print(f"[{i}] ({len(c.page_content)}자) {c.page_content[:40].replace(chr(10), ' ')}...")
PYday2_practice/chunking_md.py마크다운 제목과 소제목을 기준으로 분할하고 헤더를 메타데이터로 보존11줄
 1# chunking_md.py - 마크다운 헤더 기준 분할
 2import langchain_community.document_loaders
 3from langchain_text_splitters import MarkdownHeaderTextSplitter
 4
 5docs = langchain_community.document_loaders.TextLoader("docs/leave_policy.md", encoding="utf-8").load()
 6
 7headers = [("#", "title"), ("##", "section")]
 8md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
 9md_chunks = md_splitter.split_text(docs[0].page_content)
10for c in md_chunks:
11    print(c.metadata, "->", c.page_content[:30])

메타데이터: 청크에 꼬리표 달기

청크의 본문은 검색과 답변에 쓰이고, 메타데이터는 그 청크를 관리하는 데 쓰입니다. 어느 부서 문서인지, 언제 고친 것인지 같은 정보를 색인 단계에서 붙여 두면 질의 단계에서 세 가지를 할 수 있습니다.

출처 표시

답변 끝에 "(출처: 파일명)"을 붙이려면 청크가 자기 출신 파일을 알고 있어야 합니다.

필터

"총무팀 문서 중에서만 찾아라"처럼 검색 범위를 조건으로 좁힙니다. 의미 검색과 달리 조건에 맞지 않는 청크는 확실하게 제외됩니다.

관리

개정일을 붙여 두면 오래된 규정을 가려내거나, 특정 문서의 청크만 골라 교체할 수 있습니다.

metadata.py 5–22줄
 5# 파일별 업무 메타데이터 (교육용 더미 데이터)
 6FILE_META = {
 7    "docs/leave_policy.md":       {"department": "인사팀", "updated_at": "2026-01-15"},
 8    "docs/remote_work_policy.md": {"department": "인사팀", "updated_at": "2026-03-02"},
 9    "docs/travel_policy.md":      {"department": "총무팀", "updated_at": "2025-11-20"},
10}
11
12
13def load_chunks():
14    """세 문서를 로드해 메타데이터를 붙이고 청크로 나눕니다."""
15    splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
16    all_chunks = []
17    for path, meta in FILE_META.items():
18        docs = TextLoader(path, encoding="utf-8").load()
19        for d in docs:
20            d.metadata.update(meta)      # source 위에 업무 정보 추가
21        all_chunks.extend(splitter.split_documents(docs))
22    return all_chunks
파일 경로를 키로 하는 dict에 부서와 개정일을 적어 두고, 로드한 Document의 metadata에 합칩니다. 값은 교육용으로 지어낸 것입니다.
  1. 파일별 업무 정보를 표로 둔다FILE_META 는 "이 파일은 어느 부서 것이고 언제 개정됐다"를 적은 dict입니다. 실제 서비스라면 문서 관리 시스템이나 파일 속성에서 가져올 값입니다.
  2. 자르기 전에 붙인다20행의 d.metadata.update(meta) 는 로더가 넣어 준 source 를 지우지 않고 그 위에 두 항목을 더합니다. 이 작업이 분할보다 앞에 있다는 점이 중요합니다. split_documents 는 원본 Document의 metadata를 잘라 낸 청크 각각에 복사해 주므로, 문서에 한 번 붙이면 모든 청크가 물려받습니다.
  3. 함수로 묶어 재사용한다load_chunks() 는 "세 문서를 읽어 꼬리표를 붙이고 300자/50자 겹침으로 자른 청크 리스트"를 돌려줍니다. 뒤의 vectorstore.py 와 hybrid.py 가 이 함수를 import 해서 씁니다.
청크 하나의 metadata (예시)
1{'source': 'docs/leave_policy.md', 'department': '인사팀', 'updated_at': '2026-01-15'}
28행이 찍는 dict의 모양입니다. source 는 TextLoader에 넘긴 경로 문자열 그대로입니다.
if __name__ == "__main__" 이 하는 일

25행 아래의 코드는 python metadata.py 로 직접 실행할 때만 돕니다. 다른 파일이 from metadata import load_chunks 로 가져갈 때는 실행되지 않습니다. 이 장치가 없으면 import 하는 것만으로 화면에 출력이 쏟아집니다. 뒤에서 이 장치가 빠진 파일(hybrid.py)이 어떤 부작용을 내는지 보게 됩니다.

그래서 검색 대상은 청크 6개입니다

세 문서는 각각 300자를 조금 넘으므로 chunk_size=300 에서는 문서마다 두 조각으로 나뉩니다. 같은 분할기를 세 문서에 적용해 보면 아래와 같고, 같은 설정으로 만든 벡터 DB를 배포하는 6일차 폴더의 안내문에도 "6개 청크"라고 적혀 있습니다. 오늘 뒤에 나오는 모든 검색은 이 여섯 조각 가운데 몇 개를 고르는 일입니다.

문서첫 번째 청크두 번째 청크
leave_policy.md제목 + 안내문 + 부여 기준 + 이월 (214자)신청 절차 (107자)
remote_work_policy.md제목 + 안내문 + 운영 기준 + 보안 수칙 (278자)근태 (65자)
travel_policy.md제목 + 안내문 + 신청과 승인 + 경비 기준 (235자)정산 (77자)
두 번째 청크에는 문서 제목 줄이 없습니다

"# 출장 규정" 같은 제목 줄은 첫 번째 청크에만 들어갑니다. 오늘의 문서는 두 번째 청크도 "연차는 …", "재택근무 시작과 …", "출장비는 …"으로 시작해 본문만으로 주제가 드러나지만, 실제 규정 문서에는 "신청은 전주 목요일까지 …"처럼 무엇에 대한 것인지 말하지 않고 이어지는 조각이 흔합니다. 출처는 metadata의 source 에 남아 있어도 임베딩은 본문만 보므로, 그런 조각은 엉뚱한 질문에 걸리거나 맞는 질문에 걸리지 않습니다. 앞 절의 헤더 분할로 얻은 제목을 청크 본문 앞에 덧붙여 임베딩하는 것이 흔한 대응책입니다.

PYday2_practice/metadata.py문서에 부서·개정일 메타데이터를 붙이고 청크로 나누는 load_chunks() 정의29줄
오늘의 색인 단계 전체가 이 함수 하나에 들어 있습니다. 경로가 상대 경로이므로 day2_practice 폴더 안에서 실행해야 합니다.
 1# metadata.py - 메타데이터 부착 (뒤 실습에서 재사용)
 2from langchain_community.document_loaders import TextLoader
 3from langchain_text_splitters import RecursiveCharacterTextSplitter
 4
 5# 파일별 업무 메타데이터 (교육용 더미 데이터)
 6FILE_META = {
 7    "docs/leave_policy.md":       {"department": "인사팀", "updated_at": "2026-01-15"},
 8    "docs/remote_work_policy.md": {"department": "인사팀", "updated_at": "2026-03-02"},
 9    "docs/travel_policy.md":      {"department": "총무팀", "updated_at": "2025-11-20"},
10}
11
12
13def load_chunks():
14    """세 문서를 로드해 메타데이터를 붙이고 청크로 나눕니다."""
15    splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
16    all_chunks = []
17    for path, meta in FILE_META.items():
18        docs = TextLoader(path, encoding="utf-8").load()
19        for d in docs:
20            d.metadata.update(meta)      # source 위에 업무 정보 추가
21        all_chunks.extend(splitter.split_documents(docs))
22    return all_chunks
23
24
25if __name__ == "__main__":
26    chunks = load_chunks()
27    for c in chunks[:3]:
28        print(c.metadata)
29    print(f"\n총 청크 수: {len(chunks)}")

임베딩과 코사인 유사도: 뜻이 가까우면 벡터도 가깝다

"연차 며칠 받을 수 있어?"와 "월차 휴가 일수 알려줘"는 겹치는 단어가 거의 없지만 같은 것을 묻습니다. 글자가 아니라 뜻으로 찾으려면 뜻을 계산할 수 있는 형태로 바꿔야 합니다. 그 변환이 임베딩입니다.

임베딩: 글을 숫자 벡터로

임베딩 모델은 글을 입력받아 정해진 길이의 실수 목록(벡터)을 돌려주는 모델입니다. 답을 쓰는 LLM과는 다른 종류의 모델이고, 훨씬 작고 쌉니다. 이 모델은 뜻이 비슷한 글은 벡터 공간에서 가까운 위치에, 뜻이 다른 글은 먼 위치에 놓이도록 학습되어 있습니다. 벡터의 숫자 하나하나에 사람이 읽을 수 있는 의미는 없고, 벡터끼리의 상대적인 위치에만 의미가 있습니다.

글"오늘은 며칠인가요?" embed_query 임베딩 모델Titan Text Embeddings V2 벡터[0.01, -0.03, 0.07, ...] 두 벡터를 비교 유사도숫자 하나
embedding.py 6–13줄
 6embeddings = BedrockEmbeddings(
 7    model_id="amazon.titan-embed-text-v2:0",
 8    region_name="us-east-1",
 9)
10
11v = embeddings.embed_query("오늘은 며칠인가요?")
12print(f"벡터 차원: {len(v)}")
13print(f"앞 5개 값: {[round(x, 4) for x in v[:]]}")
Bedrock의 임베딩 모델을 불러 문장 하나를 벡터로 바꾸고, 벡터의 길이(차원)를 찍습니다.

BedrockEmbeddings 는 1일차의 ChatBedrockConverse 와 짝이 되는 클래스로, Bedrock의 임베딩 모델을 부릅니다. 여기서 쓰는 amazon.titan-embed-text-v2:0 의 기본 출력은 1024차원입니다. 메서드는 두 가지입니다. embed_query(글) 는 글 하나를 받아 벡터 하나를, embed_documents([글들]) 는 여러 글을 받아 벡터 목록을 돌려줍니다. 색인할 때는 뒤의 것이, 질문을 검색할 때는 앞의 것이 쓰입니다.

13행은 벡터 전체를 찍습니다

출력 문구는 "앞 5개 값"이지만 슬라이스가 v[:] 여서 벡터의 모든 값이 출력됩니다. 벡터가 실제로 얼마나 긴 숫자 목록인지 한 번 구경하기에는 좋습니다. 다섯 개만 보려면 v[:5] 로 바꿉니다.

코사인 유사도: 두 벡터가 같은 방향을 보는가

벡터 두 개가 얼마나 가까운지 재는 가장 흔한 방법이 코사인 유사도입니다. 두 벡터 사이 각도의 코사인 값으로, 방향이 같으면 1, 직각이면 0, 반대면 -1입니다. 벡터의 길이는 무시하고 방향만 봅니다.

similarity.py 13–17줄
13def cosine(a, b):
14    dot = sum(x * y for x, y in zip(a, b))
15    na = math.sqrt(sum(x * x for x in a))
16    nb = math.sqrt(sum(x * x for x in b))
17    return dot / (na * nb)
정의 그대로 구현한 코사인 유사도. 내적을 두 벡터 길이의 곱으로 나눕니다. 라이브러리 없이 다섯 줄입니다.
similarity.py 20–29줄
20pairs = [
21    ("연차 며칠 받을 수 있어?", "월차 휴가 일수 알려줘"),   # 같은 의미, 다른 단어
22    ("연차 며칠 받을 수 있어?", "VPN 설정 방법"),           # 무관한 주제
23    ("Python", "파이썬"),                                    # 다른 언어, 같은 의미
24    ("출장비 정산", "경비 처리 기한"),                       # 비슷한 업무 영역
25]
26
27for a, b in pairs:
28    va, vb = embeddings.embed_query(a), embeddings.embed_query(b)
29    print(f"'{a}' vs '{b}' -> {cosine(va, vb):.3f}")
문장 쌍 네 개의 유사도를 잽니다. 쌍마다 임베딩 API를 두 번 부릅니다.
문장 쌍확인하려는 것
"연차 며칠 받을 수 있어?" / "월차 휴가 일수 알려줘"단어는 다르고 뜻은 같은 경우. 키워드 검색으로는 잡히지 않는 관계가 숫자로 드러나는지
"연차 며칠 받을 수 있어?" / "VPN 설정 방법"주제가 무관한 경우. 첫 번째 쌍보다 뚜렷하게 낮은 값이 나오는지 (기준선)
"Python" / "파이썬"언어가 달라도 같은 대상이면 가깝게 놓이는지. 다국어 모델인지 확인하는 시험
"출장비 정산" / "경비 처리 기한"같은 뜻은 아니지만 같은 업무 영역인 경우. 앞의 두 극단 사이 어디쯤에 놓이는지
유사도 숫자는 절대 평가가 아닙니다

"0.8 이상이면 같은 뜻" 같은 보편적인 기준은 없습니다. 값의 분포는 임베딩 모델마다 다르고, 무관한 문장끼리도 0이 아닌 값이 나오는 것이 보통입니다. 이 실습이 무관한 쌍을 일부러 넣은 이유가 그것입니다. 숫자는 같은 모델로 잰 다른 쌍과 비교할 때만 의미가 있습니다.

색인과 질의에 같은 임베딩 모델을 써야 합니다

벡터의 위치는 그 벡터를 만든 모델 안에서만 뜻이 있습니다. 문서를 A 모델로 임베딩해 저장하고 질문을 B 모델로 임베딩하면, 두 벡터는 서로 다른 좌표계에 있으므로 거리 계산이 무의미해집니다(차원이 다르면 오류가 납니다). 오늘의 모든 파일이 같은 model_id 를 반복해서 적는 이유입니다. 임베딩 모델을 바꾸려면 문서 전체를 다시 색인해야 합니다.

앞 절의 청킹과 메타데이터부터 이 절의 임베딩, 코사인 유사도까지를 한 흐름으로 다시 넘겨 보려면 아래 플레이어를 여세요.

플레이어

청킹과 임베딩 플레이어

연차 규정 323자가 (100, 0), (100, 50), (300, 50) 설정에서 어디서 잘리는지를 원고의 실제 청크 수와 글자 수로 그리고, 마크다운 헤더 분할과 청크에 붙는 메타데이터, 글이 Titan v2 벡터가 되어 cosine 함수로 비교되는 과정을 11단계 플레이어로 보여 줍니다. 유사도 수치는 싣지 않고 계산식과 확인하려는 관계만 다룹니다.

새 탭으로day2_practice/chunking_embedding_explained.html
PYday2_practice/embedding.py문장 하나를 임베딩해 벡터의 차원과 값을 확인13줄
 1# embedding.py - 임베딩 확인
 2from dotenv import load_dotenv
 3from langchain_aws import BedrockEmbeddings
 4
 5load_dotenv()
 6embeddings = BedrockEmbeddings(
 7    model_id="amazon.titan-embed-text-v2:0",
 8    region_name="us-east-1",
 9)
10
11v = embeddings.embed_query("오늘은 며칠인가요?")
12print(f"벡터 차원: {len(v)}")
13print(f"앞 5개 값: {[round(x, 4) for x in v[:]]}")
PYday2_practice/similarity.py문장 쌍 네 개의 코사인 유사도를 직접 계산해 '의미가 가깝다'를 숫자로 확인29줄
 1# similarity.py - 코사인 유사도 체험
 2import math
 3from dotenv import load_dotenv
 4from langchain_aws import BedrockEmbeddings
 5
 6load_dotenv()
 7embeddings = BedrockEmbeddings(
 8    model_id="amazon.titan-embed-text-v2:0",
 9    region_name="us-east-1",
10)
11
12
13def cosine(a, b):
14    dot = sum(x * y for x, y in zip(a, b))
15    na = math.sqrt(sum(x * x for x in a))
16    nb = math.sqrt(sum(x * x for x in b))
17    return dot / (na * nb)
18
19
20pairs = [
21    ("연차 며칠 받을 수 있어?", "월차 휴가 일수 알려줘"),   # 같은 의미, 다른 단어
22    ("연차 며칠 받을 수 있어?", "VPN 설정 방법"),           # 무관한 주제
23    ("Python", "파이썬"),                                    # 다른 언어, 같은 의미
24    ("출장비 정산", "경비 처리 기한"),                       # 비슷한 업무 영역
25]
26
27for a, b in pairs:
28    va, vb = embeddings.embed_query(a), embeddings.embed_query(b)
29    print(f"'{a}' vs '{b}' -> {cosine(va, vb):.3f}")

벡터 DB: 청크와 벡터를 저장하고 가까운 것 찾기

청크마다 벡터를 만들었으면 어딘가에 보관해야 하고, 질문이 오면 그중에서 질문 벡터와 가장 가까운 것을 빨리 골라내야 합니다. 이 두 가지를 맡는 저장소가 벡터 DB(벡터 저장소)입니다. 실습에서는 별도 서버 없이 로컬 폴더에 저장하는 Chroma를 씁니다.

벡터 DB의 한 행은 네 가지로 이루어집니다. 식별자(ID), 벡터, 원문(청크의 page_content), 메타데이터입니다. 검색은 벡터로 하고, 돌려주는 것은 원문과 메타데이터입니다. 관계형 DB의 테이블에 해당하는 묶음 단위를 Chroma에서는 컬렉션이라고 부릅니다.

적재: 한 줄 안에서 일어나는 일

vectorstore.py 20–26줄
20db = Chroma.from_documents(
21    documents=load_chunks(), 
22    embedding=embeddings,
23    collection_name="sds_policies",
24    persist_directory="./chroma_db",   # 이 폴더에 저장됩니다 (6일차까지 사용)
25)
26print(f"적재 완료: {db._collection.count()}개 청크")
색인 단계의 마지막입니다. 이 호출 하나가 청크 전부를 임베딩하고 디스크에 저장합니다.
  1. load_chunks() 가 청크 리스트를 만든다앞 절의 함수입니다. 세 문서를 읽고, 꼬리표를 붙이고, 자릅니다.
  2. from_documents 가 청크마다 벡터를 계산한다넘겨받은 embedding 객체로 모든 청크의 본문을 임베딩합니다. 이때 Bedrock 임베딩 API가 호출됩니다. 오늘 실습에서 색인 비용이 드는 곳은 여기입니다.
  3. 컬렉션에 저장한다collection_name="sds_policies" 라는 이름의 컬렉션에 벡터, 원문, 메타데이터를 넣습니다.
  4. 디스크에 남긴다persist_directory="./chroma_db" 를 주면 그 폴더에 파일로 저장됩니다(SQLite 파일 하나와 인덱스 폴더가 생깁니다). 이 인자를 빼면 메모리에만 있다가 프로그램이 끝날 때 사라집니다.

26행의 db._collection.count() 는 컬렉션에 들어 있는 행의 수를 셉니다. 밑줄로 시작하는 이름은 라이브러리 내부용이라는 표시이므로 실습에서 확인용으로만 씁니다.

두 번 실행하면 같은 청크가 한 벌 더 쌓입니다

이 코드는 청크의 ID를 지정하지 않습니다. 그러면 실행할 때마다 새 ID가 붙으므로, 이미 chroma_db 가 있는 상태에서 다시 돌리면 기존 것을 덮어쓰지 않고 같은 내용이 추가됩니다. "적재 완료"의 숫자가 실행할 때마다 커진다면 중복이 쌓이는 중입니다. 중복이 있으면 상위 k개가 똑같은 청크로 채워져 검색 결과의 다양성이 줄어듭니다. 13~17행에 주석으로 남아 있는 코드가 그 대비책입니다.

vectorstore.py 13–17줄
13# Chroma(
14#     collection_name="sds_policies",
15#     embedding_function=embeddings,
16#     persist_directory="./chroma_db",
17# ).delete_collection()
주석을 풀고 실행하면 컬렉션을 통째로 지운 뒤 새로 만듭니다. 문서를 고쳐서 다시 색인할 때 씁니다. chroma_db 폴더를 직접 삭제해도 결과는 같습니다.

검색해 보기: 점수는 '거리'

vectorstore.py 28–33줄
28# 의미 검색 확인 (점수 포함)
29for q in ["월차 며칠 받을 수 있어?", "집에서 일해도 되나요?", "법카 등록 기한"]:
30    hits = db.similarity_search_with_score(q, k=2)
31    print(f"\n질문: {q}")
32    for doc, score in hits:
33        print(f"  [{doc.metadata['source']}] (거리 {score:.3f}) {doc.page_content[:40]}...")
질문 세 개로 검색하고, 청크마다 출처와 점수, 본문 앞 40자를 찍습니다.

similarity_search_with_score(q, k=2) 는 질문 문자열을 받아 내부에서 임베딩한 뒤 가장 가까운 청크 2개를 (Document, 점수) 쌍으로 돌려줍니다. 여기서 점수는 유사도가 아니라 거리입니다. 코드가 "거리"라고 찍는 이유가 그것입니다.

유사도 (similarity)

클수록 가깝습니다. 앞 절의 코사인 유사도가 이쪽입니다.

거리 (distance)

작을수록 가깝습니다. Chroma가 돌려주는 점수가 이쪽입니다. 방향이 반대이므로 "점수가 높으니 잘 맞는다"고 읽으면 정반대로 해석하게 됩니다.

세 질문은 모두 핵심 단어를 문서와 다르게 골랐습니다. 문서에는 "연차", "재택근무", "법인카드"라고 적혀 있고 질문은 "월차", "집에서 일해도", "법카"라고 묻습니다. 핵심 단어가 겹치지 않아도 맞는 문서가 위로 올라오는지가 관찰 지점입니다.

메타데이터 필터

vectorstore.py 35–37줄
35# 메타데이터 필터 검색
36hits = db.similarity_search("경비 정산 규정", k=2, filter={"department": "총무팀"})
37print(f"\n총무팀 문서로 한정 검색: {[h.metadata['source'] for h in hits]}")
filter 조건에 맞는 청크 안에서만 유사도 검색을 합니다.

filter={"department": "총무팀"} 은 앞 절에서 붙인 꼬리표를 검색 조건으로 씁니다. 세 문서 가운데 총무팀 문서는 출장 규정 하나뿐이므로, 질문이 무엇이든 결과는 그 문서의 청크로 한정됩니다. 의미 검색은 "가장 가까운 것"을 돌려줄 뿐 범위를 보장하지 않습니다. 접근 권한이나 부서 구분처럼 반드시 지켜져야 하는 조건은 유사도가 아니라 필터로 걸어야 합니다.

PYday2_practice/vectorstore.py청크를 임베딩해 Chroma에 저장하고, 거리 점수와 메타데이터 필터로 검색 확인37줄
오늘의 나머지 파일이 모두 이 스크립트가 만든 chroma_db 폴더를 엽니다. 컬렉션 이름과 폴더 이름은 6일차의 RAG 에이전트에서도 같은 값으로 다시 쓰입니다.
 1# vectorstore.py - 벡터 저장소 구축과 디스크 저장
 2from dotenv import load_dotenv
 3from langchain_aws import BedrockEmbeddings
 4from langchain_chroma import Chroma
 5from metadata import load_chunks
 6
 7load_dotenv()
 8embeddings = BedrockEmbeddings(
 9    model_id="amazon.titan-embed-text-v2:0",
10    region_name="us-east-1",
11)
12
13# Chroma(
14#     collection_name="sds_policies",
15#     embedding_function=embeddings,
16#     persist_directory="./chroma_db",
17# ).delete_collection()
18
19
20db = Chroma.from_documents(
21    documents=load_chunks(), 
22    embedding=embeddings,
23    collection_name="sds_policies",
24    persist_directory="./chroma_db",   # 이 폴더에 저장됩니다 (6일차까지 사용)
25)
26print(f"적재 완료: {db._collection.count()}개 청크")
27
28# 의미 검색 확인 (점수 포함)
29for q in ["월차 며칠 받을 수 있어?", "집에서 일해도 되나요?", "법카 등록 기한"]:
30    hits = db.similarity_search_with_score(q, k=2)
31    print(f"\n질문: {q}")
32    for doc, score in hits:
33        print(f"  [{doc.metadata['source']}] (거리 {score:.3f}) {doc.page_content[:40]}...")
34
35# 메타데이터 필터 검색
36hits = db.similarity_search("경비 정산 규정", k=2, filter={"department": "총무팀"})
37print(f"\n총무팀 문서로 한정 검색: {[h.metadata['source'] for h in hits]}")
PYday2_practice/load_db.pyvectorstore.py 의 사본 (빈 줄 하나와 줄 끝 공백 한 칸만 다름)36줄
강의 중에 같은 내용을 다른 이름으로 저장한 파일입니다. 첫 줄 주석도 vectorstore.py 그대로이고 동작은 완전히 같습니다. 둘 중 하나만 실행하면 됩니다. 둘 다 실행하면 위에서 말한 중복 적재가 일어납니다.
 1# vectorstore.py - 벡터 저장소 구축과 디스크 저장
 2from dotenv import load_dotenv
 3from langchain_aws import BedrockEmbeddings
 4from langchain_chroma import Chroma
 5from metadata import load_chunks
 6
 7load_dotenv()
 8embeddings = BedrockEmbeddings(
 9    model_id="amazon.titan-embed-text-v2:0",
10    region_name="us-east-1",
11)
12
13# Chroma(
14#     collection_name="sds_policies",
15#     embedding_function=embeddings,
16#     persist_directory="./chroma_db",
17# ).delete_collection()
18
19db = Chroma.from_documents(
20    documents=load_chunks(),
21    embedding=embeddings,
22    collection_name="sds_policies",
23    persist_directory="./chroma_db",   # 이 폴더에 저장됩니다 (6일차까지 사용)
24)
25print(f"적재 완료: {db._collection.count()}개 청크")
26
27# 의미 검색 확인 (점수 포함)
28for q in ["월차 며칠 받을 수 있어?", "집에서 일해도 되나요?", "법카 등록 기한"]:
29    hits = db.similarity_search_with_score(q, k=2)
30    print(f"\n질문: {q}")
31    for doc, score in hits:
32        print(f"  [{doc.metadata['source']}] (거리 {score:.3f}) {doc.page_content[:40]}...")
33
34# 메타데이터 필터 검색
35hits = db.similarity_search("경비 정산 규정", k=2, filter={"department": "총무팀"})
36print(f"\n총무팀 문서로 한정 검색: {[h.metadata['source'] for h in hits]}")

Retriever: 검색을 체인의 부품으로

벡터 DB의 검색 메서드를 직접 불러도 되지만, 체인에 끼우려면 1일차의 Runnable 규격을 따르는 부품이어야 합니다. Retriever(검색기)가 그 규격입니다. 입력은 질문 문자열, 출력은 Document 리스트입니다.

retriever.py 11–20줄
11db = Chroma(
12    collection_name="sds_policies",
13    embedding_function=embeddings,
14    persist_directory="./chroma_db",
15)
16
17retriever = db.as_retriever(search_kwargs={"k": 3})
18docs = retriever.invoke("연차 신청은 어떻게 하나요?")
19for d in docs:
20    print(f"[{d.metadata['source']}] {d.page_content[:50]}...")
저장해 둔 DB를 다시 열고, 검색기로 바꾼 뒤, invoke 로 부릅니다.
  1. DB를 새로 만들지 않고 연다Chroma.from_documents(...) 가 아니라 Chroma(...) 생성자를 씁니다. 같은 컬렉션 이름과 폴더를 가리키면 디스크에 있는 것을 그대로 엽니다. 이 파일에는 문서를 읽거나 자르는 코드가 없습니다. 질의 단계는 색인 단계의 결과물만 있으면 됩니다.
  2. 임베딩 객체는 여기서도 필요하다DB에는 벡터가 저장되어 있을 뿐, 질문을 벡터로 바꿔 주는 모델이 들어 있는 것은 아닙니다. 질문을 벡터로 바꿀 때 쓸 모델을 embedding_function 으로 다시 알려 줘야 하고, 색인 때와 같은 모델이어야 합니다.
  3. as_retriever 로 감싼다search_kwargs={"k": 3} 은 "가까운 순으로 3개"라는 뜻입니다. 이렇게 만든 retriever 는 .invoke(질문) 으로 부를 수 있고 | 로 다른 부품과 이을 수 있습니다.

k: 몇 개를 가져올 것인가

retriever.py 22–26줄
22# k값에 따른 차이 관찰
23for k in [1, 3, 5]:
24    r = db.as_retriever(search_kwargs={"k": k})
25    hits = r.invoke("연차 이월 규정")
26    print(f"k={k}: {[h.metadata['source'] for h in hits]}")
같은 질문에 k만 바꿔 가며 어떤 문서의 청크가 들어오는지 출처만 찍어 봅니다.
k얻는 것잃는 것
작게 (1)프롬프트가 짧고 관련 없는 글이 섞이지 않습니다1위가 틀리면 답할 재료가 없습니다. 답이 두 청크에 걸쳐 있으면 절반만 봅니다
크게 (5)정답 청크가 포함될 가능성이 높아집니다무관한 문서의 청크까지 딸려 와 토큰이 늘고, 모델이 엉뚱한 청크를 근거로 삼을 여지가 생깁니다

오늘의 DB는 청크가 6개뿐이라 k=5 는 거의 전부를 가져오는 셈입니다. 출력에서 "연차 이월 규정"이라는 질문에 재택근무나 출장 문서가 몇 번째부터 섞여 드는지를 보면 k의 의미가 분명해집니다. 벡터 검색은 관련이 있든 없든 항상 k개를 채워서 돌려준다는 점도 함께 기억해 둡니다.

MMR: 비슷한 것만 여러 개 가져오지 않기

retriever.py 28–30줄
28# MMR - 넓은 질문에서 여러 문서를 고르게
29mmr = db.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 6})
30print("MMR:", [h.metadata["source"] for h in mmr.invoke("회사 규정 전반")])
search_type 을 mmr 로 바꿉니다. 후보 6개(fetch_k)를 먼저 뽑고 그중 3개(k)를 고릅니다.

기본 검색은 가까운 순서대로 자르기만 합니다. 그래서 상위 결과가 서로 거의 같은 내용의 청크로 채워질 수 있습니다. MMR(Maximal Marginal Relevance)은 후보를 넉넉히(fetch_k) 뽑아 놓고, 질문과 가까우면서도 이미 고른 것과는 덜 비슷한 청크를 하나씩 골라 k 개를 채웁니다. "회사 규정 전반"처럼 범위가 넓은 질문에서 한 문서에 쏠리지 않고 여러 문서를 고르게 가져오는지가 관찰 지점입니다. 반대로 답이 한 문서에 몰려 있는 구체적인 질문에서는 다양성 때문에 필요한 청크가 밀려날 수 있습니다.

PYday2_practice/retriever.py저장된 Chroma를 열어 Retriever로 만들고 k 값과 MMR에 따른 결과 차이 관찰30줄
 1# retriever.py - Retriever 생성과 k값 조정
 2from dotenv import load_dotenv
 3from langchain_aws import BedrockEmbeddings
 4from langchain_chroma import Chroma
 5
 6load_dotenv()
 7embeddings = BedrockEmbeddings(
 8    model_id="amazon.titan-embed-text-v2:0",
 9    region_name="us-east-1",
10)
11db = Chroma(
12    collection_name="sds_policies",
13    embedding_function=embeddings,
14    persist_directory="./chroma_db",
15)
16
17retriever = db.as_retriever(search_kwargs={"k": 3})
18docs = retriever.invoke("연차 신청은 어떻게 하나요?")
19for d in docs:
20    print(f"[{d.metadata['source']}] {d.page_content[:50]}...")
21
22# k값에 따른 차이 관찰
23for k in [1, 3, 5]:
24    r = db.as_retriever(search_kwargs={"k": k})
25    hits = r.invoke("연차 이월 규정")
26    print(f"k={k}: {[h.metadata['source'] for h in hits]}")
27
28# MMR - 넓은 질문에서 여러 문서를 고르게
29mmr = db.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 6})
30print("MMR:", [h.metadata["source"] for h in mmr.invoke("회사 규정 전반")])

RAG 체인 조립: 찾은 문서를 프롬프트에 끼우기

재료가 다 모였습니다. 검색기가 돌려준 청크를 프롬프트의 빈칸에 넣고, 1일차에 만든 프롬프트 | 모델 | 파서 체인 앞에 붙이면 RAG 체인이 됩니다. 새로 배우는 것은 맨 앞의 한 줄뿐입니다.

질문str 검색 + 정리retriever | format_docs
질문은 그대로 통과
dict 프롬프트지침 + 문서 + 질문 모델Claude 파서글자만 꺼냄 답변str

가운데 세 칸(프롬프트, 모델, 파서)은 1일차의 체인 그대로입니다. 달라진 것은 프롬프트에 들어갈 dict를 만드는 앞 단계입니다.

프롬프트: 모델에게 주는 세 가지 규칙

rag_chain.py 26–35줄
26retriever = db.as_retriever(search_kwargs={"k": 3})
27
28prompt = ChatPromptTemplate.from_messages([
29    ("system",
30     "너는 삼성SDS 사내 정책 안내 어시스턴트야. 아래 사내 문서만 참고해 답해. "
31     "문서에 없는 내용은 '해당 내용은 사내 문서에서 찾을 수 없습니다'라고 답해. 추측하지 마. "
32     "답변 끝에 참고한 문서를 '(출처: 파일명)' 형식으로 붙여.\n\n"
33     "--- 사내 문서 ---\n{context}"),
34    ("human", "{question}"),
35])
system 메시지 끝의 {context} 자리에 검색된 문서가, human 메시지의 {question} 자리에 질문이 들어갑니다.
규칙프롬프트의 문구없으면 생기는 일
범위 제한"아래 사내 문서만 참고해 답해"모델이 문서와 자기 기억을 섞어 답합니다. 어디까지가 문서 내용인지 구분할 수 없게 됩니다
거절"문서에 없는 내용은 '해당 내용은 사내 문서에서 찾을 수 없습니다'라고 답해. 추측하지 마"검색 결과에 답이 없을 때 모델이 빈자리를 지어내서 메웁니다. RAG를 붙이고도 환각이 남는 가장 흔한 경로입니다
출처 표시"답변 끝에 참고한 문서를 '(출처: 파일명)' 형식으로 붙여"사용자가 답을 검증할 방법이 없습니다

거절 문구를 정확한 문장으로 정해 둔 데에는 이유가 하나 더 있습니다. 프로그램이 "모델이 거절했는가"를 문자열로 판별할 수 있게 됩니다. 오늘 마지막의 평가 스크립트가 이 문구의 일부를 찾아 채점합니다.

체인: dict 한 줄이 하는 일

rag_chain.py 38–49줄
38def format_docs(docs):
39    return "\n\n---\n\n".join(
40        f"[{d.metadata.get('source', '?')}] {d.page_content}" for d in docs
41    )
42
43
44rag_chain = (
45    {"context": retriever | format_docs, "question": RunnablePassthrough()}
46    | prompt
47    | llm
48    | StrOutputParser()
49)
format_docs 는 Document 리스트를 프롬프트에 넣을 문자열 하나로 바꿉니다. 45행이 RAG에서 새로 등장하는 부분입니다.
  1. 체인의 입력은 질문 문자열 하나입니다rag_chain.invoke("질문") 처럼 부릅니다. 그런데 프롬프트는 context 와 question 두 칸을 가진 dict를 요구합니다. 45행의 dict가 문자열 하나를 dict로 바꾸는 변환기입니다.
  2. dict는 병렬 실행으로 바뀝니다LCEL에서 | 앞에 놓인 dict는 1일차의 RunnableParallel로 자동 변환됩니다. 같은 입력(질문)이 dict의 각 값으로 동시에 들어가고, 결과가 같은 키의 dict로 모입니다.
  3. context 쪽: 검색하고 문자열로 정리retriever | format_docs 는 질문으로 청크 3개를 검색한 뒤 format_docs 에 넘깁니다. 평범한 함수도 | 로 이으면 체인의 부품(RunnableLambda)으로 취급됩니다. 이 함수는 청크마다 앞에 [출처] 를 붙이고 청크 사이를 --- 구분선으로 나눕니다.
  4. question 쪽: 그대로 통과RunnablePassthrough() 는 받은 값을 손대지 않고 내보내는 부품입니다. 질문 원문이 프롬프트의 {question} 칸까지 가려면 이 통로가 필요합니다.
  5. 나머지는 1일차와 같습니다프롬프트가 메시지를 만들고, 모델이 답하고, 파서가 문자열만 꺼냅니다.
출처는 모델이 아는 것이 아니라 우리가 적어 준 것입니다

모델이 "(출처: docs/travel_policy.md)"라고 쓸 수 있는 것은 format_docs 가 청크 앞에 [docs/travel_policy.md] 를 붙여 프롬프트에 넣었기 때문입니다. 메타데이터는 자동으로 모델에게 전달되지 않습니다. 모델에게 보여 주고 싶은 꼬리표는 이렇게 글자로 적어 넣어야 합니다. 부서나 개정일도 같은 방법으로 넣을 수 있습니다.

rag_chain.py 51–52줄
51if __name__ == "__main__":
52    print(rag_chain.invoke("회사에서 교육을 보냈는데 환경이 x같아? 누구에게 항의해야해?"))
직접 실행했을 때만 도는 시험 질문입니다. 세 문서 어디에도 답이 없는 내용이라, 프롬프트의 거절 규칙이 지켜지는지를 볼 수 있습니다.
이 파일은 다른 파일들의 부품 창고이기도 합니다

뒤의 verify.py, verify_sources.py, failures.py, hybrid.py, eval.py 가 모두 from rag_chain import ... 로 rag_chain, retriever, db, llm 을 가져다 씁니다. 시험 질문을 if __name__ == "__main__": 아래에 둔 덕분에 import 할 때는 질문이 실행되지 않습니다.

이 체인은 질문이 무엇이든 무조건 검색합니다

"안녕하세요"라고 해도 청크 3개를 찾아 프롬프트에 넣습니다. 검색이 필요한지 판단하거나, 결과가 부실하면 질문을 바꿔 다시 찾는 일은 한 방향으로 흐르는 체인으로는 표현할 수 없습니다. 3일차의 조건부 엣지와 루프, 그리고 검색기를 도구로 만들어 에이전트가 필요할 때만 부르게 하는 6일차의 구성이 이 한계에 대한 답입니다.

색인 단계부터 이 체인의 마지막 파서까지를 질문 하나로 이어서 따라가 보려면 아래 플레이어를 넘겨 보세요.

플레이어

RAG 파이프라인 플레이어

색인 단계(로드, 메타데이터, 청킹, 임베딩, Chroma 저장)와 질의 단계를 구조도와 14단계 플레이어로 보여 줍니다. 질문 하나가 문자열에서 dict, 메시지, 답 문자열로 모양을 바꾸는 과정을 실제 코드 줄과 나란히 놓고, verify.py 와 verify_sources.py 로 근거를 확인하는 방법까지 다룹니다.

새 탭으로day2_practice/rag_pipeline_explained.html
PYday2_practice/rag_chain.py검색기, 프롬프트, 모델, 파서를 이은 기본 RAG 체인. 다른 실습 파일들이 import 해서 재사용52줄
모듈 수준에 llm, embeddings, db, retriever, rag_chain 이 정의되어 있어 다른 파일에서 그대로 가져다 씁니다. ./chroma_db 가 먼저 만들어져 있어야 합니다.
 1# rag_chain.py - 기본 RAG 체인
 2from dotenv import load_dotenv
 3from langchain_aws import ChatBedrockConverse, BedrockEmbeddings
 4from langchain_chroma import Chroma
 5from langchain_core.prompts import ChatPromptTemplate
 6from langchain_core.output_parsers import StrOutputParser
 7from langchain_core.runnables import RunnablePassthrough
 8
 9load_dotenv()
10
11llm = ChatBedrockConverse(
12    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
13    region_name="us-east-1",
14    temperature=0,
15)
16embeddings = BedrockEmbeddings(
17    model_id="amazon.titan-embed-text-v2:0",
18    region_name="us-east-1",
19)
20
21db = Chroma(
22    collection_name="sds_policies",
23    embedding_function=embeddings,
24    persist_directory="./chroma_db",
25)
26retriever = db.as_retriever(search_kwargs={"k": 3})
27
28prompt = ChatPromptTemplate.from_messages([
29    ("system",
30     "너는 삼성SDS 사내 정책 안내 어시스턴트야. 아래 사내 문서만 참고해 답해. "
31     "문서에 없는 내용은 '해당 내용은 사내 문서에서 찾을 수 없습니다'라고 답해. 추측하지 마. "
32     "답변 끝에 참고한 문서를 '(출처: 파일명)' 형식으로 붙여.\n\n"
33     "--- 사내 문서 ---\n{context}"),
34    ("human", "{question}"),
35])
36
37
38def format_docs(docs):
39    return "\n\n---\n\n".join(
40        f"[{d.metadata.get('source', '?')}] {d.page_content}" for d in docs
41    )
42
43
44rag_chain = (
45    {"context": retriever | format_docs, "question": RunnablePassthrough()}
46    | prompt
47    | llm
48    | StrOutputParser()
49)
50
51if __name__ == "__main__":
52    print(rag_chain.invoke("회사에서 교육을 보냈는데 환경이 x같아? 누구에게 항의해야해?"))

검증: 정말 문서를 보고 답하는가

체인이 답을 내놓는다고 끝이 아닙니다. 그 답이 문서에서 나온 것인지, 문서에 없는 것을 물었을 때 모른다고 하는지를 확인해야 합니다. 같은 질문을 RAG 없이, 그리고 RAG로 두 번 던져 나란히 놓고 봅니다.

verify.py 13–23줄
13questions = [
14    "국내 출장 식비 한도는 하루 얼마인가요?",   # 문서에 답 있음
15    "재택근무는 주 며칠까지 가능한가요?",       # 문서에 답 있음
16    "육아휴직은 얼마나 쓸 수 있나요?",          # 문서에 답 없음 -> 모른다고 해야 정답
17]
18
19for q in questions:
20    print("=" * 60)
21    print(f"질문: {q}")
22    print(f"\n[RAG 없음] {get_text(llm.invoke(q))[:100]}")
23    print(f"\n[RAG 적용] {rag_chain.invoke(q)}")
질문마다 모델 직접 호출(앞 100자만)과 RAG 체인 호출을 차례로 찍습니다. 세 번째 질문은 일부러 문서에 없는 것을 묻습니다.
질문문서에 적힌 내용RAG 체인에 기대하는 답
국내 출장 식비 한도는 하루 얼마인가요?출장 규정: "국내 출장 식비 한도는 하루 5만원"5만원, 출처는 출장 규정
재택근무는 주 며칠까지 가능한가요?재택근무 규정: "주 2일까지", 수요일은 불가주 2일, 출처는 재택근무 규정
육아휴직은 얼마나 쓸 수 있나요?세 문서 어디에도 없음"해당 내용은 사내 문서에서 찾을 수 없습니다"
세 번째 질문이 이 실습의 본론입니다

문서에 답이 있는 질문에 맞게 답하는 것은 RAG의 절반입니다. 나머지 절반은 답이 없을 때 지어내지 않는 것입니다. 육아휴직은 모델이 일반 상식으로 답을 알고 있는 주제라서, 검색된 문서에 내용이 없어도 아는 대로 답하고 싶어지는 질문입니다. 이때 프롬프트의 거절 규칙이 지켜지는지를 보는 것이 목적입니다.

RAG 없이 부른 쪽은 [:100] 으로 앞 100자만 잘라 찍습니다. 직접 호출한 모델의 답은 길어지기 쉬워서 비교에 필요한 첫머리만 보는 것입니다. 질문 하나에 모델 호출 두 번과 임베딩 호출 한 번이 일어납니다.

근거 문서를 답과 함께 돌려주기

답변 끝의 "(출처: ...)"는 모델이 쓴 글입니다. 모델이 출처를 빠뜨리거나 틀리게 적을 수 있으므로, 화면에 근거를 보여 줄 때는 모델의 글이 아니라 검색기가 실제로 돌려준 문서 목록을 프로그램이 직접 들고 있는 편이 안전합니다.

verify_sources.py 5–11줄
 5rag_with_sources = RunnableParallel(
 6    answer=rag_chain,
 7    sources=retriever | (lambda docs: sorted({d.metadata["source"] for d in docs})),
 8)
 9result = rag_with_sources.invoke("물류플랫폼팀 박도윤입니다. 해외 출장 숙박비 한도가 얼마인가요?")
10print(result["answer"])
11print("근거 문서:", result["sources"])
답을 만드는 체인과 출처 목록을 만드는 체인을 병렬로 묶어 dict 하나로 받습니다.

RunnableParallel 에 이름표 두 개를 줍니다. answer 에는 RAG 체인 전체가, sources 에는 "검색하고 → 청크들의 source 를 집합으로 모아 중복을 없애고 → 정렬한 리스트로 만드는" 작은 체인이 들어갑니다. 결과는 result["answer"] 와 result["sources"] 로 꺼냅니다. 집합({...})을 쓴 것은 같은 파일에서 청크가 여러 개 검색되어도 파일 이름은 한 번만 나오게 하기 위해서입니다.

sources 는 '검색된 문서'이지 '답에 쓰인 문서'가 아닙니다

이 목록에는 상위 3개 청크의 출처가 모두 들어갑니다. 그중 모델이 실제로 근거로 삼은 것은 하나일 수도 있습니다. 오늘의 DB는 문서 한 편이 청크 두 개씩이므로(중복 적재가 없다면) 상위 3개에는 반드시 두 편 이상의 문서가 섞이고, 따라서 질문과 무관한 문서가 목록에 항상 하나는 낍니다. 또 이 구성에서는 rag_chain 안에서 한 번, sources 쪽에서 한 번, 같은 질문으로 검색이 두 번 실행됩니다. 결과는 같지만 임베딩 호출이 두 배입니다. 검색을 한 번만 하고 그 결과를 답 생성과 출처 표시에 함께 쓰도록 체인을 다시 짜는 것이 다음 개선 과제입니다.

시험 질문 앞에는 "물류플랫폼팀 박도윤입니다."라는 자기소개가 붙어 있습니다. 이 체인은 사용자가 입력한 문장 전체를 그대로 임베딩해 검색어로 쓰므로, 질문과 무관한 말도 검색에 함께 들어간다는 점을 기억해 둡니다.

PYday2_practice/verify.py같은 질문을 RAG 없이, RAG로 각각 던져 답을 비교. 문서에 없는 질문 포함23줄
 1# verify.py - RAG 유무 비교 검증
 2from rag_chain import rag_chain, llm
 3
 4
 5def get_text(message):
 6    """ChatBedrockConverse는 content를 블록 리스트로 주기도 하므로 텍스트만 모아 반환합니다."""
 7    content = message.content
 8    if isinstance(content, list):
 9        return "".join(block.get("text", "") for block in content if isinstance(block, dict))
10    return content
11
12
13questions = [
14    "국내 출장 식비 한도는 하루 얼마인가요?",   # 문서에 답 있음
15    "재택근무는 주 며칠까지 가능한가요?",       # 문서에 답 있음
16    "육아휴직은 얼마나 쓸 수 있나요?",          # 문서에 답 없음 -> 모른다고 해야 정답
17]
18
19for q in questions:
20    print("=" * 60)
21    print(f"질문: {q}")
22    print(f"\n[RAG 없음] {get_text(llm.invoke(q))[:100]}")
23    print(f"\n[RAG 적용] {rag_chain.invoke(q)}")
PYday2_practice/verify_sources.pyRunnableParallel로 답변과 검색된 근거 문서 목록을 함께 반환11줄
 1# verify_sources.py - 근거 문서를 함께 반환
 2from langchain_core.runnables import RunnableParallel
 3from rag_chain import rag_chain, retriever
 4
 5rag_with_sources = RunnableParallel(
 6    answer=rag_chain,
 7    sources=retriever | (lambda docs: sorted({d.metadata["source"] for d in docs})),
 8)
 9result = rag_with_sources.invoke("물류플랫폼팀 박도윤입니다. 해외 출장 숙박비 한도가 얼마인가요?")
10print(result["answer"])
11print("근거 문서:", result["sources"])

검색이 실패하는 경우를 일부러 만들기

RAG의 답이 틀렸을 때 원인의 대부분은 모델이 아니라 검색에 있습니다. 맞는 청크가 프롬프트에 들어가지 않았다면 모델이 아무리 좋아도 답할 수 없습니다. 그래서 체인 전체가 아니라 검색만 떼어 내어, 약한 질문 유형을 직접 던져 봅니다.

failures.py 4–13줄
 4fail_queries = [
 5    "출장 신청서 TR-102 어디서 내?",   # 유형 1: 고유 기호
 6    "3년차 연차 가산 규정",            # 유형 2: 청크 경계에 걸릴 수 있음
 7    "그거 신청 어떻게 해?",            # 유형 4: 모호한 질문
 8]
 9for q in fail_queries:
10    hits = db.similarity_search_with_score(q, k=2)
11    print(f"\n질문: {q}")
12    for doc, score in hits:
13        print(f"  (거리 {score:.3f}) [{doc.metadata['source']}] {doc.page_content[:40]}")
모델을 부르지 않고 DB에 직접 검색만 합니다. 상위 2개의 거리, 출처, 본문 앞 40자를 찍습니다.
질문코드 주석의 분류왜 벡터 검색에 어려운가
"출장 신청서 TR-102 어디서 내?"유형 1: 고유 기호임베딩은 뜻을 벡터로 옮깁니다. TR-102 같은 양식 번호, 제품 코드, 오류 코드는 뜻이 없는 기호라서 HR-103, RW-201 과 벡터상 거의 구별되지 않을 수 있습니다. 글자가 정확히 일치하는지는 벡터 검색이 보는 것이 아닙니다
"3년차 연차 가산 규정"유형 2: 청크 경계에 걸릴 수 있음답이 되는 문장이 청크의 끝에 걸려 잘리거나, 그 문장을 설명하는 소제목과 다른 청크로 갈라지면 어느 청크도 질문과 충분히 가깝지 않게 됩니다. 오늘의 DB(300자 설정)에서는 "3년 이상 근속자는 …" 문장이 잘리지 않고 연차 규정의 첫 청크에 들어 있지만, 그 청크에는 제목, 부여 기준, 이월이 함께 섞여 있습니다. 주석이 "걸릴 수 있음"이라고 한 대로, 청킹 설정에 따라 결과가 달라지는 유형입니다
"그거 신청 어떻게 해?"유형 4: 모호한 질문'그거'가 무엇인지 질문에 없습니다. 세 문서 모두에 신청 절차가 있으므로 어느 것이 올라와도 이상하지 않습니다. 검색 이전에 질문이 불완전한 경우입니다

코드 주석의 번호는 1, 2, 4로 건너뜁니다. 강의에서 다룬 실패 유형 가운데 이 파일에 재현된 것이 세 가지라는 뜻으로 읽으면 됩니다.

출력에서 볼 것은 '거리'의 상대적인 크기입니다

이 질문들이 실제로 틀린 문서를 가져오는지는 실행해 봐야 압니다(첫 질문은 "출장 신청서"라는 뜻이 통하는 단어가 함께 있어 맞는 문서가 나올 수도 있습니다). 볼 것은 두 가지입니다. 1위 문서가 맞는가, 그리고 1위와 2위의 거리 차이가 얼마나 나는가. 앞의 vectorstore.py 에서 잘 맞았던 질문들의 거리와 비교해 보면, 검색기가 확신 없이 고른 결과가 어떤 숫자로 나타나는지 감이 잡힙니다.

벡터 검색은 "없음"이라고 답하지 않습니다

k=2 를 주면 아무리 무관한 질문에도 2개를 돌려줍니다. 가장 가까운 것이 충분히 가까운지는 판단하지 않습니다. 그래서 RAG 체인에서 "모르면 모른다고 하라"는 방어선은 검색기가 아니라 프롬프트의 거절 규칙에 걸려 있습니다. 거리에 기준선을 두어 먼 결과를 버리는 방법도 있지만, 앞에서 말한 대로 그 기준선은 모델과 데이터에 따라 직접 재서 정해야 합니다.

실패 유형대응다루는 곳
고유 기호, 정확한 단어 일치가 필요한 질문단어 일치 기반 검색을 함께 쓴다바로 다음 절 하이브리드 검색
청크 경계청크 크기와 겹침을 조정하거나 문서 구조를 따라 자른다앞의 청킹 절
모호한 질문이전 대화를 참고해 질문을 다시 쓰거나 사용자에게 되묻는다대화 기록과 분기가 필요하므로 3일차 이후의 그래프에서
후보에는 있으나 순위가 낮음후보를 넓게 뽑아 정밀한 모델로 다시 정렬한다재랭킹 절
PYday2_practice/failures.py고유 기호, 청크 경계, 모호한 질문으로 벡터 검색의 약점을 재현13줄
모델 호출 없이 검색 결과만 보므로 임베딩 호출 세 번이 전부입니다. RAG가 틀릴 때는 이렇게 검색 단계부터 따로 떼어 확인합니다.
 1# failures.py - 검색 실패 재현
 2from rag_chain import db
 3
 4fail_queries = [
 5    "출장 신청서 TR-102 어디서 내?",   # 유형 1: 고유 기호
 6    "3년차 연차 가산 규정",            # 유형 2: 청크 경계에 걸릴 수 있음
 7    "그거 신청 어떻게 해?",            # 유형 4: 모호한 질문
 8]
 9for q in fail_queries:
10    hits = db.similarity_search_with_score(q, k=2)
11    print(f"\n질문: {q}")
12    for doc, score in hits:
13        print(f"  (거리 {score:.3f}) [{doc.metadata['source']}] {doc.page_content[:40]}")

하이브리드 검색: 단어 일치와 의미를 함께

벡터 검색은 뜻이 통하면 단어가 달라도 찾지만, TR-102 처럼 글자가 정확히 일치해야 하는 질문에는 약합니다. 전통적인 키워드 검색은 정반대입니다. 둘을 함께 돌려 결과를 합치는 것이 하이브리드 검색입니다.

BM25 (키워드 검색)

질문에 나온 단어가 문서에 얼마나 나오는지로 점수를 매기는 고전적인 순위 함수입니다. 드문 단어가 일치할수록 점수가 높고, 문서 길이에 따른 유불리를 보정합니다. 임베딩 모델이 필요 없어 빠르고 비용이 없습니다. "월차"로 물으면 "연차"라고 적힌 문서는 찾지 못합니다.

벡터 검색 (의미 검색)

질문과 문서의 임베딩이 가까운지로 순위를 매깁니다. "월차"와 "연차", "집에서 일하기"와 "재택근무"를 연결합니다. 양식 번호 같은 기호의 정확한 일치는 보장하지 못합니다.

한국어에는 형태소 분석이 필요하다

BM25는 글을 단어 단위로 쪼갠 뒤 단어가 일치하는지를 봅니다. 기본 쪼개기 방법은 공백 기준입니다. 영어에서는 통하지만 한국어에서는 "연차는", "연차를", "연차"가 전부 다른 단어로 취급되어 일치하지 않습니다. 조사와 어미를 떼어 내는 형태소 분석을 먼저 거쳐야 합니다.

hybrid.py 13–16줄
13def korean_tokenizer(text: str) -> list[str]:
14    """한국어 형태소 단위 토크나이저 (사전 검증본). 명사, 어간, 숫자, 외래어만 남깁니다."""
15    keep = {"NNG", "NNP", "NNB", "NR", "NP", "VV", "VA", "SL", "SN"}
16    return [t.form for t in kiwi.tokenize(text) if t.tag in keep]
한국어 형태소 분석기 Kiwi로 글을 형태소로 쪼개고, 품사 태그가 keep 에 있는 것만 남깁니다.
태그품사남기는 이유
NNG NNP NNB일반 명사, 고유 명사, 의존 명사"연차", "출장", "식비"처럼 검색의 핵심이 되는 말
NR NP수사, 대명사"하나", "둘" 같은 수량 표현 등
VV VA동사, 형용사의 어간"먹어", "먹는", "먹을"을 모두 어간 "먹"으로 모읍니다
SL SN알파벳, 숫자"TR", "102", "VPN" 같은 기호와 수치

조사("는", "를"), 어미("습니다"), 문장 부호는 목록에 없으므로 버려집니다. 어느 문서에나 나오는 이런 말은 검색에 도움이 되지 않습니다.

토크나이저만 따로 돌려 본 결과 (kiwipiepy 0.24.1)
1korean_tokenizer("TR-102 양식 규정")   # ['TR', '102', '양식', '규정']
2korean_tokenizer("월차 며칠 받아?")     # ['월차', '며칠']
korean_tokenizer 함수만 복사해 실습 질문 두 개를 넣어 본 것입니다. Kiwi는 로컬에서 도는 라이브러리라 이 확인에는 비용이 들지 않습니다.
"받아"의 어간 "받"은 남지 않습니다

함수 설명에는 어간을 남긴다고 되어 있지만 두 번째 결과에 "받"이 없습니다. Kiwi는 규칙 활용과 불규칙 활용이 모두 있을 수 있는 어간에 VV-R, VA-I 처럼 꼬리가 붙은 태그를 주는데("받다"의 "받"은 VV-R), keep 에는 꼬리 없는 VV, VA 만 있어서 t.tag in keep 을 통과하지 못합니다. 문서와 질문 양쪽에서 똑같이 빠지므로 검색이 깨지지는 않지만, 일부 동사와 형용사는 BM25의 단어 목록에 들어가지 않는다는 점은 알고 있어야 합니다. 꼬리까지 포함하려면 t.tag.split("-")[0] in keep 처럼 비교합니다.

두 검색기를 만들고 합치기

hybrid.py 19–27줄
19# 1) BM25 검색기 - 벡터 DB가 아니라 청크 리스트에서 직접 만듭니다
20bm25 = BM25Retriever.from_documents(load_chunks(), preprocess_func=korean_tokenizer)
21bm25.k = 3
22
23# 2) 벡터 검색기
24vector = db.as_retriever(search_kwargs={"k": 3})
25
26# 3) 하이브리드 - 가중치 조합 [BM25 비중, 벡터 비중]
27hybrid = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.3, 0.7])
BM25 검색기, 벡터 검색기, 그리고 둘을 가중치로 합치는 EnsembleRetriever.
질문 BM25 검색기단어 일치 · 청크 리스트에서 벡터 검색기의미 유사 · Chroma에서 EnsembleRetriever순위를 가중치로 합산 재랭킹다음 절 0.30.7 후보
두 검색기는 서로 다른 곳을 봅니다. BM25는 메모리에 올린 청크 리스트를, 벡터 검색기는 디스크의 Chroma를 검색합니다.
  1. BM25 검색기는 DB 없이 청크 리스트에서 바로 만듭니다load_chunks() 로 문서를 다시 읽어 자른 뒤, 청크마다 korean_tokenizer 로 단어 목록을 뽑아 메모리에 색인을 만듭니다. preprocess_func 로 넘긴 토크나이저는 질문에도 똑같이 적용됩니다. bm25.k = 3 은 3개를 돌려 달라는 설정입니다.
  2. 벡터 검색기는 앞에서 쓰던 그것입니다rag_chain.py 의 db 를 가져와 k=3 검색기로 만듭니다.
  3. EnsembleRetriever 가 두 결과를 하나의 순위로 합칩니다질문을 두 검색기에 모두 보내고, 각 검색기가 매긴 순위를 가중치와 함께 합산해 최종 순위를 정합니다. weights=[0.3, 0.7] 은 BM25의 순위를 30%, 벡터의 순위를 70% 반영한다는 뜻입니다. 양쪽 모두에서 상위에 오른 청크가 가장 위로 갑니다.
점수가 아니라 순위를 합치는 이유

BM25 점수와 벡터 거리는 단위도 범위도 방향도 다릅니다. 그대로 더할 수 없습니다. 그래서 각 검색기 안에서의 순위(1위, 2위, ...)만 꺼내 합치는 방식(Reciprocal Rank Fusion)을 씁니다. 순위는 어떤 검색기든 같은 척도이기 때문입니다.

hybrid.py 29–39줄
29queries = [
30    "월차 며칠 받아?",        # 의미형 질문 -> 벡터가 유리
31    "TR-102 양식 규정",       # 키워드형 질문 -> BM25가 유리
32]
33for q in queries:
34    print("=" * 55)
35    print(f"질문: {q}")
36    for name, r in [("BM25만", bm25), ("벡터만", vector), ("하이브리드", hybrid)]:
37        hits = r.invoke(q)
38        srcs = [f'{h.metadata["source"].split("/")[-1]} | {h.page_content[:14].replace(chr(10), " ")}' for h in hits[:3]]
39        print(f"  {name}: {srcs}")
성격이 다른 질문 두 개를 세 검색기에 각각 던져, 상위 3개의 파일 이름과 본문 첫머리를 비교합니다.
질문BM25벡터기대하는 관찰
"월차 며칠 받아?"질문이 "월차", "며칠" 두 단어로 바뀌는데 둘 다 세 문서에 없습니다. 일치하는 단어가 하나도 없으므로 BM25가 돌려주는 3개의 순서에는 의미가 없습니다"연차"와 뜻이 가까워 유리합니다하이브리드가 벡터의 결과를 살리는지
"TR-102 양식 규정""TR", "102"가 출장 규정에만 나와 유리합니다"양식", "규정"은 세 문서 모두에 나오는 말이라 뜻만으로는 구별이 어려울 수 있습니다하이브리드가 BM25의 결과를 살리는지

가중치를 바꿔 보기

hybrid.py 41–45줄
41        # hybrid_weights.py - 가중치 조정 실험 (hybrid.py에 이어서)
42for w in [0.2, 0.5, 0.8]:
43    h = EnsembleRetriever(retrievers=[bm25, vector], weights=[w, 1 - w])
44    top = h.invoke("월차 며칠 받아?")[0].metadata["source"].split("/")[-1]
45    print(f"BM25 {w:.1f} / 벡터 {1 - w:.1f} -> 1위: {top}")
BM25 비중을 0.2, 0.5, 0.8로 바꿔 가며 의미형 질문의 1위 문서가 어떻게 달라지는지 봅니다.

41행의 주석이 알려 주듯 원래 hybrid_weights.py 라는 별도 파일의 내용을 이어 붙인 부분입니다. 의미형 질문("월차 며칠 받아?")에서 BM25 비중을 높여 갈 때 1위가 언제 바뀌는지를 봅니다. 위에서 본 대로 이 질문에서 BM25의 순위는 근거가 없는 순위이므로, 그 비중을 높일수록 근거 없는 순위가 최종 결과를 끌고 가게 됩니다. 정답 가중치는 없습니다. 기호나 코드를 묻는 질문이 많은 업무라면 BM25 쪽을, 표현이 제각각인 자연어 질문이 많다면 벡터 쪽을 올리되, 그 결정은 다음 절의 평가 세트로 재서 내립니다.

이 파일은 import 만 해도 검색이 실행됩니다

29행 이후의 비교 실험과 가중치 실험이 if __name__ == "__main__": 없이 모듈 수준에 놓여 있습니다. 그래서 뒤의 rerank_demo.py 가 from hybrid import hybrid 를 하는 순간 이 실험들이 먼저 전부 돌고 출력이 찍힙니다. 임베딩 API도 그만큼 호출됩니다. 다른 파일에서 가져다 쓸 모듈이라면 실험 코드는 가드 아래에 두는 것이 맞습니다.

두 검색기의 색인은 따로 관리됩니다

BM25 색인은 실행할 때마다 docs/ 의 파일을 다시 읽어 메모리에 만들고, 벡터 색인은 예전에 vectorstore.py 가 디스크에 저장해 둔 것입니다. 문서를 고친 뒤 vectorstore.py 를 다시 돌리지 않으면 BM25는 새 문서를, 벡터 검색은 옛 문서를 보게 됩니다. 또 EnsembleRetriever 의 import 경로가 langchain_classic 이고 BM25에는 rank-bm25, 토크나이저에는 kiwipiepy 패키지가 필요하다는 점도 설치할 때 확인합니다(모두 레포의 requirements.txt 에 있습니다).

하이브리드 검색기도 Retriever 규격을 따르므로 rag_chain.py 의 retriever 자리에 그대로 끼울 수 있습니다. 다만 오늘의 코드에서는 거기까지 연결하지 않았고, RAG 체인은 벡터 검색기만 씁니다.

PYday2_practice/hybrid.pyKiwi 형태소 토크나이저를 쓴 BM25와 벡터 검색을 EnsembleRetriever로 결합, 가중치 실험 포함45줄
첫 줄 주석대로 BM25 구성 부분은 강의에서 제공한 검증된 코드입니다. hybrid 객체를 rerank_demo.py 가 가져다 씁니다.
 1# hybrid.py - 하이브리드 검색 (BM25 구성은 사전 검증된 완성 코드)
 2from dotenv import load_dotenv
 3from langchain_community.retrievers import BM25Retriever
 4from langchain_classic.retrievers import EnsembleRetriever
 5from kiwipiepy import Kiwi
 6from metadata import load_chunks
 7from rag_chain import db
 8
 9load_dotenv()
10kiwi = Kiwi()
11
12
13def korean_tokenizer(text: str) -> list[str]:
14    """한국어 형태소 단위 토크나이저 (사전 검증본). 명사, 어간, 숫자, 외래어만 남깁니다."""
15    keep = {"NNG", "NNP", "NNB", "NR", "NP", "VV", "VA", "SL", "SN"}
16    return [t.form for t in kiwi.tokenize(text) if t.tag in keep]
17
18
19# 1) BM25 검색기 - 벡터 DB가 아니라 청크 리스트에서 직접 만듭니다
20bm25 = BM25Retriever.from_documents(load_chunks(), preprocess_func=korean_tokenizer)
21bm25.k = 3
22
23# 2) 벡터 검색기
24vector = db.as_retriever(search_kwargs={"k": 3})
25
26# 3) 하이브리드 - 가중치 조합 [BM25 비중, 벡터 비중]
27hybrid = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.3, 0.7])
28
29queries = [
30    "월차 며칠 받아?",        # 의미형 질문 -> 벡터가 유리
31    "TR-102 양식 규정",       # 키워드형 질문 -> BM25가 유리
32]
33for q in queries:
34    print("=" * 55)
35    print(f"질문: {q}")
36    for name, r in [("BM25만", bm25), ("벡터만", vector), ("하이브리드", hybrid)]:
37        hits = r.invoke(q)
38        srcs = [f'{h.metadata["source"].split("/")[-1]} | {h.page_content[:14].replace(chr(10), " ")}' for h in hits[:3]]
39        print(f"  {name}: {srcs}")
40
41        # hybrid_weights.py - 가중치 조정 실험 (hybrid.py에 이어서)
42for w in [0.2, 0.5, 0.8]:
43    h = EnsembleRetriever(retrievers=[bm25, vector], weights=[w, 1 - w])
44    top = h.invoke("월차 며칠 받아?")[0].metadata["source"].split("/")[-1]
45    print(f"BM25 {w:.1f} / 벡터 {1 - w:.1f} -> 1위: {top}")

평가 세트: 감이 아니라 숫자로 확인하기

청크 크기를 바꾸고, k를 바꾸고, 하이브리드를 붙였습니다. 그래서 좋아졌을까요. 질문 몇 개를 손으로 던져 보고 "괜찮은 것 같다"고 판단하면, 한쪽을 고칠 때 다른 쪽이 망가지는 것을 놓칩니다. 평가 세트는 "이 질문에는 이런 결과가 나와야 한다"를 미리 적어 둔 시험지입니다.

JSONeval_set.json질문, 기대 출처, 유형으로 이루어진 2일차 평가 세트 5문항27줄
문항마다 세 칸입니다. question 은 질문, expected_source 는 검색되어야 하는 문서, type 은 문항의 성격입니다. 답이 문서에 없는 문항은 expected_source 가 null 입니다.
 1[
 2  {
 3    "question": "연차 이월은 며칠까지 가능한가요?",
 4    "expected_source": "docs/leave_policy.md",
 5    "type": "fact"
 6  },
 7  {
 8    "question": "월차 얼마나 받을 수 있어?",
 9    "expected_source": "docs/leave_policy.md",
10    "type": "paraphrase"
11  },
12  {
13    "question": "집에서 일하려면 어떻게 신청해?",
14    "expected_source": "docs/remote_work_policy.md",
15    "type": "paraphrase"
16  },
17  {
18    "question": "해외 출장 식비 한도는 하루 얼마인가요?",
19    "expected_source": "docs/travel_policy.md",
20    "type": "fact"
21  },
22  {
23    "question": "육아휴직은 얼마나 쓸 수 있나요?",
24    "expected_source": null,
25    "type": "no_answer"
26  }
27]
유형문항 수무엇을 시험하나채점 방법
fact2문서의 표현과 가까운 말로 사실을 묻습니다. 가장 기본적인 경우입니다기대 출처가 검색 결과 상위 3개 안에 있는가
paraphrase2"월차", "집에서 일하려면"처럼 문서에 없는 표현으로 묻습니다. 의미 검색이 작동하는지 봅니다위와 같음
no_answer1문서에 없는 것을 묻습니다. 지어내지 않고 거절하는지 봅니다답변에 "찾을 수 없"이라는 글자가 들어 있는가
eval.py 10–26줄
10passed = 0
11for item in eval_set:
12    hits = retriever.invoke(item["question"])
13    sources = sorted({h.metadata["source"] for h in hits})
14
15    if item["expected_source"] is None:
16        # no_answer 유형: 체인이 '찾을 수 없다'고 거절하는지로 채점
17        answer = rag_chain.invoke(item["question"])
18        ok = "찾을 수 없" in answer
19    else:
20        # 기대 출처가 검색 결과 상위 k개 안에 있는지로 채점
21        ok = item["expected_source"] in sources
22
23    passed += ok
24    mark = "통과" if ok else "실패"
25    print(f"[{mark}] ({item['type']}) {item['question']}")
26    print(f"       기대: {item['expected_source']} / 실제 검색: {sources}")
문항마다 검색을 돌리고, 유형에 따라 두 가지 방법 중 하나로 통과 여부를 정합니다.
  1. 먼저 검색만 돌려 출처 목록을 만든다rag_chain.py 의 retriever (벡터 검색, k=3)로 검색하고, 결과 청크들의 source 를 중복 없이 모읍니다.
  2. 답이 있는 문항은 검색 결과로 채점한다기대한 파일 경로가 출처 목록 안에 있으면 통과입니다. 이 경우 모델은 부르지 않습니다. 검색이 맞는 문서를 가져왔는지만 봅니다.
  3. 답이 없는 문항은 체인의 답으로 채점한다RAG 체인 전체를 돌려 답변을 받고, 그 안에 "찾을 수 없"이 들어 있는지 봅니다. 프롬프트에서 정해 둔 거절 문구 '해당 내용은 사내 문서에서 찾을 수 없습니다' 의 일부입니다.
  4. 통과 수를 센다passed += ok 는 파이썬에서 True 가 1, False 가 0으로 계산되는 것을 이용한 표기입니다.
eval.py 28–32줄
28print(f"\n정답 {passed}/{len(eval_set)}문항")
29if passed >= PASS_THRESHOLD:
30    print(f"[성공 기준 통과] {PASS_THRESHOLD}문항 이상 정답입니다.")
31else:
32    print(f"[성공 기준 미달] {PASS_THRESHOLD}문항 이상 필요합니다. {PASS_THRESHOLD - passed}문항 더 맞혀야 합니다.")
5문항 중 4문항 이상 통과를 성공 기준(PASS_THRESHOLD)으로 둡니다.
출력 형식 (예시)
1[통과] (fact) 연차 이월은 며칠까지 가능한가요?
2       기대: docs/leave_policy.md / 실제 검색: ['docs/leave_policy.md', ...]
3...
4정답 N/5문항
25~28행의 print가 찍는 모양입니다. 통과 여부와 숫자는 실행 결과에 따라 달라집니다.
eval.py 는 eval_set.json 을 현재 폴더에서 찾습니다

7행의 open("eval_set.json") 은 상대 경로입니다. 그런데 레포에는 이 파일이 day2_practice 가 아니라 루트에 있습니다. 오늘의 다른 스크립트처럼 day2_practice 안에서 실행하면 파일을 찾지 못하므로, eval_set.json 을 day2_practice 폴더로 복사해 두고 실행합니다. 거꾸로 루트에서 실행하면 평가 세트는 찾지만 ./chroma_db 가 루트의 다른 폴더를 가리키게 되고, 그곳에 오늘 만든 컬렉션이 없으면 검색 결과가 비어 전부 실패로 채점됩니다.

이 채점기가 보는 것과 보지 못하는 것

한계설명
답의 내용은 채점하지 않습니다fact와 paraphrase 문항은 맞는 문서가 검색됐는지만 봅니다. 모델이 그 문서를 읽고 "이월은 5일"이라고 맞게 답했는지는 확인하지 않습니다
지금 규모에서는 관대합니다청크가 6개인 DB에서 상위 3개를 가져오면 절반이 들어오고, 문서마다 청크가 둘뿐이라 세 문서 가운데 적어도 두 편이 출처 목록에 오릅니다. 기대 문서가 그 안에 드는 것은 어려운 일이 아닙니다. 1위로 올라왔는지까지 보면 훨씬 엄격한 시험이 됩니다
경로 문자열이 정확히 같아야 합니다"docs/leave_policy.md" 와 청크의 source 를 문자열로 비교합니다. DB를 만들 때 전체 경로로 문서를 읽었다면 내용이 맞아도 전부 실패로 나옵니다. metadata.py 가 상대 경로로 로드하는 것과 짝을 이루는 약속입니다
거절 판정이 문구에 묶여 있습니다프롬프트의 거절 문구를 "확인되지 않습니다"로 바꾸면 모델이 제대로 거절해도 실패로 채점됩니다. 반대로 모델이 답을 지어내면서 문장 어딘가에 "찾을 수 없"을 쓰면 통과됩니다
하이브리드 검색은 평가 대상이 아닙니다가져다 쓰는 retriever 는 벡터 검색기입니다. 하이브리드를 평가하려면 이 자리를 hybrid 로 바꿔 같은 세트를 다시 돌립니다
허술해도 자동으로 도는 시험이 있는 편이 낫습니다

한계가 많은 채점기지만, 설정을 바꿀 때마다 명령 하나로 "5문항 중 몇 개"를 다시 확인할 수 있다는 것이 핵심입니다. 평가 세트는 이 날로 끝나지 않습니다. 날마다 새 기능에 맞는 문항이 더해지고, 7일차에는 문자열 비교로 채점할 수 없는 답변의 품질을 LLM에게 심사시키는 방법까지 나아갑니다. 실패한 질문을 발견할 때마다 평가 세트에 한 줄 추가하는 습관이 가장 값진 자산이 됩니다.

PYday2_practice/eval.pyeval_set.json 을 읽어 검색 적중과 거절 동작을 자동 채점하고 통과 기준 판정32줄
 1# eval.py - 평가 세트 자동 채점
 2import json
 3from rag_chain import rag_chain, retriever
 4
 5PASS_THRESHOLD = 4   # 2일차 성공 기준: 5문항 중 4문항 이상 정답
 6
 7with open("eval_set.json", encoding="utf-8") as f:
 8    eval_set = json.load(f)
 9
10passed = 0
11for item in eval_set:
12    hits = retriever.invoke(item["question"])
13    sources = sorted({h.metadata["source"] for h in hits})
14
15    if item["expected_source"] is None:
16        # no_answer 유형: 체인이 '찾을 수 없다'고 거절하는지로 채점
17        answer = rag_chain.invoke(item["question"])
18        ok = "찾을 수 없" in answer
19    else:
20        # 기대 출처가 검색 결과 상위 k개 안에 있는지로 채점
21        ok = item["expected_source"] in sources
22
23    passed += ok
24    mark = "통과" if ok else "실패"
25    print(f"[{mark}] ({item['type']}) {item['question']}")
26    print(f"       기대: {item['expected_source']} / 실제 검색: {sources}")
27
28print(f"\n정답 {passed}/{len(eval_set)}문항")
29if passed >= PASS_THRESHOLD:
30    print(f"[성공 기준 통과] {PASS_THRESHOLD}문항 이상 정답입니다.")
31else:
32    print(f"[성공 기준 미달] {PASS_THRESHOLD}문항 이상 필요합니다. {PASS_THRESHOLD - passed}문항 더 맞혀야 합니다.")

재랭킹: 후보를 넓게 뽑고 정밀하게 다시 정렬하기

검색기가 맞는 청크를 후보 안에 넣기는 했는데 순위가 낮은 경우가 있습니다. 프롬프트에는 상위 몇 개만 들어가므로 순위가 곧 품질입니다. 재랭킹은 1차 검색이 뽑아 준 후보를 더 정밀한 모델로 다시 채점해 순서를 고치는 두 번째 단계입니다.

1차 검색: 따로 읽고 비교

임베딩 검색은 질문과 문서를 각각 따로 벡터로 바꾼 뒤 벡터끼리 비교합니다. 문서 벡터는 미리 계산해 둘 수 있어 문서가 수백만 건이어도 빠릅니다. 대신 질문과 문서의 세부 표현이 서로 어떻게 맞물리는지는 보지 못합니다.

재랭킹: 같이 읽고 채점

크로스 인코더(Cross-Encoder)는 질문과 문서를 한 쌍으로 묶어 모델에 넣고 관련도 점수 하나를 받습니다. 두 글을 함께 읽으므로 더 정확하지만, 쌍마다 모델을 돌려야 해서 미리 계산해 둘 수 없고 느립니다.

그래서 둘을 이어 씁니다. 빠른 검색으로 전체에서 후보 수십 개를 추리고, 느리지만 정확한 재랭킹으로 그 후보만 다시 정렬합니다.

rerank_demo.py 5–9줄
5# 다국어 재랭킹 모델 (한국어 지원, 최초 실행 시 다운로드)
6reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
7
8query = "연차 이월 규정"
9candidates = [d.page_content for d in hybrid.invoke(query)]  # 1차 검색 후보
재랭킹 모델을 불러오고, 하이브리드 검색기로 1차 후보를 뽑아 본문만 리스트로 모읍니다.
rerank_demo.py 11–21줄
11# 재랭킹 전: 하이브리드 검색이 돌려준 순서 그대로
12print("=== 재랭킹 전 (하이브리드 순위) ===")
13for i, c in enumerate(candidates, 1):
14    print(f"{i}위  {c[:50].replace(chr(10), ' ')}...")
15
16scores = reranker.predict([(query, c) for c in candidates])
17ranked = sorted(zip(scores, candidates), reverse=True)
18print("\n=== 재랭킹 후 (상위 3개) ===")
19for i, (s, c) in enumerate(ranked[:3], 1):
20    before = candidates.index(c) + 1
21    print(f"{i}위 (전 {before}위)  {s:.3f}  {c[:50].replace(chr(10), ' ')}...")
(질문, 후보) 쌍을 한꺼번에 채점하고 점수가 높은 순으로 정렬합니다. 재랭킹 전 순위를 함께 찍어 순위가 어떻게 바뀌었는지 보여 줍니다.
  1. 모델은 내 컴퓨터에서 돕니다CrossEncoder("BAAI/bge-reranker-v2-m3") 는 Bedrock이 아니라 sentence-transformers 라이브러리로 로컬에서 실행하는 모델입니다. 코드 주석대로 한국어를 지원하는 다국어 재랭킹 모델이고, 처음 실행할 때 내려받습니다.
  2. 후보는 하이브리드 검색기가 줍니다hybrid.invoke(query) 는 BM25의 3개와 벡터의 3개를 합쳐 중복을 뺀 결과를 돌려주므로 후보는 3개에서 6개 사이입니다. 여기서는 본문 문자열만 꺼내 씁니다.
  3. 쌍을 만들어 채점한다reranker.predict([(query, c) for c in candidates]) 는 후보마다 (질문, 본문) 쌍을 만들어 모델에 넣고, 후보 수만큼의 점수를 돌려줍니다. 점수는 클수록 관련이 깊다는 뜻입니다. 값의 범위는 모델과 라이브러리 버전에 따라 다르므로 크기 자체보다 순서를 봅니다.
  4. 점수로 정렬하고 상위 3개를 고른다sorted(zip(scores, candidates), reverse=True) 로 점수가 높은 순서로 세웁니다. candidates.index(c) + 1 은 그 후보가 재랭킹 전에 몇 위였는지를 되찾는 계산입니다.
볼 것은 '(전 N위)' 입니다

재랭킹 후 1위 옆에 "(전 1위)"가 찍히면 1차 검색이 이미 잘하고 있었다는 뜻이고, "(전 4위)"처럼 아래에 있던 것이 올라왔다면 재랭킹이 순위를 바로잡은 것입니다. 질문 "연차 이월 규정"의 답은 연차 규정의 '이월' 항목에 있으므로, 그 내용이 담긴 청크가 몇 위에서 몇 위로 움직이는지를 봅니다.

실행 전에 알아 둘 것

첫째, sentence-transformers 패키지가 필요하고 첫 실행 때 모델 파일을 내려받으므로 시간과 디스크 공간이 듭니다. 둘째, 3행의 from hybrid import hybrid 때문에 hybrid.py 의 비교 실험이 먼저 전부 실행되고 그 출력이 앞에 찍힙니다. 재랭킹 자체는 로컬에서 돌지만 후보를 뽑는 벡터 검색에는 Bedrock 임베딩 호출과 chroma_db 가 필요합니다. 셋째, 이 파일은 순위 변화를 보여 주는 데모입니다. 재랭킹 결과를 RAG 체인에 연결하는 코드는 오늘의 범위에 없습니다.

재랭킹은 후보에 없는 것을 살려 내지 못합니다

재랭킹은 순서를 바꿀 뿐입니다. 정답 청크가 1차 후보에 들어 있지 않으면 아무 소용이 없습니다. 그래서 재랭킹을 쓸 때는 1차 검색의 k를 평소보다 넉넉하게 잡습니다. 1차 검색은 "빠뜨리지 않기", 재랭킹은 "위로 올리기"로 역할을 나눠 생각하면 됩니다.

실패 재현부터 하이브리드 검색, 재랭킹, 평가 세트 채점까지 검색 품질을 다룬 파일 네 개를 한 번에 따라가려면 아래 플레이어를 넘겨 보세요.

플레이어

하이브리드 검색과 재랭킹 플레이어

failures.py 로 벡터 검색의 약점을 재현하고, Kiwi 토크나이저와 BM25, EnsembleRetriever 의 가중 RRF, CrossEncoder 재랭킹, eval.py 의 채점 규칙을 14단계 플레이어로 보여 줍니다. 토크나이저와 BM25 점수는 로컬에서 재현한 사실만 싣고, RRF 는 가상의 순위로 가중치를 바꿔 가며 계산해 볼 수 있습니다.

새 탭으로day2_practice/hybrid_rerank_explained.html
PYday2_practice/rerank_demo.py하이브리드 검색 후보를 CrossEncoder로 다시 채점해 재랭킹 전후 순위 비교21줄
 1# rerank_demo.py - 재랭킹 전후 순위 비교
 2from sentence_transformers import CrossEncoder
 3from hybrid import hybrid
 4
 5# 다국어 재랭킹 모델 (한국어 지원, 최초 실행 시 다운로드)
 6reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
 7
 8query = "연차 이월 규정"
 9candidates = [d.page_content for d in hybrid.invoke(query)]  # 1차 검색 후보
10
11# 재랭킹 전: 하이브리드 검색이 돌려준 순서 그대로
12print("=== 재랭킹 전 (하이브리드 순위) ===")
13for i, c in enumerate(candidates, 1):
14    print(f"{i}위  {c[:50].replace(chr(10), ' ')}...")
15
16scores = reranker.predict([(query, c) for c in candidates])
17ranked = sorted(zip(scores, candidates), reverse=True)
18print("\n=== 재랭킹 후 (상위 3개) ===")
19for i, (s, c) in enumerate(ranked[:3], 1):
20    before = candidates.index(c) + 1
21    print(f"{i}위 (전 {before}위)  {s:.3f}  {c[:50].replace(chr(10), ' ')}...")

2일차 정리

오늘의 한 문장

RAG는 모델을 똑똑하게 만드는 기술이 아니라 모델이 읽을 글을 골라 주는 검색 기술이고, 답의 품질은 프롬프트에 들어간 청크의 품질을 넘지 못합니다.

로드 · 청킹load_chunks() 임베딩 · 저장Titan V2, Chroma 검색벡터 + BM25 재랭킹CrossEncoder 생성RAG 체인 평가eval_set.json

앞의 두 칸이 색인 단계, 가운데 세 칸이 질의 단계입니다. 오늘의 코드에서 RAG 체인에 실제로 연결된 검색은 벡터 검색뿐이고, BM25 결합과 재랭킹은 따로 돌려 본 실험입니다.

오늘 만든 것남은 숙제이어지는 날
질문이 오면 무조건 검색하는 한 방향 체인검색이 필요한지 판단하고, 결과가 부실하면 질문을 바꿔 다시 찾으려면3일차 LangGraph의 조건부 엣지와 루프
체인 안에 고정된 검색기검색을 여러 도구 가운데 하나로 만들어 모델이 골라 쓰게 하려면4일차 도구 설계, 6일차 RAG + MCP 통합 (같은 chroma_db 를 다시 씁니다)
프롬프트 한 줄에 기댄 거절 규칙모델이 규칙을 어겨도 걸러 낼 수 있는 별도의 방어선5일차 가드레일
5문항짜리 평가 세트와 문자열 채점문항을 계속 쌓고, 답변의 품질까지 채점하려면7일차 누적 평가와 LLM 심사

스스로 확인하기

색인 단계와 질의 단계 가운데 문서 원본 파일을 읽는 쪽은 어디인가요?

색인 단계입니다. vectorstore.py 가 문서를 읽고 잘라 임베딩해 chroma_db 에 저장해 두면, 질의 단계의 retriever.py 나 rag_chain.py 는 그 DB만 엽니다. 예외는 hybrid.py 의 BM25로, DB가 아니라 문서를 다시 읽어 만든 청크 리스트에서 색인을 만듭니다.

"월차 며칠 받을 수 있어?"라는 질문으로 "연차 휴가는 연 15일이 부여됩니다"라는 청크를 찾을 수 있는 이유는?

벡터 검색은 글자의 일치가 아니라 임베딩 벡터 사이의 거리로 찾기 때문입니다. 임베딩 모델은 뜻이 비슷한 글을 가까운 위치에 놓도록 학습되어 있어 "월차"와 "연차"가 겹치지 않아도 가깝게 나옵니다. 같은 질문을 BM25에 던지면 "월차"라는 단어가 문서에 없어 불리합니다.

similarity_search_with_score 가 돌려준 점수가 0.4인 청크와 1.1인 청크 중 어느 쪽이 질문에 더 가깝나요?

0.4인 쪽입니다. 이 메서드의 점수는 유사도가 아니라 거리여서 작을수록 가깝습니다. 코사인 유사도처럼 클수록 좋은 값과 방향이 반대입니다.

RAG 체인에 문서에 없는 내용을 물었을 때 환각을 막는 장치는 코드의 어디에 있나요?

검색기가 아니라 프롬프트에 있습니다. 벡터 검색은 무관한 질문에도 항상 k개를 돌려주므로 "없음"을 알려 주지 못합니다. system 메시지의 "문서에 없는 내용은 '해당 내용은 사내 문서에서 찾을 수 없습니다'라고 답해. 추측하지 마"라는 지침이 방어선이고, eval.py 의 no_answer 문항이 그 지침이 지켜지는지 확인합니다.

문서를 고쳤습니다. 다시 실행해야 하는 것과, 그냥 다시 실행하면 생기는 문제는?

vectorstore.py 를 다시 돌려 색인을 갱신해야 합니다. 그런데 이 코드는 청크 ID를 지정하지 않으므로 그대로 돌리면 옛 청크 위에 새 청크가 추가되어 옛 내용과 새 내용이 함께 검색됩니다. 주석 처리된 delete_collection() 블록을 풀어 컬렉션을 지우고 만들거나 chroma_db 폴더를 삭제한 뒤 실행합니다.

재랭킹을 붙였는데도 답이 틀립니다. 가장 먼저 확인할 것은?

정답이 담긴 청크가 1차 검색 후보 안에 들어 있는지입니다. 재랭킹은 후보의 순서만 바꾸므로 후보에 없는 청크는 올릴 수 없습니다. failures.py 처럼 검색만 따로 돌려 후보를 눈으로 확인하고, 없다면 k를 늘리거나 청킹과 검색 방식을 손봅니다.

오늘 쓴 파일 한눈에

이 페이지에 실린 코드 파일 전체 목록입니다. 파일 이름을 누르면 본문에서 그 코드가 있는 자리로 이동합니다.

파일무엇을 하는 코드인가분량
hallucination.pyRAG 없이 사내 규정을 물었을 때 모델이 어떻게 답하는지 확인21줄
leave_policy.md연차 규정: 부여 기준, 이월, 신청 절차14줄
remote_work_policy.md재택근무 규정: 운영 기준, 보안 수칙, 근태15줄
travel_policy.md출장 규정: 신청과 승인, 경비 기준, 정산15줄
loaders.pyTextLoader로 파일 하나, DirectoryLoader로 폴더 전체를 Document로 읽기26줄
chunking.pychunk_size와 chunk_overlap을 세 가지로 바꿔 가며 분할 결과 비교21줄
chunking_md.py마크다운 제목과 소제목을 기준으로 분할하고 헤더를 메타데이터로 보존11줄
metadata.py문서에 부서·개정일 메타데이터를 붙이고 청크로 나누는 load_chunks() 정의29줄
embedding.py문장 하나를 임베딩해 벡터의 차원과 값을 확인13줄
similarity.py문장 쌍 네 개의 코사인 유사도를 직접 계산해 '의미가 가깝다'를 숫자로 확인29줄
vectorstore.py청크를 임베딩해 Chroma에 저장하고, 거리 점수와 메타데이터 필터로 검색 확인37줄
load_db.pyvectorstore.py 의 사본 (빈 줄 하나와 줄 끝 공백 한 칸만 다름)36줄
retriever.py저장된 Chroma를 열어 Retriever로 만들고 k 값과 MMR에 따른 결과 차이 관찰30줄
rag_chain.py검색기, 프롬프트, 모델, 파서를 이은 기본 RAG 체인. 다른 실습 파일들이 import 해서 재사용52줄
verify.py같은 질문을 RAG 없이, RAG로 각각 던져 답을 비교. 문서에 없는 질문 포함23줄
verify_sources.pyRunnableParallel로 답변과 검색된 근거 문서 목록을 함께 반환11줄
failures.py고유 기호, 청크 경계, 모호한 질문으로 벡터 검색의 약점을 재현13줄
hybrid.pyKiwi 형태소 토크나이저를 쓴 BM25와 벡터 검색을 EnsembleRetriever로 결합, 가중치 실험 포함45줄
eval_set.json질문, 기대 출처, 유형으로 이루어진 2일차 평가 세트 5문항27줄
eval.pyeval_set.json 을 읽어 검색 적중과 거절 동작을 자동 채점하고 통과 기준 판정32줄
rerank_demo.py하이브리드 검색 후보를 CrossEncoder로 다시 채점해 재랭킹 전후 순위 비교21줄

2일차 용어

RAG
Retrieval-Augmented Generation. 질문과 관련된 문서 조각을 검색해 프롬프트에 덧붙인 뒤 모델에게 답하게 하는 방식. 모델을 다시 학습시키지 않고 새 지식을 쓰게 합니다.
색인 단계
문서를 로드, 청킹, 임베딩해 벡터 DB에 저장해 두는 사전 준비. 문서가 바뀔 때만 다시 실행합니다.
질의 단계
질문이 들어올 때마다 실행되는 부분. 질문을 임베딩해 검색하고, 찾은 청크를 프롬프트에 넣어 답을 생성합니다.
Document
LangChain에서 글 한 덩어리를 담는 객체. 본문인 page_content 와 부가 정보인 metadata 로 이루어집니다.
청크 / 청킹
문서를 검색 단위로 자른 조각과 그 자르는 일. 청크 하나가 임베딩 하나, 검색 결과 한 건이 됩니다.
chunk_overlap
이웃한 청크끼리 겹치게 두는 길이. 경계에 걸린 문장이 양쪽에서 잘리는 것을 줄입니다.
메타데이터
청크에 붙이는 꼬리표(출처, 부서, 개정일 등). 출처 표시와 필터 검색에 쓰이며, 모델에게 보이려면 프롬프트에 글자로 적어 넣어야 합니다.
임베딩
글을 고정 길이의 숫자 벡터로 바꾸는 일, 또는 그 벡터. 뜻이 비슷한 글은 가까운 벡터가 되도록 학습된 모델이 계산합니다.
코사인 유사도
두 벡터가 이루는 각도의 코사인 값. 방향이 같으면 1에 가깝고, 클수록 뜻이 가깝다고 봅니다.
거리
두 벡터가 떨어진 정도. 작을수록 가깝습니다. Chroma의 similarity_search_with_score 가 돌려주는 점수가 이것입니다.
벡터 DB
벡터와 원문, 메타데이터를 저장하고 주어진 벡터와 가까운 것을 빠르게 찾아 주는 저장소. 실습에서는 로컬 폴더에 저장하는 Chroma를 씁니다.
컬렉션
Chroma에서 청크를 묶어 두는 단위. 관계형 DB의 테이블에 해당하며 실습에서는 sds_policies 하나를 씁니다.
Retriever
질문 문자열을 받아 Document 리스트를 돌려주는 Runnable. 벡터 DB의 as_retriever() 로 만들고 체인에 | 로 잇습니다.
top-k
가까운 순으로 상위 k개를 가져오는 검색 방식, 또는 그 k. 크면 빠뜨릴 위험이 줄고 무관한 글이 섞일 위험이 늡니다.
MMR
Maximal Marginal Relevance. 질문과 가까우면서 이미 고른 결과와는 덜 비슷한 청크를 골라 결과의 중복을 줄이는 검색 방식.
BM25
질문의 단어가 문서에 얼마나, 얼마나 드물게 나오는지로 순위를 매기는 키워드 검색 방식. 고유 기호나 코드처럼 글자가 정확히 일치해야 하는 검색에 강합니다.
형태소 분석
문장을 뜻을 가진 최소 단위로 쪼개고 품사를 붙이는 처리. 한국어 키워드 검색에서 조사와 어미를 떼어 내는 데 필요하며 실습에서는 Kiwi를 씁니다.
하이브리드 검색
키워드 검색과 벡터 검색을 함께 돌려 결과를 하나의 순위로 합치는 방식. 실습에서는 EnsembleRetriever 가 두 검색기의 순위를 가중치로 합산합니다.
재랭킹
1차 검색이 뽑은 후보를 더 정밀한 모델로 다시 채점해 순서를 고치는 단계. 질문과 문서를 한 쌍으로 함께 읽는 크로스 인코더를 씁니다.
평가 세트
질문과 기대 결과를 미리 적어 둔 시험 문항 모음. 설정을 바꿀 때마다 자동으로 돌려 좋아졌는지 나빠졌는지를 숫자로 확인합니다.