안과 의료 챗봇을 개발하며 GraphRAG를 도입했다가 실패한 경험을 공유한다.
이 글은 GraphRAG를 고려하는 분들에게 "이론의 매력"과 "현실 구현의 함정"을 함께 보여주고, 실무에서 바로 적용할 수 있는 교훈을 남기기 위해 작성한다.
처음 받은 요구사항은 명확했다. 단순한 의료 상담 챗봇이 아니라, 정확한 수치와 근거를 기반으로 한 전문적 수준의 답변을 제공하는 시스템이 필요했다.
예를 들어, "백내장 수술을 고민 중입니다"라는 질문에 대해:
추가로 한국 의료 환경에 맞춰 국내 유통 의약품 정보와 보험 급여 기준까지 포함해야 했다.
가장 중요한 요구사항 중 하나는 환자 개별 컨텍스트를 반영한 맞춤형 답변이었다. 시스템은 대화 중에 계속해서 추론하고 데이터를 검색하도록 설계되어야 했다:
예를 들어 "당뇨가 있는데 백내장 수술 가능한가요?"라는 질문에:
이 모든 과정이 하나의 답변을 생성하는 동안 연속적으로 일어나야 한다는 점이 핵심 요구사항이었다.
위 요구사항은 예시이며, 사용자의 질문에 따라 대화 맥락에 따라 구체적이고 다양한 관점에서의 답변을 제공하는것이 요구사항이었다. (추상적임)
의료 지식은 본질적으로 관계형 데이터다. 질병과 증상, 약물과 부작용, 치료법과 예후가 복잡하게 연결되어 있기 때문에, GraphRAG(Graph Retrieval-Augmented Generation)가 이러한 구조를 활용해 정확한 답변을 생성할 수 있을 것으로 기대했다. 특히 LangGraph를 통해 체계적인 파이프라인을 구축할 수 있다는 점도 매력적이었다.
의료 지식 베이스가 필요한 상황에서 UMLS(Unified Medical Language System)를 선택하게 되었다. NIH에서 제공하는 이 시스템은 세계 최대 규모의 의료 온톨로지로, 우리의 요구사항을 충족시킬 수 있을 것으로 판단했다.
UMLS는 보안상의 이유로 직접 다운로드가 불가능하며, UTS(UMLS Terminology Services) 계정 생성과 의료 라이센스 동의가 필수다.
1. UTS 계정 생성 (https://uts.nlm.nih.gov/uts/signup-login)
2. 라이센스 동의 (의료 목적 사용 조건)
3. 2025AA Full Release 다운로드 (5.1GB)
5.1GB라는 대용량 파일을 다루어야 했다.
전체 UMLS 데이터는 방대해서 필요한 부분만 추출해야 한다는 결론에 도달했다. MetamorphoSys라는 도구를 사용해 CDSS(Clinical Decision Support System)에 필요한 서브셋을 만들었는데, 여기서 수백 개의 Semantic Types 중 어떤 것을 선택할 것인가라는 중요한 결정을 내려야 했다.
긴 고민 끝에 다음 13개만 선택하기로 결정했다:
질병 관련 (5개)
증상/소견 (3개)
약물 (2개)
시술 (2개)
기타 (1개)
Drug와 Medication의 구분이 특히 중요했는데, "아스피린"은 Drug(성분)에 해당하고 "바이엘 아스피린 100mg 장용정"은 Medication(실제 제품)에 해당한다.
UMLS의 미국 중심 의약품 정보와 별도로 확보한 한국 의약품 데이터를 함께 사용해야 했다. (UMLS 그래프에 데이터 추가 필요)
UMLS의 복잡한 관계 이름을 LLM이 이해하기 쉽게 변환했다:
| UMLS 관계 | LLM 친화적 이름 | 건수 |
|---|---|---|
| isa | IS_A | 1.96M |
| inverse_isa | HAS_SUBTYPE | 1.96M |
| has_ingredient | HAS_INGREDIENT | 145K |
| has_manifestation | CAUSES_SYMPTOM | 133K |
| disease_may_have_finding | MAY_PRESENT_WITH | 19K |
이런 식으로 22개 관계를 모두 매핑했다.
변환 스크립트를 돌린 결과:
입력 파일:
- MRCONSO.RRF: 551MB (개념 정보)
- MRREL.RRF: 1.3GB (관계 정보)
- MRSTY.RRF: 91MB (Semantic Type)
출력:
- 약 1.3M개 개념 노드 (영어 용어만)
- 약 4.2M개 관계 엣지
처음에는 Cypher 스크립트로 임포트를 시도했다. APOC의 apoc.export.cypher.all()로 추출한 스크립트를 실행했는데, 너무 느려 포기했다. 결국 neo4j-admin import를 사용해 6.8초만에 임포트를 완료했다.
LangGraph를 사용해 파이프라인을 구축했다.
1. normalize_query: 질문 정규화 (한국어 → 영어, UMLS 표준화)
2. discover_entities: 의료 엔티티 발견
3. analyze_and_decompose: 복잡한 질문 분해 및 템플릿 매칭
4. generate_cypher: Cypher 쿼리 생성
5. validate_cypher: 쿼리 검증
6. execute_cypher: Neo4j 실행
7. make_context: 컨텍스트 생성
단순히 순차적으로 실행되는 것이 아니라, validate_cypher 실패 시 generate_cypher로 돌아가는 루프 구조였다:
질문: "당뇨약 복용 중인데 백내장 수술 가능한가요?"
[1. 의료 용어 정규화]
→ "당뇨약" → "antidiabetic medication", "diabetes medication"
→ "백내장 수술" → "cataract surgery", "phacoemulsification"
→ 한국어-영어 의료 용어 매핑 테이블 참조
[2. discover_entities]
→ 정규화된 용어로 UMLS 엔티티 검색
→ "당뇨약", "백내장 수술" 엔티티 추출
→ 환자 컨텍스트와 함께 전달
[3. analyze_and_decompose]
→ 질문을 여러 관점으로 분해
→ 113개 템플릿 중 매칭되는 것 선택
→ 병렬로 처리할 서브 질문 생성
[4-5-6 루프: generate → validate → execute]
1차 시도:
→ generate_cypher: 잘못된 관계 타입 사용
→ validate_cypher: 검증 실패 (is_valid: false)
→ 다시 generate_cypher로 (최대 3회)
2차 시도:
→ generate_cypher: 피드백 반영하여 재생성
→ validate_cypher: 검증 성공
→ execute_cypher: 결과 없음 (0 rows)
3차 시도:
→ 다른 템플릿 시도
→ 검증 성공했지만 여전히 결과 없음
[7. make_context]
→ Neo4j 쿼리 결과를 의료적 내러티브로 변환
→ Semantic Field Analysis: 동적 키-값 구조에서 의미 추출
→ 결과가 없을 때: Cypher 쿼리 패턴 분석 → 의료적 검색 방향 제시
Cypher 쿼리가 잘못 생성되면 자동으로 재시도하는 기능을 구현했다:
문제점:
13개 노드 타입 × 22개 관계 타입 = 복잡한 조합의 문제
LLM에게 이 복잡한 스키마를 이해시키고 올바른 Cypher 쿼리를 생성하게 하는 것은 거의 불가능했다.
실제로 생성된 쿼리들을 보면:
잘못된 예시 1: 한국어 직접 사용
MATCH (d:Disease {name: '황반변성'})-[:HAS_SYMPTOM]->(s)
문제: UMLS는 영어만 있고, HAS_SYMPTOM이 아니라 CAUSES_SYMPTOM이 맞다.
잘못된 예시 2: Cartesian Product
MATCH (d:Disease), (s:Symptom)
WHERE d.name CONTAINS 'diabetic' AND s.name CONTAINS 'vision'
RETURN d, s
LIMIT 10
문제: Cartesian Product로 인한 성능 문제:
MATCH (d:Disease)-[:CAUSES_SYMPTOM]->(s:Symptom) 처럼 관계 명시이 문제를 해결하기 위해 113개의 의료 질문-쿼리 템플릿을 만들었다. 각 템플릿은:
여기에 BAAI/bge-reranker-base 리랭커까지 도입해서 가장 적합한 템플릿을 선택하도록 했다.
어느 정도 개선되었지만, 여전히 잘못된 쿼리를 생성하는 경우가 있다.
추가적인 cypher 생성 rule 은 cypher 생성 프롬프트에 더 구체적으로 추가했다.
사용자는 다국어로 질문하고, UMLS는 영어 표준 용어만 있다. 이 간극을 메우는 게 생각보다 훨씬 어려웠다.
같은 의미의 질문도 다양하게 표현된다:
LLM에게 이걸 UMLS 표준 용어로 변환하라고 하면 Temperature를 0으로 설정해도 종종 다른 결과가 나왔다.
이 문제를 해결하기 위해 벡터 검색을 추가했다:
문제는 백만 개가 넘는 노드를 임베딩해야 한다는 것이었다. intfloat/multilingual-e5-base 모델을 로컬 CPU에서 실행했다.
Neo4j 노드 임베딩과 함께, 의료 문서들도 Milvus에 저장해야 했다. 의료 문서는 일반 문서와 다른 특성이 있다:
어떤 청킹 전략이 가장 효과적일지 4가지를 실험했다:
RAGAS(Retrieval Augmented Generation Assessment)로 평가한 결과:
| 전략 | RAGAS 점수 | 특징 |
|---|---|---|
| TablePreservingChunker | 0.8571 | 테이블 보호 + 안정성 |
| MedicalSemanticChunker | 0.8786 | 의료 용어 특화 |
| Simple Recursive | 0.7981 | 기본 방식 |
MedicalSemanticChunker가 더 높은 점수를 보였지만, 실제 운영 환경에서는 TablePreservingChunker를 선택했다:
선택 이유:
실제로 점수 차이보다 운영 환경에서의 안정성과 예측 가능성이 더 중요했다.
로컬 임베딩 실행 환경:
모델: intfloat/multilingual-e5-base (768차원)
장비: M4 MacBook Pro (24GB)
배치 크기: 50개
워커 수: 15개 (ThreadPoolExecutor)
처리 시간: 약 1시간
임베딩 데이터 구조:
Neo4j 노드를 단순히 이름만 임베딩한 것이 아니라, 관계 정보를 포함한 JSON 구조로 변환했다. 각 노드는 최대 20개의 관계(들어오는 10개, 나가는 10개)를 포함하여 평균 250토큰 정도의 크기를 가졌다.
OpenAI API 비용 분석:
노드 수: 1,324,567개
평균 토큰: 250 per node (관계 정보 포함)
총 토큰: 331,141,750
text-embedding-3-large 비용:
- 단가: $0.13 per 1M tokens
- 총 비용: 약 $43.05
문제점:
- Neo4j 데이터 업데이트 시마다 재임베딩 필요
- 업데이트 할 노드의 연관관계에 있는 노드들도 함께 재임베딩 필요 (복잡도 증가)
위 사항들을 고려해 우선 로컬 임베딩을 선택했다. M4 MacBook Pro에서 1시간이면 완료되었다.
BAAI/bge-reranker-base를 로컬에서 실행했다. M4 MacBook Pro에서는 사용할 만한 성능이었지만, 순수 CPU 환경에서는 성능 문제가 예상되었다. GPU 서버를 두고 사용하기에는 비용, 운영 복잡성, 디바이스 호환성 등 고려할 사항이 너무 많았다.
완성된 시스템을 돌아보니 너무 복잡했다:
인프라 복잡성:
실제 활용도:
전체: 1.3M 노드, 4.2M 관계
실제 사용: 주요 안과 질환, 관련 약물
백내장, 녹내장, 황반변성 등 안과에서 다루는 핵심 데이터를 위주로 사용되었고, UMLS의 대부분은 조회되지 않았다.
Neo4j를 선택한 이유:
실제로 필요했던 것:
Neo4j가 준 것:
결론적으로, 복잡한 그래프 구조 대신 벡터 검색과 실시간 웹 검색의 조합이 더 실용적이었다. GraphRAG의 이론적 우아함에 매료되어, 실제 필요한 것이 무엇인지를 놓쳤다.
"의료 도메인은 복잡하므로 복잡한 솔루션이 필요하다"는 생각이 틀렸다. 복잡성은 복잡성을 낳을 뿐, 문제를 해결하지 못했다.
GraphRAG는 이론적으로 매력적이지만, 실제 구현에서는:
UMLS는 방대하지만 치명적인 단점이 있었다. 대부분의 노드에 description이 없다. 단순히 개념 이름과 관계만 있을 뿐, 실제 의료 정보가 부족했다.
결과적으로 LLM이 답변을 생성할 때 할루시네이션이 발생할 위험이 높았다. "이 약물이 이 질병을 치료한다"는 관계는 알지만, "어떻게, 왜, 언제" 치료하는지는 알 수 없었다.
청킹 전략 실험에서 배운 가장 중요한 교훈이다. TablePreservingChunker처럼 단순하지만 핵심을 지키는 접근이 더 효과적이었다.
# 최종 선택
config = ChunkingConfig(
chunk_size=1000,
chunk_overlap=200,
strategy="table_preserving_chunker",
splitter="sentence_transformer"
)
"대화 중 계속 추론하고 데이터를 검색한다"는 요구사항이 시스템을 복잡하게 만들었다.
실패를 돌아보며, "만약 GraphRAG를 성공적으로 도입하려면 어떻게 해야 했을까?"를 고민해봤다.
UMLS라는 거대한 데이터셋을 그대로 가져와 사용하려 했다. 이는 근본적인 실수였다:
"데이터가 많으면 좋겠지"라는 안일한 생각이 문제였다. 실제로는:
잘못된 방법: UMLS 다운로드 → 필터링 → Neo4j 임포트
올바른 방법: 의료 문서 분석 → 핵심 개념 추출 → 수동 검증 → 그래프 구축
구체적 실행 방안:
신뢰할 수 있는 소스 선정
핵심 노드와 관계 추출
위 갯수는 단순 예시이며, 준비된 문서의 양에 다라 달라질 수 있다.
최근에는 문서에서 지식 그래프를 자동으로 구축하는 다양한 도구들이 등장했다. 이런 도구들을 활용하면:
핵심 기능:
중요한 것은 도구가 아니라 프로세스:
| 측면 | 우리의 접근 (실패) | 올바른 접근 |
|---|---|---|
| 데이터 소스 | UMLS 전체 다운로드 | 검증된 의료 문서에서 추출 |
| 규모 | 1.3M 노드 (대부분 미사용) | 소수 노드 |
| 품질 | 소수 노드에만 Description 존재 | 풍부한 설명, 근거 포함 |
| 구축 방법 | 일괄 임포트 | 점진적 구축 + 지속적 검증 |
| 도구 활용 | 수동 변환 스크립트 | 전문 그래프 구축 도구 활용 |
| 검증 | 사후 검증 (이미 늦음) | 구축 단계별 도메인 전문가 검증 |
GraphRAG가 실패한 것은 기술의 문제가 아니라 접근 방법의 문제였다:
만약 처음부터 이렇게 접근했다면, GraphRAG의 장점(구조화된 지식, 명확한 관계, 추론 가능)을 살리면서도 운영 가능한 시스템을 만들 수 있었을 것이다.
위의 교훈과 올바른 접근법을 고민한 끝에, 우리는 다른 길을 선택했다.
GraphRAG를 제대로 구축하는 것도 방법이지만, 우리의 상황에서는 React Agent가 더 실용적이었다.
실제 운영 경험에서 깨달은 중요한 사실이 있다:
이 깨달음이 중요한 이유는, 복잡한 그래프 인프라가 사실상 ReAct 패턴으로 대체 가능하다는 것이다.
GraphRAG의 복잡성을 제거하고, ReAct Agent가 Milvus와 웹 검색을 활용해 스스로 판단하고 필요한 정보를 수집하는 방식으로 전환했다.
핵심 메시지:
"의료 AI는 복잡한 그래프가 아니라, 신뢰할 수 있는 답변을 원한다"
기술적 우아함에 매료되어 실용성을 놓쳤던 실수를 반복하지 않기 위해 이 글을 썼다. 때로는 한 발 물러서서 "정말 이 복잡한 시스템이 필요한가?"라고 묻는 용기가 필요하다.
다음 포스팅에서는 ReAct Agent 기반의 새로운 접근법과 실제 개선 결과를 공유할 예정이다.