SSAFY AI 코어 프로젝트 2에서 국보 입문 가이드를 혼자 만들었습니다. 국보를 고르고 질문하면 국보 메타데이터·한국어 위키백과·국가유산청 공식 설명에서 근거를 찾아, 문장마다 [근거 N]을 붙여 답하는 RAG 서비스입니다.
만들면서 가장 오래 붙잡고 있었던 문제는 "근거를 인용하라"고 시켜도 LLM이 그걸 지킨다는 보장이 없다는 점이었습니다.
문제: 인용은 형식일 뿐, 사실이 아니다
처음 버전은 검색한 문서를 프롬프트에 넣고 "근거에 없는 내용은 쓰지 말고 문장 끝에 [근거 N]을 붙여라"라고만 했습니다. 겉보기엔 잘 됐지만 답변을 하나씩 대조해 보니 이런 경우가 나왔습니다.
- 근거는 5개만 줬는데
[근거 7]이 붙어 있다 - 답변에는
[근거 2]만 나오는데 화면에는 검색된 근거 8개가 전부 출처로 표시된다 → 사용자는 8개 모두가 답의 근거라고 오해한다 [근거 2, 4],[근거 3~5],[근거3][근거 5·6]처럼 표기가 제각각이라 단순 문자열 비교로는 못 잡는다
"근거를 보여 주는 서비스"인데 근거 표시가 틀리면 오히려 더 그럴듯한 오답이 됩니다. 그래서 인용을 LLM에게 맡기지 않고, 번호의 의미를 코드가 정하고 코드가 검사하는 쪽으로 구조를 바꿨습니다.
1단계 — 근거 번호를 검색 단계에서 고정한다
LangGraph로 retrieve → generate 두 노드를 만들고, 번호는 retrieve에서 정해서 넘깁니다.
retrieve:
[근거 1] = 선택한 국보의 메타데이터 청크 (검색과 무관하게 항상 첫 번째)
[근거 2~] = 질문으로 검색한 외부 문서 청크 — 단, item_id 필터로 그 국보 범위 안에서만
(+ 설정 시) 질문과 관련된 다른 국보 메타데이터
→ 청크 ID와 본문 지문(md5)으로 중복 제거 → 1부터 다시 번호 부여
generate:
번호가 붙은 근거 블록만 보고 답변 작성
두 가지가 중요했습니다.
- 선택한 국보의 메타데이터는 항상
[근거 1]: 검색 점수에 밀려 정작 그 국보의 기본 정보가 빠지는 일을 막습니다. - 다른 국보 문서는 필터로 차단: "숭례문"을 물었는데 비슷한 성문 문서가 근거로 섞이면, 답이 그럴듯하게 틀립니다. 벡터 검색에
item_id필터를 걸어 그 국보에 연결된 문서만 찾습니다.
청크 ID도 국보ID:문서ID:순번으로 고정했습니다. 같은 위키 문서를 다른 국보가 다시 수집해도 ID가 겹치지 않고, 같은 국보를 다시 색인하면 덮어쓰기만 일어나 중복 청크가 생기지 않습니다.
2단계 — 답변의 인용을 파싱해서 없는 번호를 지운다
LLM 답변을 받으면 정규식으로 [근거 …] 묶음을 모두 찾고, 안의 숫자를 풀어냅니다.
2, 4/2·4/2、4→ 2, 43~5/3-5/3–5→ 3, 4, 5 (범위는 최대 20개로 제한)
그다음 근거 목록에 실제로 있는 번호만 남기고 묶음을 다시 씁니다. [근거 1, 9]인데 근거가 8개뿐이면 [근거 1]로, [근거 9]만 있으면 표기 자체를 지웁니다. 화면에 엉뚱한 번호가 노출될 일이 없습니다.
3단계 — 실제로 인용한 근거만 출처로 보여 준다
남은 번호를 기준으로 답변이 실제로 인용한 근거만 출처 목록에 표시합니다. 검색은 8건 했어도 답이 2건만 인용했다면 출처도 2건입니다. 화면에는 "답변에 사용한 근거 2건 · 검색된 근거 8건"처럼 두 숫자를 나눠 보여 줘서, 검색 결과와 실제 근거를 사용자가 구분할 수 있게 했습니다.
인용이 하나도 없으면 "답변에 인용된 근거가 없습니다"라는 안내를 따로 띄웁니다. 그리고 문서에서 확인되지 않는 질문에는 프롬프트 규칙으로 "제공된 문서에서 확인되지 않습니다"라는 고정 문장으로 답하게 해, 테스트에서 이 경우를 정확히 검사할 수 있게 했습니다.
같은 원칙을 다른 기능에도
인용만이 아니라 LLM이 고르는 모든 것을 같은 방식으로 다뤘습니다.
| 기능 | LLM이 하는 일 | 코드가 막는 것 |
|---|---|---|
| 검색어 추출 | 위키 검색어를 JSON 배열로 제안 | 형식이 깨지면 국보 이름만으로 검색. 국보 자체 문서 검색어는 LLM 답과 무관하게 항상 첫 번째 |
| 문서 선별 | 검색 후보 중 관련 문서 번호 선택 | 범위를 벗어난 번호 무시, 형식이 깨지면 국보 이름이 제목에 들어간 후보만 |
| 조건으로 찾기 | 후보 국보 중 조건에 맞는 ID 선택 | 실제 후보에 있던 ID만 채택 → 없는 국보가 결과에 끼어들 수 없음 |
| 통설 판정 | 사실 / 일부 사실 / 다름 / 확인 불가 중 판정 | 판정 문구를 못 찾으면 "문서로 확인 불가"로 처리 |
수집 단계에서는 실패의 흔적이 다음 질문을 오염시키지 않게 했습니다. 선별 프롬프트가 실패하면 그 시도에서 메모리에 쌓인 기록(어떤 검색어를 처리했는지)을 버리고 디스크 상태로 되돌립니다. 그렇게 하지 않으면 "이미 처리한 검색어"로 잘못 기록돼, 같은 질문을 다시 해도 문서를 영영 수집하지 않습니다.
테스트 — LLM 없이 전 과정을 검증
이런 규칙은 실제 LLM으로는 재현이 어려워서, 시스템 프롬프트의 [작업] … 줄을 보고 정해진 답을 돌려주는 가짜 LLM을 만들었습니다. 일부러 [근거 99]를 섞어 답하게 하고, 화면과 API에서 그 번호가 사라지는지 검사합니다.
- 결정적 임베딩 + 가짜 위키·국가유산청 응답으로 수집→색인→검색→생성 전 과정
- Streamlit
AppTest로 실제 화면을 실행해 출처 표시·오류 안내 확인 - 오류 메시지에 키·트레이스백이 섞여 나가지 않는지 확인
외부 호출 없이 50개 테스트가 10초 안에 끝나서, 프롬프트를 고칠 때마다 부담 없이 돌릴 수 있었습니다.
배운 것
- 프롬프트는 요청이고, 코드는 보장이다. 형식 지시는 대부분 지켜지지만 "대부분"으로는 출처 표시를 신뢰할 수 없습니다. 지켜져야 하는 규칙은 결과를 받은 뒤 코드로 다시 검사했습니다.
- 번호의 의미를 LLM 밖에서 정한다.
[근거 1]이 무엇인지는 검색 단계가 정하고, LLM은 그 번호를 가리키기만 합니다. 그래서 검증이 단순한 집합 비교가 됩니다. - 실패했을 때의 기본값을 먼저 정한다. JSON이 깨지면, 선별이 실패하면, 인용이 없으면 어떻게 할지를 정해 두니 LLM 응답이 흔들려도 서비스는 안정적으로 동작했습니다.
ThRead 프로젝트에서 "LLM은 번호만 고르고 실제 삭제는 코드가 한다"고 했던 원칙을, 이번에는 답변의 출처 표시까지 넓힌 셈입니다.