AI 개발 · 기본
바이브 코딩 실습 - 작은 서비스 만들기
문서 검색 도움말 - 검색 후 답하기(RAG) 원리
이용 안내 문서를 조각내기, 단어 색인과 점수로 질문과 비슷한 조각 찾기, 근거 조각을 인용해 답하기, 근거가 없으면 모른다고 답하기, 모델 호출은 규칙 기반 가짜 함수로 대신해 흐름만 이해, 완성 코드는 질문 몇 개에 대해 고른 조각과 답을 출력한다
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 남이 짠 코드를 읽고 설명과 실제 동작이 맞는지 확인했다. 이번에는 같은 태도를 문서 도움말에 적용한다. 답이 자연스럽게 들리는지만 살피지 않고, 그 답을 뒷받침하는 이용 안내가 있는지 확인한다. 공간 예약 서비스에 “취소 마감은 언제인가요?”라는 질문이 들어오면 관련 문서를 먼저 찾고, 찾은 문서에 있는 내용으로만 답한다.
이 흐름을 검색 증강 생성(Retrieval-Augmented Generation, RAG)이라고 한다. 이름은 길지만 출발점은 단순하다. 질문에 곧바로 답을 만드는 대신, 답을 만들기 전에 참고 자료를 검색한다. 이번 프로그램은 실제 모델을 호출하지 않는다. 검색 결과를 받아 정해진 형식으로 문장을 만드는 가짜 함수를 사용한다. 외부 패키지, API 키, 네트워크 연결 없이 검색과 답변의 연결을 관찰하는 것이 목적이다.
- 이용 안내를 출처가 붙은 작은 문서 조각으로 나눈다.
- 단어가 어느 조각에 있는지 기록한 색인을 만든다.
- 질문과 겹치는 단어 수로 조각에 점수를 매긴다.
- 근거 조각을 인용하고, 근거가 부족하면 모른다고 답한다.
- 몇 가지 질문과 검사를 실행해 검색 결과와 답변을 함께 확인한다.
문제 상황
동아리와 스터디 모임이 함께 쓰는 공간에 이용 안내가 생겼다. 예약할 수 있는 시간, 취소 마감, 음식 반입 조건이 문서에 적혀 있다. 운영자는 같은 질문에 여러 번 답한다. 이용자는 안내 전체를 읽기보다 자신에게 필요한 규칙 한 가지를 찾고 싶어 한다. 예약 화면 옆에 짧은 문서 도움말을 붙이면 이런 반복을 줄일 수 있다.
그런데 도움말이 문서를 확인하지 않고 답하면 곤란하다. 이용 안내에 주차 요금이 없는데 “주차는 무료다”라고 답할 수도 있다. 문장을 매끄럽게 만드는 능력과 서비스 규칙을 아는 것은 서로 다른 문제다. 이번 실습에서는 답을 만들 수 있는 범위를 현재 문서에 있는 사실로 제한한다. 문서에 없는 질문에는 운영자에게 확인하도록 안내한다.
예제의 이용 안내는 이 장을 위해 새로 만든 연습 자료다. 실제 공간의 정책을 나타내지 않는다. 예약 가능 시간이 바뀌었는지 판단하거나 취소를 처리하는 기능도 만들지 않는다. 이용 안내를 찾아 보여 주는 기능만 추가한다. 따라서 예약 데이터베이스를 열 필요가 없다. 안내 문자열과 검색에 필요한 자료를 메모리에 담고 실행이 끝나면 함께 사라지게 한다.
AI에게 구현을 부탁할 때도 답변의 경계를 먼저 적는다. “도움말을 만들어 줘”만으로는 검색 기준, 출처 표시, 모르는 경우의 처리가 빠지기 쉽다. 이번 실습에 사용할 요청문은 다음과 같이 구체화할 수 있다.
Python 3.12 이상 표준 라이브러리만 사용해 main.py 하나로 실행되는 문서 도움말 예제를 만들어라. 빈 줄로 이용 안내를 나누고 각 조각에 식별자를 붙여라. 질문과 조각에 공통으로 있는 서로 다른 단어 수를 점수로 삼아라. 동점이면 식별자가 작은 조각을 고르고, 점수가 2 미만이면 근거 없음으로 처리하라. 답변 함수는 모델을 호출하지 않고 선택한 본문과 출처를 반환하게 하라. 질문별 점수, 선택한 조각, 답변을 출력하고 결과를 검사하는 코드를 포함하라.
이 요청문은 구현을 시작하는 기준이다. AI가 “요구사항을 모두 반영했다”라고 답해도 확인이 끝난 것은 아니다. 실제 코드를 실행하고, 동점 처리와 근거 없음 검사가 있는지 읽고, 변경 전후의 차이도 살펴야 한다.
문서를 조각내고 출처를 붙인다
문서 조각은 검색과 인용에 사용하는 작은 문서 단위다. 이용 안내 전체를 한 덩어리로 검색하면 음식 반입을 물었는데 예약 시간과 취소 규칙까지 함께 전달하게 된다. 반대로 글자 몇 개마다 자르면 “음식은”과 “반입할 수 없다”가 다른 조각으로 갈라질 수 있다. 조각의 크기는 단순한 저장 방식이 아니라 답변에 필요한 의미를 얼마나 함께 보관할지 정하는 선택이다.
이번 안내는 주제별로 제목 한 줄과 본문 한 줄을 쓴다. 주제 사이에는 빈 줄을 둔다. 프로그램은 빈 줄을 경계로 나누고 첫 줄을 제목, 나머지를 본문으로 읽는다. 예약 시간, 취소 마감, 음식 반입이 각각 하나의 조각이 된다. 문서의 구조가 이미 주제를 구분하므로 복잡한 길이 계산을 도입할 필요가 없다.
각 조각에는 1부터 시작하는 식별자도 붙인다. 식별자는 프로그램 안에서 특정 조각을 가리키는 번호다. 답변 끝의 “[이용 안내 #2: 취소 마감]”은 어떤 문서를 근거로 삼았는지 알려 준다. 이번 번호는 문서에 나타난 순서대로 붙으므로 문서 앞에 새 조각을 삽입하면 뒤 번호가 바뀐다. 이 예제의 출처 표시는 한 번 실행하는 동안 조각을 구분하기 위한 장치다. 오래 보관할 인용에는 별도의 고정 식별자가 필요하다.
조각의 제목도 검색 대상에 포함한다. 본문에 “21:00까지 가능하다”만 있고 “예약 시간”이라는 말이 없어도 제목이 질문과 연결해 준다. 그러나 답변에는 제목과 본문을 구분해 사용한다. 본문은 실제 안내 내용이고, 제목은 출처를 읽기 쉽게 만드는 정보다. 검색에 쓰는 문자열과 답변에 쓰는 문자열이 반드시 같을 필요는 없다.
| 항목 | 예 | 쓰임 |
|---|---|---|
| 식별자 | 2 | 검색 결과와 인용을 같은 조각으로 연결한다. |
| 제목 | 취소 마감 | 검색 단서를 제공하고 출처를 설명한다. |
| 본문 | 예약 시작 2시간 전까지 취소할 수 있다. | 답변에 사용할 실제 규칙을 담는다. |
조각을 만든 뒤에도 내용을 확인해야 한다. 제목만 남은 조각, 중간에 끊긴 문장, 다른 주제가 섞인 조각이 있는지 살핀다. 이후 검색 점수를 아무리 정교하게 계산해도 조각 자체가 잘못 나뉘면 적절한 근거를 전달하기 어렵다.
단어 색인으로 비슷한 조각을 찾는다
색인은 찾을 대상의 위치를 미리 적어 둔 목록이다. 이번 단어 색인은 “취소”가 어느 조각에 들어 있는지 기록한다. 모든 질문마다 안내 전체를 처음부터 읽는 대신, 질문에 나온 단어의 목록을 확인해 해당 조각에 점수를 더한다. 이런 식으로 단어에서 문서 위치를 찾는 구조를 역색인(inverted index)이라고 한다.
먼저 문장을 검색용 단어로 바꾼다. 이 작업을 토큰화(tokenization)라고 하며, 나눈 검색 단위를 토큰이라고 부른다. 예제에서는 정규 표현식으로 연속된 한글, 영문자, 숫자를 뽑는다. 정규 표현식은 문자열에서 일정한 모양을 찾는 규칙이다. 여기서는 물음표나 콜론을 단어에 포함하지 않는 용도로만 사용한다. 문장의 문법이나 의미를 분석하지는 않는다.
한국어에서는 “시간”과 “시간은”처럼 조사가 붙은 표현이 문제다. 공백으로만 나누면 두 표현은 서로 다른 문자열이 된다. 이번에는 작은 변환 사전을 두어 “시간은”을 “시간”으로, “마감은”을 “마감”으로 바꾼다. 이 사전은 예제 질문을 처리하기 위한 제한된 규칙이다. 모든 한국어 조사를 이해하는 도구로 설명해서는 안 된다.
“어떻게”, “정하나요”, “언제인가요”, “가능한가요”는 이번 질문에서 주제를 구분하는 데 사용하지 않는다. 이런 제외 단어를 불용어(stop word)라고 한다. 제외 목록 역시 서비스와 질문에 따라 달라진다. “불가”나 “없다”처럼 정책의 뜻을 바꾸는 표현까지 무심코 제거하면 검색이 필요한 구별을 잃을 수 있다.
토큰화 결과는 집합으로 반환한다. 집합은 같은 값을 한 번만 보관하는 자료형이다. 질문에 “예약 예약 시간”이라고 써도 “예약”을 두 번 세지 않는다. 점수는 질문과 조각 양쪽에 들어 있는 서로 다른 단어의 개수다. 제목과 본문에 같은 단어가 반복되어도 해당 단어의 점수는 한 번만 더한다.
점수가 가장 큰 조각을 하나 고른다. 점수가 같으면 식별자가 작은 조각을 고른다. 이 규칙을 코드에 드러내면 자료형의 순회 순서에 기대지 않고 결과를 고정할 수 있다. 단, 번호가 앞이라는 이유로 그 조각이 의미상 더 적합해지는 것은 아니다. 동점 처리 규칙은 실행을 일정하게 만들기 위한 선택이다.
예제의 채택 기준은 점수 2 이상이다. “예약” 한 단어만 겹치는 질문에는 답을 내지 않도록 정했다. 이 수치는 정확도나 확률이 아니다. “예약 주차”처럼 관련 없는 단어가 섞인 질문도 한 단어만 겹치면 거절하지만, 공통 단어 두 개가 우연히 겹치는 질문은 잘못 채택할 수 있다. 점수 기준은 답의 진실성을 보장하지 않으므로 실제 질문으로 따로 확인해야 한다.
찾은 근거로 답하고 빈 근거를 거절한다
검색과 답변을 함수로 나누면 책임을 확인하기 쉽다. 검색 함수는 선택한 조각과 점수를 반환한다. 답변 함수는 선택한 조각이 없으면 모른다고 답하고, 있으면 본문과 출처를 함께 반환한다. 검색 실패를 답변 함수가 추측으로 메우지 않게 하는 것이 이번 흐름의 핵심이다.
가짜 답변 함수는 본문을 그대로 옮긴다. 문장을 요약하거나 여러 조각을 종합하지 않는다. 따라서 검색한 본문이 답변으로 이어지는 과정을 눈으로 확인할 수 있다. 실제 모델을 붙이면 질문과 근거를 전달하고 답을 생성하는 단계가 들어가지만, 여기서는 그 호출을 한 함수로 대신한다. 이 함수의 출력만 보고 실제 모델의 답변 품질을 평가할 수는 없다.
출처가 붙었다고 답 전체가 검증되는 것도 아니다. 실제 모델이 근거에는 없는 조건을 덧붙였다면 출처 표시는 있어도 내용은 틀릴 수 있다. 확인할 대상은 인용 표시의 존재와 답변 내용의 일치다. 이번 코드는 본문 복사 방식으로 범위를 좁혔으므로 검사하기 쉽다. 앞으로 문장을 바꾸는 기능을 넣더라도 원문과 답을 비교하는 검사는 남겨야 한다.
근거가 없다는 말은 질문의 답이 세상 어디에도 없다는 뜻이 아니다. 현재 읽은 이용 안내에서 정해진 검색 기준을 만족한 조각을 찾지 못했다는 뜻이다. 그래서 거절 문장은 “현재 이용 안내에서 근거를 찾지 못했다”라고 쓴다. 문서에 없는 정책과 검색기가 놓친 정책을 이 작은 프로그램만으로 구별할 수는 없다.
완성 코드
다음 코드를 main.py에 저장한다. 안내 문서, 조각 만들기, 색인 만들기, 검색, 가짜 답변, 검사, 출력이 모두 한 파일에 들어 있다. 앞 장의 파일이나 예약 데이터가 없어도 실행할 수 있다. 검사에 실패하면 예외로 실행이 멈추며, 모두 통과하면 네 질문의 결과를 출력하고 끝난다.
import re
from collections import defaultdict
GUIDE = """예약 시간
예약은 09:00부터 21:00까지 가능하며, 한 번에 최대 2시간 이용한다.
취소 마감
예약 시작 2시간 전까지 취소할 수 있다.
음식 반입
뚜껑이 있는 음료만 반입할 수 있고, 음식은 반입할 수 없다.
"""
NORMALIZE = {
"예약은": "예약",
"시간은": "시간",
"마감은": "마감",
"반입은": "반입",
"음식은": "음식",
}
STOP_WORDS = {"어떻게", "정하나요", "언제인가요", "가능한가요"}
MIN_SCORE = 2
UNKNOWN = "현재 이용 안내에서 근거를 찾지 못했다. 운영자에게 확인해 달라."
def tokenize(text):
words = re.findall(r"[가-힣a-zA-Z0-9]+", text.lower())
normalized = {NORMALIZE.get(word, word) for word in words}
return normalized - STOP_WORDS
def split_guide(text):
chunks = []
for block in text.strip().split("\n\n"):
lines = [line.strip() for line in block.splitlines() if line.strip()]
if not lines:
continue
if len(lines) < 2:
raise ValueError("각 조각에는 제목과 본문이 필요하다.")
chunks.append({
"id": len(chunks) + 1,
"title": lines[0],
"body": " ".join(lines[1:]),
})
return chunks
def build_index(chunks):
index = defaultdict(set)
for chunk in chunks:
text = chunk["title"] + " " + chunk["body"]
for word in tokenize(text):
index[word].add(chunk["id"])
return dict(index)
def retrieve(question, chunks, index):
scores = defaultdict(int)
for word in tokenize(question):
for chunk_id in index.get(word, ()):
scores[chunk_id] += 1
if not scores:
return None, 0
best_id = min(scores, key=lambda chunk_id: (-scores[chunk_id], chunk_id))
best_score = scores[best_id]
if best_score < MIN_SCORE:
return None, best_score
by_id = {chunk["id"]: chunk for chunk in chunks}
return by_id[best_id], best_score
def fake_answer(chunk):
if chunk is None:
return UNKNOWN
source = f'이용 안내 #{chunk["id"]}: {chunk["title"]}'
return f'{chunk["body"]} [{source}]'
def run_checks(chunks, index):
assert len(chunks) == 3
assert tokenize("시간은 시간") == {"시간"}
assert index["취소"] == {2}
selected, score = retrieve("예약 시간은 어떻게 정하나요?", chunks, index)
assert selected is not None and selected["id"] == 1 and score == 2
assert fake_answer(selected) == (
"예약은 09:00부터 21:00까지 가능하며, 한 번에 최대 2시간 이용한다. "
"[이용 안내 #1: 예약 시간]"
)
selected, score = retrieve("주차 요금은 얼마인가요?", chunks, index)
assert selected is None and score == 0
assert fake_answer(selected) == UNKNOWN
selected, score = retrieve("예약 주차", chunks, index)
assert selected is None and score == 1
selected, score = retrieve("예약 시간 음식 반입", chunks, index)
assert selected is not None and selected["id"] == 1 and score == 2
selected, score = retrieve("", chunks, index)
assert selected is None and score == 0
def main():
chunks = split_guide(GUIDE)
index = build_index(chunks)
run_checks(chunks, index)
print("검사: 모두 통과")
questions = (
"예약 시간은 어떻게 정하나요?",
"취소 마감은 언제인가요?",
"음식 반입은 가능한가요?",
"주차 요금은 얼마인가요?",
)
for question in questions:
selected, score = retrieve(question, chunks, index)
print()
print(f"질문: {question}")
print(f"검색 점수: {score}")
if selected is None:
print("선택한 조각: 없음")
else:
print(f'선택한 조각: #{selected["id"]} {selected["title"]}')
print(f'근거: {selected["body"]}')
print(f"답: {fake_answer(selected)}")
if __name__ == "__main__":
main()
줄별 해설
import re는 문자열에서 단어 모양을 찾는 도구를 가져온다. defaultdict는 아직 없는 키에 접근할 때 기본값을 만들어 주는 딕셔너리다. 색인에는 빈 집합이 필요하므로 defaultdict(set)을 사용하고, 점수에는 0이 필요하므로 defaultdict(int)를 사용한다. 두 경우 모두 키가 처음 나타났는지 매번 조건문으로 확인하는 일을 줄인다.
GUIDE는 검색 대상 원문이다. 빈 줄은 조각의 경계이고 첫 줄은 제목이다. NORMALIZE는 검색할 때만 적용하는 변환표다. 본문 문자열을 고쳐 저장하지 않으므로 인용에는 원래 문장이 남는다. MIN_SCORE는 채택 기준을 한곳에 모은 값이고, UNKNOWN은 거절할 때 반복해서 사용할 문장이다.
tokenize()의 text.lower()는 영문 대문자와 소문자를 같은 방식으로 찾게 한다. re.findall()은 지정한 모양에 맞는 부분들을 목록으로 반환한다. NORMALIZE.get(word, word)는 변환표에 단어가 있으면 바꾼 값을, 없으면 원래 단어를 돌려준다. 중괄호로 만든 집합에서 제외 단어 집합을 빼고 반환한다.
split_guide()는 바깥쪽 공백을 정리한 뒤 정확히 두 줄바꿈을 경계로 나눈다. 각 블록에서는 빈 줄을 제외하고 줄의 양끝 공백을 정리한다. 제목만 있는 블록은 잘못된 안내 형식으로 보고 ValueError를 발생시킨다. 본문이 여러 줄이면 공백으로 이어 붙인다. 이 함수는 이 장에서 정한 문서 형식을 읽는 함수이며 임의의 문서 형식을 모두 처리하지 않는다.
chunks.append()에는 식별자, 제목, 본문을 담은 딕셔너리를 추가한다. 식별자를 붙일 때 현재 목록 길이에 1을 더한다. 빈 블록을 건너뛰어도 실제로 추가한 조각의 번호는 연속된다. 같은 조각의 세 정보를 함께 보관하면 제목을 다른 본문의 출처로 잘못 붙이는 실수를 줄일 수 있다.
build_index()는 제목과 본문을 연결해 토큰화한다. 단어마다 해당 조각의 식별자를 집합에 넣는다. 예를 들어 “취소”의 집합은 {2}가 된다. 반환할 때 일반 딕셔너리로 바꾼다. 검색할 때는 get()으로 없는 단어를 조회하므로 질문에 새 단어가 나와도 색인에 불필요한 항목이 추가되지 않는다.
retrieve()는 질문의 각 단어로 색인을 조회한다. 단어를 가진 조각마다 1점을 더한다. 질문의 단어도 집합이고 색인의 식별자도 집합이므로 같은 단어와 같은 조각의 조합을 중복해서 세지 않는다. 점수표가 비었다면 공통 단어가 하나도 없는 경우이므로 선택 결과와 점수로 None, 0을 반환한다. None은 선택한 조각이 없음을 나타낸다.
min()에 전달한 기준 함수는 (-점수, 식별자)를 만든다. 튜플은 앞 항목부터 비교한다. 높은 점수에 음수를 붙이면 값이 작아지므로 가장 높은 점수가 먼저 선택된다. 점수가 같으면 식별자가 작은 쪽이 먼저다. 집합을 어떤 순서로 읽더라도 최종 선택은 이 기준으로 결정된다.
최고 점수가 2에 못 미치면 None과 실제 최고 점수를 돌려준다. 거절했어도 0점인지 1점인지 출력에서 구분할 수 있다. 기준을 통과한 경우에만 식별자로 조각을 찾아 반환한다. 검색 결과와 점수를 함께 돌려주면 답변 함수에는 조각만 전달하면서도 실행 기록에는 판단 근거를 남길 수 있다.
fake_answer()는 조각이 없을 때 정해진 거절 문장을 반환한다. 조각이 있으면 본문 뒤에 출처를 붙인다. 질문 문자열은 받지 않는다. 이 함수가 수행하는 일은 검색된 근거를 답변 형식으로 옮기는 것뿐이기 때문이다. 실제 모델을 연결할 때는 질문도 필요하겠지만, 현재 함수의 역할에 맞춰 입력을 작게 유지한다.
run_checks()의 assert는 조건이 참인지 검사하는 문장이다. 조각 수, 표현 변환, 색인, 정상 검색, 답변 형식, 근거 없음, 낮은 점수, 동점, 빈 질문을 확인한다. 정상 검색에서는 번호만 검사하지 않고 점수도 함께 본다. 반복 단어 때문에 점수가 부풀거나 채택 기준이 사라졌다면 관련 검사가 실패한다. 이 실습은 최적화 옵션 없이 실행한다. 최적화 옵션에서는 assert 검사가 생략될 수 있다.
main()은 자료 준비와 검사를 마친 뒤 질문 네 개를 순서대로 실행한다. 시간, 난수, 파일 시스템 상태를 사용하지 않아 매번 같은 결과가 나온다. 마지막 조건문은 이 파일을 직접 실행했을 때만 main()을 호출한다. 출력에서 선택한 조각과 답변을 같이 보는 이유는 검색이 틀린 경우와 답변 조립이 틀린 경우를 따로 확인하기 위해서다.
실행 결과
macOS나 Linux의 터미널에서 다음 명령을 실행한다. 첫 명령은 문법 오류와 경고를 확인하기 위한 컴파일 검사다. 정상이라면 출력 없이 끝난다. 두 번째 명령은 내장 검사와 예제 질문을 실행한다. 컴파일 검사 과정에서는 실행 폴더의 __pycache__에 바이트코드 파일이 만들어질 수 있다. 프로그램 자체는 파일이나 데이터베이스를 만들지 않는다.
python3 -W error -m py_compile main.py
python3 main.py
예상 출력은 다음과 같다. 여기의 “모두 통과”는 코드에 적은 검사를 통과했다는 뜻이다. 모든 가능한 질문에서 검색이 올바르다는 뜻으로 읽으면 안 된다.
검사: 모두 통과
질문: 예약 시간은 어떻게 정하나요?
검색 점수: 2
선택한 조각: #1 예약 시간
근거: 예약은 09:00부터 21:00까지 가능하며, 한 번에 최대 2시간 이용한다.
답: 예약은 09:00부터 21:00까지 가능하며, 한 번에 최대 2시간 이용한다. [이용 안내 #1: 예약 시간]
질문: 취소 마감은 언제인가요?
검색 점수: 2
선택한 조각: #2 취소 마감
근거: 예약 시작 2시간 전까지 취소할 수 있다.
답: 예약 시작 2시간 전까지 취소할 수 있다. [이용 안내 #2: 취소 마감]
질문: 음식 반입은 가능한가요?
검색 점수: 2
선택한 조각: #3 음식 반입
근거: 뚜껑이 있는 음료만 반입할 수 있고, 음식은 반입할 수 없다.
답: 뚜껑이 있는 음료만 반입할 수 있고, 음식은 반입할 수 없다. [이용 안내 #3: 음식 반입]
질문: 주차 요금은 얼마인가요?
검색 점수: 0
선택한 조각: 없음
답: 현재 이용 안내에서 근거를 찾지 못했다. 운영자에게 확인해 달라.
출력이 다르면 먼저 문서와 질문을 그대로 저장했는지 확인한다. 특히 질문의 조사 변환과 제외 단어를 바꾸면 점수가 달라질 수 있다. 답변만 맞춰 고치지 말고, 질문의 토큰과 색인의 위치를 확인해 어느 단계에서 달라졌는지 찾는다.
AI에게 수정을 맡긴 뒤에는 변경 전후의 차이인 diff를 검토한다. 안내 본문이 요청 없이 바뀌었는지, 채택 기준이 낮아졌는지, 출처 표시가 없어졌는지, 근거 없음 검사가 삭제됐는지 확인한다. 실행 성공과 diff 검토는 서로 다른 정보를 준다. 실행은 현재 동작을 보여 주고, diff는 의도하지 않은 변경을 드러낸다.
실무에서 자주 틀리는 것
반복한 단어만큼 점수를 더한다
다음 함수는 같은 단어가 질문에 반복될 때마다 점수를 더한다. “예약 예약”만으로 2점을 얻으면 서로 다른 단어 두 개를 요구한다는 기준이 깨진다. 짧은 함수만 보아도 중복을 세는 위치를 확인할 수 있다.
def wrong_score(question_words, chunk_words):
return sum(1 for word in question_words if word in chunk_words)
고친 함수는 양쪽을 집합으로 만들고 교집합의 크기를 구한다. 교집합은 두 집합에 공통으로 있는 값들의 집합이다. 완성 코드의 색인 방식도 같은 점수 정의를 따른다.
def fixed_score(question_words, chunk_words):
return len(set(question_words) & set(chunk_words))
이렇게 바꾸면 반복 횟수의 영향은 줄지만 단어의 중요도를 구분하지는 못한다. 이 장은 그 한계를 남겨 둔 채 점수 계산의 의미를 분명하게 만드는 데 집중한다.
후보가 있으면 점수와 관계없이 답한다
가장 가까운 조각과 답하기에 충분한 조각은 같은 뜻이 아니다. 관련 단어가 하나만 겹쳐도 후보는 생긴다. 다음 함수는 후보 존재만 확인해 낮은 점수의 조각도 채택한다.
def wrong_accept(candidate, score, minimum):
if candidate is not None:
return candidate
return None
고친 함수는 후보와 채택 기준을 함께 확인한다. 완성 코드에서는 이 판단을 검색 함수 안에서 수행한다.
def fixed_accept(candidate, score, minimum):
if candidate is None or score < minimum:
return None
return candidate
기준을 높이면 거절이 늘고, 낮추면 관련이 약한 조각을 고를 가능성이 커진다. 어느 쪽이 적절한지는 실제 질문과 기대 결과를 모아 판단해야 한다. 여기의 2점은 예제 문서와 질문에 맞춘 규칙이다.
답변을 만든 뒤 그럴듯한 출처를 붙인다
출처는 답변 장식이 아니라 근거와 답을 연결하는 정보다. 아래 함수는 고정된 답에 선택한 조각의 제목만 붙인다. 본문이 무엇이든 “제한 없음”이라고 답하므로 인용이 답을 뒷받침하지 못한다.
def wrong_answer(chunk):
if chunk is None:
return "근거를 찾지 못했다."
return f'제한 없음. [이용 안내: {chunk["title"]}]'
고친 함수는 선택된 본문에서 답을 만든다. 이 예제에서는 본문을 그대로 사용해 원문과 답의 관계를 확인한다.
def fixed_answer(chunk):
if chunk is None:
return "근거를 찾지 못했다."
return (
f'{chunk["body"]} '
f'[이용 안내 #{chunk["id"]}: {chunk["title"]}]'
)
근거가 있는 답변도 잘못된 조각을 골랐다면 질문에 맞지 않는다. 그래서 출처 형식 검사와 검색 결과 검사를 둘 다 둔다. 한쪽 검사만으로 다른 쪽의 문제를 찾아낼 수는 없다.
문서만 바꾸고 예전 색인을 계속 쓴다
색인은 문서에서 만든 자료다. 문서를 수정하면 조각과 색인을 함께 다시 만들어야 한다. 다음 함수는 새 문서를 조각으로 나누면서도 예전 색인을 그대로 반환한다.
def wrong_refresh(text, old_index, split_fn):
chunks = split_fn(text)
return chunks, old_index
고친 함수는 새 조각으로 색인까지 다시 만든다. 함수들을 인자로 받아 어떤 작업이 필요한지 드러낸 예다.
def fixed_refresh(text, split_fn, index_fn):
chunks = split_fn(text)
return chunks, index_fn(chunks)
옛 색인이 새 조각 번호를 가리키면 엉뚱한 본문을 찾거나 없는 번호를 조회할 수 있다. 문서를 추가한 뒤에는 새 질문뿐 아니라 기존 질문도 다시 실행한다. 새 안내를 넣었다는 사실만으로 기존 동작이 유지됐다고 판단하지 않는다.
한눈에 보기
| 단계 | 입력 | 결과 | 확인할 것 |
|---|---|---|---|
| 조각 만들기 | 빈 줄로 나눈 안내 | 식별자·제목·본문 | 조건과 예외가 같은 조각에 있는가 |
| 토큰화 | 제목·본문·질문 | 중복 없는 단어 집합 | 변환과 제외가 필요한 뜻을 지우는가 |
| 색인 만들기 | 문서 조각 | 단어별 조각 식별자 집합 | 현재 문서로 다시 만들었는가 |
| 검색 | 질문과 색인 | 선택한 조각과 점수 | 동점과 낮은 점수의 처리가 고정됐는가 |
| 답변 | 선택한 조각 또는 없음 | 본문과 출처 또는 거절 | 답의 내용이 실제 근거와 일치하는가 |
이번 검색은 단어 일치 방식이다. “음식”과 “간식”처럼 뜻이 가까워도 문자열이 다르면 자동으로 연결하지 않는다. 여러 조각을 합쳐 답하거나 문서의 최신성을 판단하지도 않는다. 이 범위를 알고 있으면 검색 점수가 낮을 때 모델을 바꾸기 전에 문서 구조와 검색 표현부터 점검할 수 있다.
다음 장에서는 동작을 검사로 지키면서 코드 구조를 바꾼다. 그때도 이 장의 외부 동작은 기준으로 남길 수 있다. 같은 질문에서 같은 조각을 고르고, 근거가 없을 때 같은 거절을 반환하는지 확인하면 구조 변경이 답변 규칙까지 바꾸었는지 알 수 있다.
연습 문제
- “예약 예약 시간 시간”을 검색했을 때 점수가 얼마여야 하는지 설명하라. 질문을
run_checks()에 추가하고 선택한 식별자와 점수를 검사하라. - “간식 반입은 가능한가요?”를 검색하면 어떤 결과가 나오는지 예상하라. 간식을 음식과 같은 검색 표현으로 취급하려면 어디를 바꿔야 하는지 설명하고, 기존 질문도 다시 검사하라.
- 이용 안내에 제목이 “청소 규칙”, 본문이 “퇴실 전에 책상을 닦고 쓰레기를 가져간다.”인 조각을 추가하라. “청소 규칙”을 검색해 새 조각과 출처가 나오는지 확인하고, 조각 수 검사도 수정하라.
- 완성 코드의 동점 검사는 “예약 시간 음식 반입”을 쓴다. 이 질문에서 두 후보가 동점이 되는 이유와 현재 프로그램이 고르는 조각을 설명하라. 질문을 “예약 시간 취소 마감”으로 바꾸면 결과가 어떻게 달라지는지 계산하고, 두 질문 모두 두 규칙을 함께 답하지 못하는 이유를 적어라.
정답과 해설
첫 번째 문제. 점수는 2이고 선택한 식별자는 1이다. 질문을 토큰화하면 “예약”과 “시간”만 남는다. 예약 시간 조각에 두 단어가 모두 있으므로 2점이다. 검사는 검색 결과가 None이 아닌지 먼저 확인한 다음 식별자가 1이고 점수가 2인지 확인한다. 이 질문은 단어 반복이 채택 점수를 부풀리지 않는지 검증한다.
두 번째 문제. 변경 전에는 음식 반입 조각과 “반입”만 겹쳐 최고 점수가 1이다. 채택 기준에 못 미쳐 선택한 조각은 없고 거절 문장이 나온다. NORMALIZE에 “간식”을 “음식”으로 바꾸는 항목을 추가하면 두 단어가 겹쳐 식별자 3을 선택하고 점수는 2가 된다. 다만 간식과 음식을 같은 정책 범주로 다뤄도 되는지 안내 작성자의 판단을 먼저 확인해야 한다. 검색 편의를 위한 변환이 정책의 범위를 임의로 넓혀서는 안 된다.
세 번째 문제. 기존 음식 반입 조각 뒤에 빈 줄을 넣고 새 제목과 본문을 붙이면 식별자는 4가 된다. 조각 수 검사는 4로 바꾼다. “청소 규칙”은 새 제목의 두 단어와 겹치므로 2점으로 채택된다. 답변은 새 본문 뒤에 “[이용 안내 #4: 청소 규칙]”을 붙인다. main()은 실행할 때 조각과 색인을 다시 만들므로 새 문서가 검색에 반영된다. 변경 후에는 기존 네 질문도 실행해 예상 밖의 영향이 없는지 확인한다.
네 번째 문제. “예약 시간 음식 반입”에서 예약 시간 조각은 “예약”, “시간”으로 2점, 음식 반입 조각은 제목의 “음식”, “반입”으로 2점을 얻는다. 본문의 “반입할”은 “반입”과 다른 문자열이라 더해지지 않는다. 취소 마감 조각은 본문의 “예약”만 겹쳐 1점이다. 최고 점수 2가 두 조각에서 같으므로 동점 규칙에 따라 식별자 1을 고른다. 반면 “예약 시간 취소 마감”으로 바꾸면 취소 마감 조각이 “취소”, “마감”에 본문의 “예약”까지 겹쳐 3점을 얻고, 예약 시간 조각은 2점이라 동점이 아니다. 식별자 2가 선택된다. 겉보기에 두 주제를 반씩 물은 질문이라도 본문 단어 때문에 점수가 달라질 수 있으므로, 동점 검사의 입력은 직접 계산해 확인한 뒤 정해야 한다. 어느 질문이든 두 규칙을 함께 답하지 못하는 이유는 검색 함수가 가장 높은 후보 하나만 반환하도록 설계됐기 때문이다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.