6장. 문서 기반 챗봇
근거 보여 주기와 모르면 모른다고 답하기
약국에서 약사에게 "이 약 같이 먹어도 돼요?"라고 물으면, 좋은 약사는 "설명서의 이 부분에 적혀 있습니다"라고 짚어 주고, 설명서에 없는 것은 "의사에게 물어보세요"라고 합니다. 모르는 것을 그럴듯하게 지어내지 않습니다. 약관 챗봇도 이렇게 답해야 합니다. 답 아래에 근거 조항과 쪽을 붙이고, 약관에 없는 질문에는 "약관에서 찾지 못했습니다"라고 답합니다.
네 상자가 근거를 챙기는 챗봇의 차례입니다. 둘째 상자에서 가장 가까운 조각의 거리가 문턱보다 멀면 모델을 부르지 않고 바로 "찾지 못했습니다"로 끝낼 수 있습니다. 셋째 상자의 프롬프트에는 "근거에 없으면 모른다고 답하라"는 규칙을 적고, 넷째 상자에서 검색한 조각의 조항과 쪽을 답 아래에 붙입니다. 여기서 설명서의 그 부분이 근거 조각, "의사에게 물어보세요"가 "약관에서 찾지 못했습니다"입니다. 비유와 다른 점은, 약사는 설명서에 없다는 것을 스스로 알지만 챗봇은 거리 문턱과 프롬프트 규칙이라는 두 겹의 장치로 막아야 한다는 것입니다. 둘 다 완벽하지 않습니다.
모르면 모른다고 답하게 하는 두 장치
| 장치 | 하는 일 | 좋은 점 | 약한 점 |
|---|---|---|---|
| 거리 문턱 | 가장 가까운 조각도 멀면 모델을 부르지 않는다 | 요금이 들지 않는다. 지어낼 틈이 없다 | 알맞은 문턱을 정하기 어렵다. 맞는 질문도 막을 수 있다 |
| 프롬프트 규칙 | "근거에 없으면 '약관에서 찾지 못했습니다'라고만 답하라" | 뜻을 보고 판단한다 | 모델이 규칙을 어길 때가 있다 |
| 출처 붙이기 | 답 아래에 조항과 쪽을 적는다 | 손님이 원문을 확인할 수 있다 | 검색이 빗나가면 엉뚱한 출처가 붙는다 |
짧은 예시
조항 네 개짜리 벡터DB 에서 거리 문턱 1.75 를 두고, 약관에 있는 질문과 없는 질문을 넣어 봅니다. 모델은 부르지 않고 "모델을 부를지"만 정합니다. 첫 블록은 앞 절과 같은 준비입니다.
# 준비: "FAISS 벡터DB 에 저장하고 검색하기" 절과 같은 연습용 임베딩과 조항 네 개짜리 벡터DB
import zlib
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
class BigramEmbeddings(Embeddings):
"""글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""
def __init__(self, size=512):
self.size = size
def _vector(self, text):
text = text.replace(" ", "").replace("\n", "")
vec = [0.0] * self.size
for i in range(len(text) - 1):
# 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
norm = sum(v * v for v in vec) ** 0.5 or 1.0
return [v / norm for v in vec]
def embed_documents(self, texts):
return [self._vector(t) for t in texts]
def embed_query(self, text):
return self._vector(text)
emb = BigramEmbeddings()
docs = [
Document("제5조 출발 10일 전까지 취소하면 전액 돌려준다.", metadata={"article": "제5조"}),
Document("제10조 국내선 상품은 15kg 짐 한 개가 포함된다.", metadata={"article": "제10조"}),
Document("제12조 반려동물은 7kg 이하의 개와 고양이만 된다.", metadata={"article": "제12조"}),
Document("제14조 여권의 남은 기간이 6개월 이상이어야 한다.", metadata={"article": "제14조"}),
]
db = FAISS.from_documents(docs, emb)THRESHOLD = 1.75 # 가장 가까운 조각의 거리가 이보다 크면 "찾지 못함"
def check(question):
doc, score = db.similarity_search_with_score(question, k=1)[0]
score = round(float(score), 3)
if score > THRESHOLD:
return f"찾지 못함 (가장 가까운 {doc.metadata['article']}, 거리 {score})"
return f"모델에 물음 (근거 {doc.metadata['article']}, 거리 {score})"
for q in ["출발 10일 전에 취소하면 돌려받나요?", "환율이 얼마예요?", "강아지 데려가도 돼요?"]:
print(q, "→", check(q))출발 10일 전에 취소하면 돌려받나요? → 모델에 물음 (근거 제5조, 거리 1.018)
환율이 얼마예요? → 찾지 못함 (가장 가까운 제5조, 거리 1.835)
강아지 데려가도 돼요? → 찾지 못함 (가장 가까운 제5조, 거리 1.855)약관에 있는 취소 질문은 제5조가 거리 1.018 로 가까워 모델에 묻기로 했습니다. "환율"은 가장 가까운 조각도 1.835 로 멀어서 모델을 부르지 않고 "찾지 못함"으로 끝냈습니다. 그런데 "강아지" 질문도 1.855 로 멀어 함께 막혔습니다. 약관에 답이 있는데도, 연습용 임베딩이 "강아지"와 "반려동물"을 잇지 못해서입니다. 거리 문턱은 검색이 잘 될 때만 믿을 만한 장치라는 뜻입니다. 진짜 임베딩으로 바꾼 뒤의 모습은 실습 5에서 봅니다.
코드가 의미하는 것
이 코드는 약사가 설명서를 펴 보고 "여기 있다"와 "없다"를 가르는 첫 판단만 떼어 낸 것입니다. 첫 블록은 앞 절과 같은 벡터DB 준비이고, 둘째 블록이 가장 가까운 조각 하나의 거리를 문턱과 견줘 모델을 부를지 정합니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
THRESHOLD = 1.75 | 거리 문턱을 정한다 | "이보다 멀면 근거가 없다"고 보는 선입니다. 연습용 임베딩에 맞춰 고른 값이라 임베딩이 바뀌면 다시 정합니다 |
similarity_search_with_score(question, k=1)[0] | 가장 가까운 조각 하나와 거리를 받는다 | 근거 후보 가운데 1등의 확실성을 봅니다. 1등도 멀면 나머지는 더 멉니다 |
if score > THRESHOLD: return "찾지 못함 …" | 문턱보다 멀면 모델을 부르지 않고 끝낸다 | 모델에게 지어낼 기회를 주지 않는 첫째 장치입니다. 요금도 들지 않습니다 |
doc.metadata['article'] | 근거 조각의 조항 번호를 꺼낸다 | 출처를 모델이 아니라 우리 코드가 붙이는 자리입니다. 없는 조항 번호가 나올 수 없습니다 |
결과에서 읽을 것은 두 가지입니다.
- 문턱은 "없는 질문"을 막았습니다. "환율"은 거리 1.835 로 문턱 1.75 를 넘어 모델을 부르지 않았습니다. 약관에 없는 질문에 지어낸 답이 나갈 길이 막힌 것입니다.
- 문턱은 "있는 질문"도 막았습니다. "강아지" 질문은 1.855 로 함께 막혔습니다. 문턱이 아무리 알맞아도 임베딩이 뜻을 못 이으면 맞는 질문까지 거절합니다. 문턱의 성능은 검색의 성능을 넘지 못합니다.
주의할 점
- 거리 문턱은 임베딩마다 다릅니다. 연습용 임베딩에서 고른 1.75 를 진짜 임베딩에 그대로 쓰면 안 됩니다. 평가 질문을 모아 놓고 맞는 질문과 없는 질문의 거리를 견줘 정합니다.
- 출처는 모델에게 쓰게 하지 말고, 검색한 조각의
metadata에서 우리 코드가 붙입니다. 모델에게 맡기면 없는 조항 번호를 지어낼 수 있습니다. - "모른다"는 답도 좋은 답입니다. 약관 챗봇이 지어낸 환불 규정을 안내하면, 아무 답도 안 하는 것보다 손님과 회사 모두에게 해롭습니다.