RAG 검색의 출처 표기 문제

Zyoon·2026년 3월 16일

개인프로젝트

목록 보기
6/6
post-thumbnail

'답변은 맞는데 출처가 틀리는' RAG 출처 매핑 정확도 개선과 파싱 최적화

1. 문제 상황

로컬 환경에서 RAG(Retrieval-Augmented Generation) 시스템을 구축하고 테스트하던 중 이상한 현상을 발견했다. LLM이 생성하는 답변 자체는 꽤 정확했다. 문서 내용과도 잘 맞았다. 하지만 문제는 출처였다. 답변은 분명 문서 A 내용을 기반으로 생성된 것처럼 보이는데, 출처로 표시되는 문서는 전혀 다른 문서 B였다. 사용자 입장에서는 이런 상황이었다.

질문 → "보라색 북극곰이 뭐야?"

LLM 답변 → 토큰 생성 과정 설명 (문서 A 기반)

출처 → 문서 B

답변 자체는 맞지만 출처가 틀리는 상황이었다. RAG 시스템에서 출처 신뢰도는 매우 중요하기 때문에 반드시 해결해야 하는 문제였다.


2. 기존 구조 설명

초기 RAG 구조는 비교적 단순했다. 질문을 벡터로 변환하고 Vector Store에서 유사도가 가장 높은 문서를 출처로 사용하는 방식이었다.

시스템 흐름

문서
 ↓
텍스트 파싱
 ↓
Chunk 생성
 ↓
Embedding 생성
 ↓
Redis Vector Store 저장
 ↓
유사도 검색
 ↓
Top Score 문서 → 출처
 ↓
LLM 답변 생성

출처 매핑 로직도 단순했다.

List<SearchResult>results=vectorStore.similaritySearch(query);

SearchResultbest=results.stream()
.max(Comparator.comparing(SearchResult::getScore))
.orElseThrow();

Stringsource=best.getMetadata().get("fileName");

이 구조는 구현은 쉬웠지만 명확한 한계가 있었다.

  • 유사도가 높다고 해서 LLM이 실제로 해당 문서를 사용했다는 보장이 없음
  • 문서 Chunk가 문맥 없이 잘려 있을 가능성 존재
  • 출처 매핑 로직이 지나치게 단순함

3. 문제를 발견한 계기

처음에는 Embedding 모델이나 Similarity 계산 문제라고 생각했다.하지만 테스트를 반복하면서 이상한 점을 발견했다.Vector Search 결과를 자세히 보면 정답 문서가 아예 없는 것은 아니었다.

예를 들어 검색 결과가 이런 식이었다.

Top 1 → 문서 B (score 0.82)
Top 2 → 문서 C (score 0.78)
Top 3 → 문서 A (score 0.75)
Top 4 → 문서 D (score 0.73)
Top 5 → 문서 E (score 0.71)

여기서 중요한 점은 정답 문서인 A가 Top-K 안에는 존재했다는 것이다. 즉, LLM은 Top-K 문맥을 기반으로 답변을 생성하면서 문서 A의 내용을 활용하고 있었지만, 출처는 단순히 Top Score 문서(B)로 표시되면서 문제가 발생했다. 그래서 단순 유사도 기반 구조를 개선하기로 했다.


4. 매핑 방식의 변경

(1) 참조 번호와 실제 문서 매칭

먼저, 각 문서에는 문맥 생성 단계에서 번호를 부여한다. 예를 들어 검색된 문서를 LLM에게 전달할 때 다음과 같은 형태로 구성한다.

[1] 파일명: 신비한 동물사전.txt
내용: 동물이란 ...

[2] 파일명: 신비한 식물사전.txt
내용: 식물이란 ...

이렇게 전달된 문맥을 기반으로 LLM은 답변을 생성하면서 [1], [2] 와 같은 참조 번호를 함께 출력한다.

질문 : 보라색 북극곰은?

보라색 북극곰은 털색이 보라색이며,  [1]
화가 나면 온몸에서 포도향 연기를 내뿜는다. [2]

이후 서버에서는 정규식을 이용해 답변에 포함된 참조 번호를 추출한다.

Patternpattern=Pattern.compile("\\[(\\d+)\\]");
Matchermatcher=pattern.matcher(answer);

추출된 번호는 다음과 같은 규칙으로 실제 문서와 매칭된다.

intdocIndex=refNum-1;

문맥을 생성할 때 문서 번호는 1부터 시작하지만, 실제 리스트 인덱스는 0부터 시작하기 때문이다. 따라서 [1] → sources.get(0), [2] → sources.get(1) 과 같은 방식으로 LLM이 참조한 번호를 실제 문서 정보와 연결할 수 있다. 이 단순한 매핑 규칙을 통해 LLM 답변에 표시된 참조 번호와 실제 출처 문서 간의 연결을 안정적으로 유지할 수 있다.

(2) Redis 원본 데이터 검증

그래서 한 단계 더 나아가 Redis 원본 데이터와 직접 비교하는 검증 로직을 추가했다.Vector Store 메타데이터만 믿지 않고 실제 문서 내용과 비교하는 방식이었다.

 // 유사도 계산
double similarity = calculateContentSimilarity(normalizedContent, normalizedRedisContent);

if (similarity > bestMatchScore && similarity > 0.3) {
    bestMatchScore = similarity;
    bestMatch = createSourceInfo(redisDoc);
}

검증 기준은 다음과 같았다.

  • 짧은 내용 : 정확한 일치 확인
  • 긴 내용 : 20자 이상의 공통 부분 문자열 존재
  • 유사도 30% 이상

여기서 30% 유사도 기준은 정확도를 보장하기 위한 기준이라기보다 최소한의 안전장치에 가까웠다.완벽한 매칭을 보장하기보다는 완전히 다른 문서가 출처로 선택되는 상황을 막기 위한 필터였다.

하지만 이 단계까지 적용했음에도 출처 문제는 완전히 해결되지 않았다.


5. Redis 데이터를 직접 확인하면서 발견한 문제

출처 매핑 로직을 여러 번 개선했지만 문제가 계속 발생했다.그래서 방향을 바꿨다.코드를 계속 수정하는 대신 Redis에 저장된 실제 데이터를 확인하기로 했다. 그리고 여기서 결정적인 문제를 발견했다.

Chunk가 의미 단위로 나뉜 것이 아니라 문장 중간에서 잘려 있었다.

// (실제로 테스트에 사용한 문서 내용)
1. 거꾸로 흐르는 시계
- 특징: 시침과 분침이 반대로 돌지만, 사용자가 잠들면 현재 시간을 정확히 맞춘다.
- 재질: 녹지 않는 아이스크림으로 만들어져 달콤한 향이 난다.
- 제작처: 시간의 끝에 위치한 '어제 공장'.

2. 보라색 북극곰
- 특징: 털색이 보라색이며, 화가 나면 온몸에서 포도향 연기를 내뿜는다.
- 서식지: 남극의 지하 300미터에 위치한 온천 수영장.
- 취미: 얼음 위에서 피겨 스케이팅 하기.
....
// 위의 문서가 이런식으로 나뉘어져 있었다.
chunk1
"1. 거꾸로 흐르는 시계 - 특징: 시침과 분침이 반대로 돌지만, 사용자가 잠들면 현재 시간을 정확히 맞춘다."

chunk2
"- 제작처: 시간의 끝에 위치한 '어제 공장'. 2. 보라색 북극곰"

문맥이 완전히 깨진 상태였다.


6. 문맥을 보존하는 계층적 파싱(Hierarchical Parsing)

문서를 단순히 글자 수 기준으로 잘라 Chunk를 생성하면, 문장이 중간에서 끊기거나 서로 관련된 내용이 다른 Chunk로 분리되는 문제가 발생한다. 그래서 문서의 구조(제목, 목록)를 이해하고 나누는 계층적 파싱 로직을 도입했다. 핵심 목표는 "관련된 내용은 흩어지지 않도록 하나의 바구니(Chunk)에 담는 것"이다.

먼저 1. 제목 형태의 문장을 만나면 새로운 주제가 시작된 것으로 판단하도록 했다. 이렇게 하면 다음과 같은 구조를 하나의 문맥으로 인식할 수 있다.

1. 보라색 북극곰
 - 특징
 - 서식지
 - 크기

또한 단순히 불릿 문장만 저장하면 문맥 정보가 사라지는 문제가 발생한다. 예를 들어 다음 문장만 보면 어떤 대상에 대한 설명인지 알 수 없다.

- 서식지는 산지이다

이를 해결하기 위해 상위 제목을 본문 앞에 함께 붙여 저장하는 방식, 즉 Context Injection을 적용했다.

기존 저장
- 서식지는 산지이다   (누구의 서식지인지 알 수 없음)
개선 저장
[보라색 북극곰] - 서식지는 산지이다

이렇게 하면 Chunk가 분리되더라도 문장이 어떤 주제에 속하는지 문맥을 유지할 수 있다. 이 방식 덕분에 의미 단위로 문서를 파싱하는 로직 자체는 제대로 동작했다. 하지만 실제 Redis에 저장된 데이터를 확인해보니 마지막 불릿 항목이 다음 Chunk로 밀려나는 현상이 발생하고 있었다.

즉, 우리가 만든 파싱 로직이 아니라 다른 어딘가에서 데이터를 다시 잘라버리고 있었다.


7. 마지막 문제 해결 : Max Chunk Size 제한

처음에는 파싱 로직 문제라고 생각했다. 그래서 불렛 단위로 파싱하는 로직을 계속 수정했다. 하지만 아무리 수정해도 문제가 해결되지 않았다. 결국 원인은 전혀 다른 곳에 있었다. 문제는 Max Chunk Size 제한이었다. Chunk 생성 과정에서 글자 수 제한을 넘으면 자동으로 다음 Chunk로 넘어가고 있었다.

if (currentChunk.length()>maxChunkSize) {
	nextChunk();
}

여기서 중요한 점은 의미 단위 파싱보다 물리적 제한(Max Size)이 우선 적용되고 있었다는 것이다.즉 불렛 단위로 문서를 나눠도,Chunk Size 제한에 걸리면 시스템이 강제로 문장을 잘라 다음 Chunk로 넘겨버렸다.

기존 설정은 다음과 같았다.

maxChunkSize = 150

150자는 영어 기준으로도 매우 짧고, 한국어 기준으로는 문장 2~3개 정도면 바로 제한에 걸리는 수준이었다.결국 너무 타이트한 제한 때문에 문맥이 계속 깨지고 있었다.


8. 최종 해결

해결 방법은 단순했다.Chunk Size를 현실적인 수준으로 늘렸다.

기존
maxChunkSize = 150

변경
maxChunkSize = 300

문맥을 유지할 수 있는 수준으로 Chunk Size를 늘리자 데이터가 정상적으로 저장되기 시작했다.


9. 적용 결과

수정 이후 시스템은 정상적으로 동작하기 시작했다.

  • 답변 정확도 → 동일
  • 출처 정확도 → 정상
  • 문서 매칭 오류 → 거의 제거

특히 다음 문제가 사라졌다.

답변은 문서 A 기반
출처는 문서 B

이제 답변과 출처가 정확히 일치했다.


10. 개발하면서 느낀 점

이번 경험을 통해 RAG 시스템에서 가장 중요한 요소가 무엇인지 다시 생각하게 되었다.많은 사람들이 다음을 먼저 고민한다.

  • 어떤 LLM을 사용할 것인가
  • 어떤 Embedding 모델을 사용할 것인가

하지만 실제로 성능에 가장 큰 영향을 주는 것은 다음이었다.

문서 파싱
Chunking 전략
문맥 유지
출처 매핑 로직

특히 Chunking 전략이 잘못되면 RAG 전체가 무너진다. 이번 경험 이후 RAG를 설계할 때는 가장 먼저 문서가 어떻게 쪼개지고 저장되는지부터 확인하는 습관이 생겼다. 코드를 계속 파기 전에 데이터를 직접 확인하는 것이 시간을 줄이는 가장 빠른 방법이라는 것을 다시 한번 느낀 경험이었다.

profile
기어 올라가는 개발

0개의 댓글