프로젝트에서 LLM의 환각과 오류

한상우·2026년 6월 29일

AI

목록 보기
13/15

"기초연금"을 못 찾는 RAG: 프로필 필터·임베딩 recall·HITL까지, 한 버그의 끝장 추적기

이 글은 WelfareAI에서 "DB에 분명히 있는 정책을 RAG가 못 찾는" 버그를 끝까지 추적하고 고친 과정을 정리한 글이다.

겉보기엔 단순한 검색 버그였지만, 파고들수록 층이 나왔다. 프로필 필터 → 임베딩 recall → 신뢰도 측정 → HITL 설계까지. 추적의 각 단계에서 가설을 데이터로 검증하고, 고친 뒤 E2E로 재검증하다 또 다른 결함을 찾는 과정이 핵심이다.

발단: E2E에서 드러난 이상

데이터 동기화를 마치고 실제 RAG를 end-to-end로 돌려봤다. 세 갈래 질문 모두 정상이었는데, 하나가 이상했다.

Q: "기초연금 지원 내용 알려줘"
A: "검색 결과에 '기초연금' 관련 항목이 없습니다..."

그런데 Postgres를 보면:

SELECT source, name FROM policies WHERE name ILIKE '%기초연금%';
-- bokjiro | 기초연금   (7건)

데이터는 있는데 검색이 못 찾는다. 흥미로운 건, 시스템이 환각하지 않고 정직하게 "없다"고 답했다는 것이다(이건 좋은 신호 — 그라운딩이 작동). 문제는 왜 못 찾았나다.

1층: 프로필 온톨로지 하드 필터

가장 먼저 rag_traces(자체 추적 테이블)에서 실제 실행 흐름을 재구성했다. trace가 결정적 단서를 줬다.

graph_walk: "Neo4j 프로필 그래프 매칭"
  → 36세·중장년·서울 기준으로 50개 정책 후보 추림
  → topPolicies: [주거안정 월세대출, 장애인자립자금, 노후준비서비스, ...]
  → (기초연금 없음)
vector_search: "Qdrant 일반 복지 검색" → 8건 (전부 위 50개 안에서)

코드를 보니 원인이 명확했다.

// retriever-services.ts
const policyIds = profile ? await policyGraph.inferFromOntology(profile) : [];
const vectorResult = await vectorRetrieval.searchGeneralWelfare(question, policyIds);

// vector-retrieval.ts
const filter = policyIds.length > 0
  ? { must: [{ key: 'policyId', match: { any: policyIds } }] }  // ← 하드 필터
  : { should: [...bokjiro...] };

inferFromOntology질문을 받지 않는다. 오직 프로필(나이·지역·생애주기)로 Neo4j에서 정책 후보 50개를 뽑는다. 그리고 그 policyId가 Qdrant 벡터 검색에 must(하드 필터)로 들어간다.

벡터 검색은 프로필이 미리 추린 후보 안에서만 일어난다. 36세 프로필의 온톨로지 후보에 노인 대상 기초연금이 없으니, 의미적으로 완벽히 일치해도 구조적으로 배제된다.

이건 전형적인 RAG 안티패턴이다. 메타데이터 사전 필터가 너무 공격적이어서 쿼리 의도를 덮어쓴다.

1층 수정: intersection → union + boost

여기서 설계 토론이 한 번 있었다. "필터를 그냥 빼면, 내가 받을 수 있는 복지 추천해줘 같은 개인화 질문에서 프로필 안 맞는 게 나오지 않나?" — 타당한 우려다.

답은 "필터를 빼자"가 아니라 "하드 배제(intersection)를 소프트 부스트(union)로 바꾸자"였다.

// (1) recall: 프로필 무관, 쿼리 의미로 코퍼스 전체 검색
// (2) personalization: 온톨로지 후보를 같은 쿼리 벡터로 랭킹(하드 임계값 없음)
const [semanticHits, ontologyHits] = await Promise.all([...]);
const merged = mergeWithOntologyBoost(semanticHits, ontologyHits);
// 온톨로지에 속한 정책은 score + BOOST 로 상위 정렬, 점수는 원본 유사도 보존
  • 특정 정책 질의("기초연금") → 의미검색으로 잡힘 (recall)
  • 개인화 질의("내 조건 맞는 복지") → 온톨로지 부스트로 프로필 적합 상위
  • 최종 적합 판단은 LLM에 위임 (이미 프로필을 컨텍스트로 받고 "조건 불일치 명시" 지시가 있음)

2층: 사실 진짜 원인은 임베딩 recall이었다

union+boost를 넣고 E2E로 재검증했다. 그런데 기초연금은 여전히 "없음"이었다.

여기서 멈추지 않고, 임베딩 유사도를 직접 측정했다. 필터 없이 기초연금 문서의 순위를 쿼리별로 찍어봤다.

쿼리기초연금 ranktop score
기초연금 (이름만)300위 밖0.39
기초연금 지원 내용 알려줘300위 밖0.49
노인 기초연금 65세 이상 소득 지원 (서술형)8위0.52
[정책명] 기초연금 [개요] 노인에게… (문서형)1위0.70

충격적이었다. 정책 이름만으로는 자기 문서를 못 찾는다. 서술형으로 풀어 쓰면 잘 찾는다.

즉 union+boost(필터 제거)로도 안 되는 이유는, 기초연금이 필터 이전에 벡터 유사도 자체로 후보권 밖이기 때문이었다. 짧은 키워드 쿼리 ↔ 구조화된 문서 매칭이 text-embedding-3-small + 한국어에서 약하다. 게다가 score_threshold: 0.4인데 "기초연금"의 최고 점수가 0.39라 전부 컷됐다.

1층(필터)은 진짜 버그였지만, 이 특정 케이스의 지배적 원인은 2층(임베딩 recall)이었다. 고치고 재검증하지 않았다면 1층만 고치고 "끝났다"고 착각했을 것이다.

3층: "낮은 점수면 HITL" — 그런데 점수로는 구분이 안 된다

여기서 좋은 제안이 나왔다. "이렇게 스코어 밖에 있는 질문일 땐 그냥 휴먼 인 더 루프(HITL)로 되묻는 게 낫지 않나? 내 프로필 말고 다른 정보를 찾고 싶을 수도 있고."

방향은 정확했다. 그런데 데이터를 보니 점수 절대값으론 구분이 안 됐다.

질의검색 top score결과 품질
기초연금 (실패)0.49엉뚱한 문서
내 조건 맞는 복지 (성공)0.44완벽한 개인화 답변

실패한 질의가 성공한 질의보다 점수가 높다. 한국어+3-small에선 점수가 0.4~0.5 좁은 띠에 몰려서, 단순 score floor를 걸면 좋은 개인화 질의를 먼저 HITL로 빠뜨린다.

그래서 신뢰도 신호를 점수가 아니라 "물어본 걸 실제로 못 찾았나(엔티티 부재)"로 잡았다.

질문이 특정 정책명을 지목했는데, 검색된 문서의 정책명(title) 또는 본문의 [정책명] X 마커에 그게 하나도 없으면 → 저신뢰 → HITL 재질문.

// rag.graph.ts — tools 직후 assess_retrieval 노드
const namedPrograms = queryAnalysis.extractNamedPrograms(question); // ["기초연금"]
const found = namedPrograms.some(p =>
  titles.some(t => t.includes(p)) ||
  contents.some(c => new RegExp(`\\[정책명\\]\\s*${p}`).test(c)));
// found=false → request_clarification(HITL questionnaire)

검증이 또 잡아낸 두 결함

게이트를 넣고 E2E를 돌렸더니, 검증이 두 개의 결함을 잡았다.

결함 A — 게이트 false negative. 게이트가 "기초연금을 찾았다"고 오판했다. 원인: 도구 payload에 query(질문 echo)가 들어 있어서, raw JSON에 substring "기초연금"이 항상 존재했다. → items의 content/title만 보도록 수정. 그래도 또 오판 — 이번엔 다른 문서 본문에 "기초연금과 중복 불가" 같은 우발적 언급이 있었다. → 정책명(title)과 [정책명] 구조화 마커에서만 매칭하도록 정밀화.

결함 B — reactive 안전망 false positive. 보조로 넣은 "검색 결과에 없습니다" 정규식이, "지금 신청 가능한 청년월세는 검색 결과에 없습니다" 같은 정상 답변(찾았으나 접수 종료)에 오발동했다. → 검색 실패 동사(찾을 수 없/조회되지 않)만 매치하고, 가용성 문구는 의도적으로 제외.

각 결함은 데이터(trace, 이벤트 카운트)로 확인하고, 고친 뒤 또 재검증했다.

최종 동작

질의동작
기초연금 / 의료급여 (recall 미스)약한 "없음" 대신 HITL 재질문
청년월세 (검색됨)정상 답변, HITL 미발동 ✅
"내 조건 맞는 복지" (미지목)개인화 답변, 게이트 스킵 ✅

이제 시스템은 못 찾으면 어설픈 답 대신 "어떤 기초연금인지(본인/부모님), 키워드를 구체화해 달라"고 구조화된 선택지로 되묻는다. 사용자는 프로필에 갇히지 않고 원하는 정보로 좁혀갈 수 있다.

회고: 추적에서 배운 것

  1. 데이터로 가설을 죽여라. "필터가 문제다"는 맞았지만 부분적이었다. 임베딩 rank를 직접 찍어보지 않았다면 1층에서 멈췄을 것이다.

  2. 고친 뒤 반드시 재검증하라. union+boost를 넣고 "됐겠지" 했으면 버그는 살아있었다. 재검증이 2층(임베딩)을, 그리고 게이트의 결함 A/B를 연달아 드러냈다.

  3. 좋은 직관도 측정으로 다듬어라. "낮은 점수 → HITL"은 옳은 방향이었지만, 점수 분포를 보지 않았다면 좋은 질의를 망쳤을 것이다. 측정이 신호를 "점수"에서 "엔티티 부재"로 바꿨다.

  4. 남은 빚을 정직하게 남겨라. 임베딩 recall 자체(렉시컬 하이브리드)는 아직 안 고쳤다. HITL로 우아하게 우회 중이지만, 근본 해결은 별도 과제로 남아 있다. 그리고 이 케이스("기초연금 → 발견돼야 함")는 retrieval 골든셋으로 박제해 회귀를 막을 차례다.

    RAG 버그는 한 겹이 아니다. 필터, 임베딩, 신뢰도 측정, UX 폴백이 한 증상 뒤에 겹쳐 있다. 각 겹을 데이터로 벗기고, 고칠 때마다 다시 돌려봐야 진짜 바닥이 보인다.

profile
안녕하세요

0개의 댓글