lifecarelog
백엔드

AI가 만든 각주는 인용이 아니다 — 추출과 검증을 분리한 백엔드 파이프라인

LLM이 뽑은 출처를 그대로 저장하면 없는 논문이 DB에 남습니다. 추출 API와 검증 API를 분리하고 unknown 레인을 두는 백엔드 설계를 정리했어요.

7분 읽기

법령 검색 서비스인 플랜엘(plan-l)을 만들면서 가장 오래 붙잡았던 문제는 정확도가 아니라 저장 시점이었어요. LLM한테 "이 조항의 근거를 정리해 줘"라고 시키면 조문 번호와 판례 사건번호가 그럴듯하게 딸려 나옵니다. 문제는 그중 일부가 존재하지 않는다는 거예요. 그런데 API 응답은 200이고, JSON 파싱도 성공하고, DB insert도 성공합니다. 실패한 곳이 한 군데도 없는데 없는 판례가 테이블에 앉아 있어요.

같은 모델에게 자기 검증을 시키면 안 되는 이유

처음엔 프롬프트에 "확실하지 않으면 비워 두세요"를 넣었어요. 돌려 보니 빈 값 비율이 눈에 띄게 늘긴 하는데, 틀린 값이 사라지는 게 아니라 자신 있는 틀린 값만 남습니다. 뽑아낸 주체와 확인하는 주체가 같으면 검증이 아니라 재확인이에요.

그래서 파이프라인을 두 단계로 잘랐습니다.

원문 입력LLM 추출(후보만)결정론적 검증기(공식 API 대조)verified / unknown 분기저장

핵심은 LLM 출력이 후보 레코드라는 점이에요. 최종 데이터가 아니라 검증 대기열에 들어가는 티켓입니다.

스키마에 상태를 박아 둔다

테이블 설계를 이렇게 바꿨어요. 검증 결과를 컬럼이 아니라 행의 존재 조건으로 만드는 게 포인트입니다.

create type citation_status as enum ('pending', 'verified', 'unknown');
 
create table citations (
  id           bigint generated always as identity primary key,
  doc_id       bigint not null references documents(id),
  raw_ref      text   not null,          -- LLM이 뱉은 원문 그대로
  source_key   text,                     -- 검증 성공 시에만 채워짐
  status       citation_status not null default 'pending',
  checked_at   timestamptz,
  constraint verified_needs_key
    check (status <> 'verified' or source_key is not null)
);

verified_needs_key 제약이 하는 일이 전부예요. 검증기를 거치지 않은 레코드는 verified가 될 수 없습니다. 애플리케이션 코드에서 실수로 상태만 바꾸면 DB가 거부해요. 이런 판정은 코드보다 제약조건에 두는 게 안전합니다.

unknown 레인이 없으면 조용히 사라진다

가장 흔한 실수는 검증 실패를 catch 안에서 로그만 찍고 넘기는 거예요. 그러면 매칭 안 된 항목이 통계에서도 빠지고 사람 눈에도 안 보입니다. 저는 실패를 삭제하지 않고 unknown으로 격리해요.

async def verify(ref: str) -> tuple[str, str | None]:
    try:
        hit = await official_api.lookup(ref)   # 공식 데이터 조회
    except TimeoutError:
        return ("pending", None)               # 재시도 대상, 소진 아님
    return ("verified", hit.key) if hit else ("unknown", None)

타임아웃과 미매칭을 다른 상태로 가른 게 중요해요. 둘 다 "실패"로 묶으면 외부 API가 5분 잠깐 죽었을 때 멀쩡한 인용이 전부 unknown으로 떨어집니다. 적용해 봤더니 이 구분 하나로 재시도 대상이 실제 오류의 몇 배 규모에서 정상 수준으로 줄었어요.

  • 추출과 검증의 주체 분리
  • DB 제약으로 상태 강제
  • unknown 격리
  • 타임아웃과 미매칭 구분

대시보드가 아니라 표면을 본다

운영하면서 배운 건 status = 'success' 카운트를 믿으면 안 된다는 거예요. 파이프라인이 성공했다는 뜻이지 데이터가 맞다는 뜻이 아니거든요. 그래서 모니터링 쿼리를 이렇게 둡니다.

select status, count(*), max(checked_at)
from citations
where created_at > now() - interval '1 day'
group by status;

행 수만 보지 말고 max(checked_at)을 같이 보세요. 검증 워커가 조용히 죽으면 에러 없이 그냥 시각이 멈춥니다. 이 컬럼 하나 추가하는 데 5분 걸렸는데, 워커가 멈춘 걸 알아챈 건 이것 덕분이었어요.

정리

  • LLM은 추출에 쓰고, 확인은 공식 데이터를 가진 결정론적 검증기에 맡겨요
  • 검증 통과 여부는 애플리케이션 분기가 아니라 DB 제약으로 강제해요
  • 실패는 버리지 말고 unknown으로 남겨서 사람이 보게 해요
  • 헬스체크에는 개수뿐 아니라 마지막 성공 시각을 넣어요

플랜엘은 국가법령정보센터와 대법원 데이터를 근거로 검색 결과를 돌려주는데, 이 구조를 넣기 전과 후는 체감이 완전히 달라요. 모델이 똑똑해지길 기다리는 것보다 파이프라인을 두 조각으로 자르는 게 훨씬 빠른 길이었습니다. 🔍

#백엔드#LLM#데이터검증

라이프케어로그 서비스가 궁금하신가요?

AI 기반 건강·일정·재활 관리 앱을 직접 써보세요.

서비스 살펴보기

관련 글

댓글

아직 댓글이 없어요. 첫 댓글을 남겨주세요.