본문 바로가기

Tech

거짓말 안 하는 챗봇 만들기

"AI가 지어내면 어쩌지"에서 시작한 사내 RAG 챗봇

"AI가 지어내면 어쩌지"에서 시작한 사내 RAG 챗봇

사내 문서를 근거로 가벼운 질의응답을 해주는 챗봇을 만들고 있다. 아직 개발이 끝난 건 아니지만, 지금까지 시스템을 세우며 고민한 결정들을 정리해 둔다.


이 글은 기능 설명이 아니다. "왜 굳이 이 방식을 골랐나"에 초점을 맞췄다. 챗봇을 만들다 보면 선택지가 계속 갈라지는데, 그 갈림길마다 무엇을 버리고 무엇을 택했는지가 결국 시스템의 성격을 결정하더라.


전체 흐름은 단순하다.


문서 업로드 → 문서 분할/저장 → 사용자 질문 → 저장된 문서와 유사도 측정 후 답변

이 흐름의 바탕에는 RAG(검색 증강 생성)가 있다. 사용자가 올린 문서 안에서만 근거를 찾아 답하는 방식이다. AI가 제멋대로 사실을 지어내는 걸 구조적으로 막고, 주어진 데이터 안에서만 답하게 뼈대를 잡았다. 시작점이 "AI가 그럴듯한 거짓말을 하면 어쩌지"였으니, 설계의 거의 모든 결정이 그 불안을 줄이는 방향으로 수렴했다.


그림 1. 적재와 질의 — 전체 파이프라인

TL;DR

  • AI에게 통째로 맡기던 요약 구조를 버렸다. 문서를 그대로 두고 라이브러리로 기계적 청킹 → 토큰 낭비·지연·세부 누락을 한 번에 해결.
  • 청킹은 1,000자 + 200자 오버랩. 여러 길이로 테스트한 끝의 최적값. 경계에서 문장이 잘려 수치가 검색 안 되는 문제를 오버랩으로 막았다.
  • 검색은 인메모리 벡터 캐시. 디스크 DB 전체 스캔 대신 벡터만 RAM에 올려 비교 → 검색 지연 거의 제로.
  • 유사도를 3단계로 나눴다. 관련 없는 문서를 자신 있게 답하는 게 "못 찾음"보다 위험하기 때문. 낮으면 경고 문구를 자동으로 붙인다.
  • 환각은 프롬프트가 아니라 파라미터로도 막는다. 출력 토큰 상한 + 낮은 temperature로 모델이 소설 쓸 여지를 줄였다.

RAG의 핵심은 "AI에게 무엇을 맡기지 않을 것인가"를 정하는 일이었다.

1. 문서 업로드 — AI에게 다 맡기지 않기로 했다

사용자가 자료를 올려 챗봇의 지식 기준을 마련하는 첫 단계다.

동작 방식

  • 업로드 파일이 30MB를 초과하는지 먼저 검사하고, 넘으면 즉시 차단한다.
  • 통과한 일반 문서(PDF, TXT 등)는 문서 파싱 라이브러리가 직접 글자를 읽어 텍스트를 추출한다.
  • 스캔된 PDF처럼 글자를 뽑아낼 수 없는 문서는 비전 모델의 OCR로 대체 추출한다. 요약·재구성 없이 원문 그대로, 표 영역이나 문서 배치를 유지한 채 텍스트를 뽑도록 프롬프트를 잡았다.

왜 그렇게 했나

첫째, 파싱 라이브러리를 기본 추출 도구로 쓴 이유. 처음엔 원문을 그대로 저장하는 대신 문서를 통째로 LLM에 넘겨 알아서 문단을 나누고 요약하게 설계했다. 그런데 문서가 커질수록 한 번에 감당할 토큰이 폭증했고, 모델의 처리 시간을 무작정 기다리느라 시스템이 느려졌다. 게다가 방대한 내용을 무리하게 압축하다 보니 중요한 세부 절차나 수치가 통째로 누락되는 품질 저하까지 겹쳤다.


그래서 "AI에게 다 맡기는" 구조를 버렸다. 비용·속도·품질 저하의 주범이던 'LLM 통째 요약'을 없애고, 라이브러리가 기계적으로 원문을 손실 없이 일정 크기로 자르게(청킹) 했다. 토큰 낭비와 지연을 잡으면서 데이터 완전성까지 확보했다. AI를 덜 쓰는 쪽이 더 정확했다는 게 첫 교훈이었다.


둘째, 스캔 문서에만 비전 OCR을 병행한 이유. 파싱 라이브러리는 이미지에 글자가 박힌 스캔본에서는 텍스트를 못 뽑는다. 스캐너로 찍은 공문, 이미지로만 저장된 양식 등이 여기 해당한다. 그래서 추출이 실패하거나 글자가 제대로 안 나오는 경우를 감지해, 그때만 비전 모델로 대체 추출하도록 병행했다. 모든 문서에 OCR을 돌리지 않은 건 비용 때문이다 — 필요할 때만 무거운 도구를 꺼낸다.


셋째, 30MB 제한을 건 이유. 이 구조는 파일을 외부 모델에 전달한다. 파일이 너무 크면 토큰 제한으로 오류가 나거나 처리 자체가 불가능하다. 그래서 30MB 초과 파일은 업로드 시도 자체를 막는 안전장치를 걸었다. 실패를 나중에 처리하는 것보다, 애초에 들어오지 못하게 하는 편이 깔끔했다.

2. 문서 분할·저장 — 어디서 자르느냐가 검색 품질을 정한다

업로드된 자료를 챗봇이 찾기 쉽게 잘게 쪼개 저장하는 단계다.

동작 방식

  • 원문을 요약·축약하지 않고 그대로 둔 채 텍스트를 분할한다.
  • 조각 크기는 1,000자, 앞 조각의 뒤쪽 200자를 다음 조각 앞에 겹쳐서 잘랐다.
  • 나뉜 조각은 임베딩 모델을 거쳐 의미 좌표(벡터)로 변환한다.
  • 변환은 한꺼번에 하지 않고 일정 개수로 묶어 순차 처리하며, 중간에 쉬는 시간을 둔다.
  • 변환된 벡터는 원문 글자와 함께 DB에 저장한다.

임베딩과 벡터가 뭔가: 컴퓨터는 글자의 의미를 스스로 이해하지 못한다. 그래서 문장의 개념을 계산 가능한 좌표로 번역하는데, 이게 임베딩이다. 변환된 숫자 집합이 벡터다. 의미가 비슷한 문장일수록 수학적 공간에서 가까이 놓이고, 챗봇은 나중에 이 '거리'를 계산해 질문과 가장 맞는 조각을 찾는다.

왜 그렇게 했나

첫째, 전체가 아니라 분할해서 저장하는 이유. 내용이 길면 AI가 한 번에 못 읽어 뒷부분을 생략하거나 에러가 난다. 불필요한 내용까지 딸려 들어가 입력 토큰이 커지고 엉뚱한 답이 나오기도 한다. 유사도 검색에서도 정작 중요한 내용이 통 안에 묻혀 우선순위가 밀린다. 잘게 쪼개야 필요한 조각만 정확히 꺼낼 수 있다.


둘째, 1,000자 + 200자 오버랩인 이유. 어느 크기로 자르냐는 검색 품질에 크게 영향을 준다. 여러 길이로 수차례 테스트한 결과, 1,000자 기준에 200자 오버랩이 문맥 밀도를 알맞게 유지하면서 가장 정확한 검색 결과를 냈다.


특히 1,000자씩 딱 떨어지게 자르면 문장이 경계에서 뚝 잘린다. 예를 들어 …신규 집계 기준일은으로 끝나는 조각과 결제일을 원칙으로 한다.로 시작하는 조각이 완전히 분리돼 저장되면, 정작 그 기준을 물었을 때 제대로 안 찾아진다. 앞 조각 끝 200자를 뒤 조각 머리에 겹쳐 두면 이런 경계 맥락이 안전하게 보존된다.


그림 2. 청킹 오버랩 — 경계 문장 보존

셋째, 임베딩 중간에 쉬어가는 이유. 외부 임베딩 서비스에는 분당 요청 한도(RPM)가 있다. 대용량 파일을 올리면 수백 개의 변환 요청이 순식간에 쏟아지는데, 이걸 연속으로 쏘면 외부 시스템이 접속을 차단해 버린다. 그래서 20개씩 묶어 처리하고 묶음마다 15초씩 쉬도록 설계했다. 느려 보여도, 대량 파일을 에러 없이 끝까지 저장하는 게 우선이었다.

3. 질문 처리·답변 생성 — 못 찾는 것보다 잘못 답하는 게 무섭다

사용자가 질문을 던지면 원문 검색을 거쳐 최종 답변을 조립하는 마지막 단계다.

동작 방식

  • 질문 텍스트도 저장 문서와 비교하려고 먼저 임베딩을 거쳐 벡터로 바꾼다.
  • 방대한 원문을 통째로 스캔하지 않는다. 대신 저장 때 만들어둔 벡터만 인메모리 캐시로 로드하고, 질문 벡터와 수학적으로 비교해 가장 겹치는 조각을 즉시 골라낸다.
  • 결과를 유사도 3단계로 분류한다.

유사도 구간사용 방식
85% 이상상위 5개를 바로 사용
65 ~ 85%상위 3개만 보조 자료로 활용
65% 미만가장 근접한 1개 + 정확하지 않을 수 있다는 경고 문구

  • 원본 DB에서는 삭제됐는데 캐시에만 남은 '유령 데이터'가 발견되면, 즉시 메모리에서도 지운다.
  • 걸러낸 원문 조각은 엄격한 규칙과 함께 묶어 모델에 넘긴다. 지시 사항·참고 자료·사용자 질문을 서로 다른 특수 기호로 구획해 AI가 영역을 혼동하지 않게 하고, 이전 대화는 최근 3쌍만 100자로 압축해 함께 보낸다.
  • 이렇게 구성한 프롬프트를 답변 생성 모델에 넘겨 자연스러운 문장으로 조립한다.

그림 3. 질의 처리 — 유사도 3단계 분류

왜 그렇게 했나

첫째, 이전 대화를 요약해 같이 넘기는 이유. "아까 말한 그거"처럼 앞 문맥을 이어받는 질문이 들어오면, 이전 대화가 없으면 챗봇은 의도를 전혀 파악하지 못한다. 그렇다고 모든 대화를 통째로 넘기면 처리 시간과 비용이 불필요하게 커진다. 맥락은 유지하면서 부담은 줄이는 균형점으로 최근 3쌍 요약을 택했다.


둘째, 인메모리 캐시를 쓴 이유. 문서 조각이 수만 건 쌓이면 디스크 DB에서 전체를 뒤지는 I/O가 만만치 않다. 벡터만 RAM에 미리 올려두면 질문이 들어오는 즉시 메모리에서 비교가 끝나 검색 지연이 거의 제로에 가깝다. 매번 DB를 스캔하는 병목 없이 즉각 응답할 수 있다.


셋째, 유사도를 3단계로 나눈 이유. 비슷한 문서가 없다고 그냥 "찾을 수 없음"으로 끝내면 사용자에게 너무 야박하다. 반대로 아무 관련 없는 문서를 근거로 자신 있게 답하는 건 더 위험하다. 그래서 85% 이상은 신뢰 구간으로 쓰고, 그 아래도 단계별로 활용하되 가장 낮은 구간에서는 경고 문구를 미리 붙인다. 품질과 친절함을 동시에 잡기 위한 기준이다.


한 가지 배운 점: 청킹 방식이 유사도 분포 자체를 바꾼다. 요약해서 저장하면 핵심만 압축돼 유사도가 85% 이상으로 잘 나오지만, 원문을 그대로 쪼개면 데이터 밀도가 낮아져 분포가 달라진다. 즉 청킹을 바꾸면 유사도 기준선도 함께 조정해야 한다. "85%"는 절대 진리가 아니라 내 청킹 방식에 종속된 숫자였다.


넷째, 유령 데이터를 자동 정리하는 이유. 관리자가 DB에서 자료를 지워도 메모리 캐시에는 흔적이 남을 수 있다. 이걸 방치하면 존재하지 않는 번호로 검색이 시작돼 오류가 난다. 그래서 검색 결과를 DB와 대조하다 없는 번호가 나오면 그 즉시 캐시에서도 지우게 했다. 손으로 어딘가를 건드리지 않아도 시스템이 스스로 정리된다.


다섯째, 토큰·창의성 파라미터를 제한한 이유. 모델이 불필요하게 길게 답하거나 상상력을 발휘해 사실을 흐리는 걸 막으려고 호출 파라미터를 걸었다. 출력 토큰 상한(maxOutputTokens)으로 장황함을 억제하고, temperature를 낮춰 모델이 근거에서 벗어나 소설을 쓰지 않도록 했다. 프롬프트로만 환각을 막는 게 아니라, 모델의 자유도 자체를 줄이는 접근이다.

향후 고도화 구상

지금은 기본 질의응답 흐름의 완성도를 높이는 데 집중하고 있고, 다음 단계로 이런 걸 구상 중이다.


  • 대화 이력 캐싱으로 응답 속도 개선 — 자주 묻는 질문은 검색 과정을 줄이고 더 빠르게 답하기.
  • 질문 재구성(rewriting)으로 답변 품질 향상 — 사용자의 원 질문을 그대로 쓰지 않고, 의도를 파악해 모델이 더 잘 이해할 형태로 다듬어 넘기기.
  • 업무 처리 이력 기반 영향도 추정 — 정적인 규정 문서를 넘어, 실제 처리 흐름이 담긴 요약 문서로 특정 변경이 시스템 전반에 미칠 영향을 추측·안내.
  • 입력 토큰 최소화, 답변 생성 모델의 로컬 구동 등.

사용 스택

런타임에 쓴 도구는 이렇게 나눴다. (모두 무료 티어 범위에서 검증했다.)


역할도구채택 이유
텍스트 추출·청킹문서 파싱 라이브러리원문 손실 없이 빠르고 기계적으로 분할
스캔 문서 OCR경량 비전 모델이미지 처리가 빠르고 한국어 인식률이 높음
벡터 임베딩임베딩 전용 모델한국어 문맥 이해도가 높음
최종 답변 생성대화 특화 LLM단순 대화에 최적화, 파일·이미지 불필요

정리하며

만들면서 계속 되돌아온 원칙이 하나 있었다. "이 일을 AI에게 맡길 것인가, 맡기지 않을 것인가."


  • 문서 요약을 AI에 맡겼더니 느리고 부정확했다 → 맡기지 않고 기계적으로 청킹했다.
  • 어디서 자를지를 감으로 정할 수도 있었다 → 맡기지 않고 여러 값으로 실측해 1,000/200을 얻었다.
  • 환각을 프롬프트 문구로만 막을 수도 있었다 → 그것도 하되, 모델의 자유도 자체(temperature·토큰)를 줄여 이중으로 묶었다.

RAG는 "AI를 똑똑하게 쓰는 기술"이라기보다 "AI가 틀릴 여지를 어디까지 좁힐 것인가"의 기술에 가까웠다. 근거를 통제하고, 자를 지점을 통제하고, 자유도를 통제한다. 그 통제의 총합이 곧 신뢰할 수 있는 답변이었다.


아직 개발 중인 시스템이라 이 글의 결정들도 계속 바뀔 것이다. 다음 편에서는 질문 재구성과 대화 이력 캐싱을 붙이며 겪은 것들을 이어 쓸 예정이다.