Skip to main content

Command Palette

Search for a command to run...

법률 AI 검색 실험기 (7) — Graph RAG 도입기: Neo4j로 조문 간 의미 관계 구축

Updated
7 min read

법률 QA 시스템을 만들면서 벡터 검색의 근본적인 한계에 부딪혔다. 사용자가 "부당해고 당했는데 어떻게 하나요?"라고 물으면 근로기준법 28조(구제신청)는 잘 찾는데, 함께 알아야 하는 23조(해고제한)와 26조(해고예고)는 Top-50에도 들어오지 않았다. 벡터 유사도만으로는 풀 수 없는 문제였고, 그래프 기반 확장이 필요했다. 이 글은 Neo4j를 도입해 법률 조문 간 의미 관계 그래프를 구축하고, 실제로 놓친 조문을 회수하기까지의 과정을 정리한 것이다.


1. 벡터 검색의 한계를 넘어서

벡터 검색은 질문과 조문의 텍스트 유사도에 의존한다. 질문 표면에 키워드가 드러나는 조문은 잘 찾지만, 논리적으로 연결되어 있으면서 텍스트상 닮지 않은 조문은 놓친다.

구체적인 miss 패턴을 분석해 보니 5가지 유형으로 수렴했다.

관계 유형의미예시
절차 체인같은 절차의 단계들해고제한 - 해고예고 - 구제신청
계산 체인같은 계산의 구성요소양도소득 범위 - 공제 - 세율
전제조건A가 성립하려면 B가 필요갱신청구권 - 대항력
동일 원인의 다른 효과같은 원인에서 파생되는 별개 결과계약해제 - 원상회복 vs 손해배상
기간/제한권리의 존속 기간이나 제한 조건불법행위 - 소멸시효

복수 정답 11문항을 기준으로 벡터 검색이 놓치는 조문 14개를 추적했는데, 전부 이 5가지 관계 중 하나에 해당했다. 텍스트 유사도가 아니라 의미적 관계를 잡아야 하는 문제였다.

핵심 판단은 이것이었다. 그래프는 1차 검색기가 아니라 2차 확장기다. 벡터 검색으로 anchor 조문을 확보한 뒤, 그 anchor에서 그래프를 따라 숨은 보조 조문을 회수하는 구조가 맞다.


2. 왜 Neo4j인가

이미 MongoDB를 문서 저장소로 쓰고 있었기 때문에, 처음에는 MongoDB의 $graphLookup을 검토했다. 하지만 금방 한계가 드러났다.

$graphLookup은 단일 컬렉션 내 재귀 탐색만 가능하다. 우리가 필요한 건 조문 -> 개념 -> 조문이라는 크로스 노드 타입 탐색이었다. typed edge를 표현하려면 별도 컬렉션과 복잡한 $lookup 파이프라인 체인이 필요하고, 멀티홉 쿼리는 파이프라인 지옥이 된다.

Neo4j를 선택한 이유는 명확했다.

  • typed relationship이 1등 시민이다. 관계에 타입과 속성을 네이티브로 부여할 수 있다.
  • Cypher 쿼리 언어의 표현력. "이 조문이 속한 Concept의 다른 모든 조문을 가져와라"를 한 줄로 쓸 수 있다.
  • 멀티홉 탐색이 빠르다. 관계 수에 비례하는 O(관계 수) 탐색이라 별도 인덱스 없이도 충분하다.
  • 벡터 인덱스 내장. Neo4j 5.11 이후로 같은 DB 안에서 vector + graph 검색이 가능하다(당장 쓰지는 않았지만 확장성 확보).

역할 분리도 깔끔했다. MongoDB는 원본 문서 저장소, Qdrant는 벡터 검색, Neo4j는 의미 관계 그래프 전용. 각 DB가 잘하는 일을 맡기는 구조다.

참고로 GraphRAG 분야에서 Neo4j는 이미 사실상 표준 위치를 차지하고 있다. Neo4j가 공식으로 제공하는 GraphRAG Python 패키지도 있고, Qdrant와 Neo4j를 결합한 하이브리드 검색 패턴도 공식 문서에 소개되어 있다. 법률 도메인에서도 법률 문서에서 지식 그래프를 구축하는 접근이나 법률 규범의 계층적/시간적 구조를 Graph RAG로 다루는 연구가 활발하게 진행되고 있어서, 방향성에 대한 확신을 가질 수 있었다.

설치는 Docker로 간단하게 진행했다.

docker run -d --name neo4j \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/<your-password> \
  -e NEO4J_PLUGINS='["apoc"]' \
  -v neo4j-data:/data -v neo4j-logs:/logs \
  neo4j:5-community

APOC 플러그인을 함께 넣은 이유는 JSON 파싱이나 배치 처리에 유용하기 때문이다.


3. 그래프 스키마 설계: Article - Concept - Article

스키마 설계의 핵심 아이디어는 Concept이라는 중간 노드를 두는 것이다.

노드 타입

(:Article {id, lawId, lawName, articleNumber, title})
(:Concept {id, name, type, description, lawName})

Article은 개별 법률 조문이고, Concept은 "해고 절차", "종합소득세 계산", "임대차 보호" 같은 법률 개념이다. Concept의 type은 procedure, calculation, right, obligation, definition, penalty 중 하나를 갖는다.

엣지 타입

조문과 개념 사이의 관계:

(Article)-[:PART_OF {role}]->(Concept)

role에는 "요건", "기간", "효과", "세율", "공제", "정의", "절차", "예외" 등이 들어간다.

조문 간 직접 관계:

(Article)-[:NEXT_STEP]->(Article)         // 절차 순서
(Article)-[:REQUIRES]->(Article)          // 전제조건
(Article)-[:CALCULATION_INPUT]->(Article) // 계산 흐름
(Article)-[:LIMITS]->(Article)            // 기간/제한
(Article)-[:EXCEPTION_OF]->(Article)      // 예외

Concept이 핵심인 이유

벡터 검색이 28조(구제신청)를 찾으면, 이 구조에서는 다음과 같이 확장된다:

28조 --PART_OF--> "해고 절차" Concept <--PART_OF-- 23조(해고제한)
                                      <--PART_OF-- 26조(해고예고)

Concept 하나를 경유하는 1홉 탐색으로 같은 그룹의 모든 조문이 자동 연결된다. 조문 간 직접 관계만으로는 모든 쌍을 일일이 정의해야 하지만, Concept 노드를 두면 그룹 멤버십 하나로 N:N 연결이 만들어진다.


4. LLM 기반 관계 추출

관계를 만드는 방법으로 co-citation(판례에서 함께 인용된 조문 쌍)과 LLM 추출 두 가지를 검토했다.

co-citation은 이미 데이터가 있어서 바로 쓸 수 있다는 장점이 있었지만, 우리가 풀려는 문제와 맞지 않았다. miss 패턴의 본질은 "판례에서 같이 인용되었느냐"가 아니라 "논리적으로 같은 절차나 계산에 속하느냐"였다. 예를 들어 근로기준법 28조와 23조는 판례 공출현 빈도가 높지만, 28조와 26조(해고예고)는 상대적으로 낮다. 그런데 논리적으로 26조도 해고 절차의 핵심 구성요소다. 통계적 상관이 아니라 의미적 관계가 필요했다.

LLM 추출의 핵심 설계는 이렇다. 법률 전체 조문 목록(조번호 + 제목 + 본문)을 한번에 LLM에게 주고, Concept 그룹핑 + 각 조문의 role + 조문 간 직접 관계를 출력하게 한다. 조문을 하나씩 보는 게 아니라 전체 맥락을 주는 것이 정확한 관계 추출의 전제조건이다.

프롬프트 튜닝: 1차 실패와 2차 성공

1차 시도에서는 Concept이 36개 나왔다. 조문 42개 대비 거의 1:1이라 그룹핑의 의미가 없었고, miss 회수에 실패했다.

2차 시도에서 다음 규칙을 프롬프트에 추가했다:

  • "일반인의 관점에서 함께 알아야 하는 조문을 묶어라"
  • Concept 수를 조문수/10 ~ 조문수/5로 제한
  • "적극적으로 중복 배정하라" (하나의 조문이 여러 Concept에 소속 가능)
  • 조문 수가 150개를 초과하면 요약 모드(제목 + 첫 200자만 전달)

"일반인의 관점"이라는 지시가 특히 효과적이었다. 법률 전문가 관점에서는 조문 하나하나가 독립적 의미를 갖지만, 일반인 관점에서는 "해고당하면 알아야 할 것들"처럼 실용적 단위로 묶인다. 이 관점이 miss 회수에 정확히 맞았다.

대형 법률 처리

법률조문 수사용 모델비고
주택임대차보호법42gemini-2.5-flash-lite문제 없음
근로기준법145gemini-2.5-flash-lite문제 없음
국세기본법167gemini-2.5-flashlite 모델 JSON 깨짐
법인세법247gemini-2.5-flash요약 모드 + JSON 복구 필요
소득세법382gemini-2.5-flash요약 모드
민법1,307-편별 분할 필요

조문 수가 늘어나면서 두 가지 문제가 발생했다. 하나는 LLM의 JSON 출력이 깨지는 것이고, 다른 하나는 컨텍스트 윈도우 한계다. 167조 이상부터는 flash-lite에서 flash로 모델을 올렸고, 247조 이상부터는 조문 본문을 요약 모드로 전달했다. 민법(1,307조)은 편(채권편, 물권편 등)별로 분할해서 별도 처리했다.


5. 6개 법률 적재와 검증

적재 결과

법률조문ConceptMembershipRelations
주택임대차보호법4266039
근로기준법14519131163
국세기본법1672417598
법인세법24724202165
소득세법38245422119
합계983117971584

983개 조문에서 117개 Concept이 추출되었고, 971개의 멤버십(조문-Concept 연결)과 584개의 직접 관계가 만들어졌다.

이후 민법, 형법, 부가가치세법을 추가 적재하면서 정합성 수정도 함께 진행했다. Concept.id를 법률별 namespace로 변경하고, cross-law PART_OF를 제거하고, orphan 노드를 정리했다. 특히 대형 법률에서 fallback(그래프에 제대로 연결되지 못한 조문) 비율을 줄이는 작업이 중요했는데, 법인세법의 fallback을 27%에서 0%로, 부가가치세법을 17%에서 0%로 개선했다.

Miss 회수 검증

벡터 검색이 놓친 14개 조문에 대해 1홉 Concept 경유 탐색을 테스트한 결과:

  • 1홉 회수: 8/14 (57%)
  • 2홉 포함: 9/14 (64%)
  • 실패: 5/14

실패한 5건은 모두 소득세법의 "소득 - 공제 - 세율" 계산 체인이었다. 소득세법의 Concept이 45개로 너무 세분화되어 "이자소득"과 "세율"이 별개 Concept으로 분리된 것이 원인이었다. 이 부분은 프롬프트에 "소득 - 공제 - 세율은 같은 계산 체인"이라는 힌트를 추가하면 개선 가능하다.

성공한 케이스를 보면, 이 접근의 유효성이 분명했다. 예를 들어 "부당해고" 질문에서 벡터가 28조(구제신청)만 찾았을 때, "해고 및 고용 보장" Concept을 경유해 23조(해고제한)와 26조(해고예고)를 모두 회수했다.


6. 결과와 교훈

최종적으로 검색 파이프라인은 다음 구조로 확정되었다.

질문
-> 프리라이터 (질문 재작성)
-> 벡터 검색 / Neo4j 그래프 확장 (병렬)
-> 하이브리드 병합
-> multi_issue 질문일 때만 LLM rerank
-> 답변 생성

벤치마크 기준으로 K10에서 전체 정답 회수를 달성했고, 더 이상 임베딩 모델이나 provider 비교를 계속할 필요가 없는 수준이 되었다.

돌아보며 정리하는 교훈

그래프는 만능이 아니다. 모든 질문에 그래프 확장을 태우면 노이즈가 늘어난다. 질문 유형(절차형, 계산형, 권리형, 단순 조회형)에 따라 확장 정책을 달리하는 것이 중요하다.

Concept의 적정 수가 성패를 가른다. 1차 시도에서 조문과 1:1로 나온 Concept은 의미가 없었다. "일반인 관점의 실용적 그룹"이라는 프롬프트 지시가 적절한 추상화 수준을 만들어냈다.

LLM 추출은 co-citation보다 직접적이다. 법률은 이미 구조화된 텍스트이기 때문에 LLM이 전체 조문 목록만 보고도 논리적 그룹핑이 가능하다. 통계적 상관보다 의미적 관계가 필요한 도메인에서는 LLM 추출이 더 효과적이다.

대형 법률은 별도 전략이 필요하다. 조문 수가 150개를 넘으면 모델 등급을 올리고, 250개를 넘으면 요약 모드를 적용하고, 1,000개를 넘으면 편별 분할이 필요하다. 이 경계값을 미리 알았다면 시행착오를 줄일 수 있었을 것이다.

역할 분리가 깔끔한 시스템을 만든다. MongoDB(원본 저장), Qdrant(벡터 검색), Neo4j(의미 관계 확장)라는 세 DB의 역할이 명확하게 나뉘면서 각 구성 요소를 독립적으로 개선할 수 있게 되었다.

벡터 검색만으로 충분하지 않다는 걸 인정하고, 그래프라는 다른 축을 추가한 것이 이 프로젝트에서 가장 큰 전환점이었다.

More from this blog

법률 AI 검색 실험기 (10) — Query Prep 마무리: 무엇을 남기고 무엇을 버렸나

전처리 파이프라인을 "마무리"한다는 것 RAG 파이프라인에서 전처리(query preparation)는 사용자 질문과 검색 엔진 사이의 번역 계층이다. 질문을 그대로 벡터 검색에 넣는 것과, 질문을 구조화하고 어떤 소스를 열지 먼저 정하는 것은 검색 품질에서 체감할 수 있는 차이를 만든다. 이 프로젝트에서는 법률 QA를 다루고 있고, 검색 대상이 조문, 판례, 유권해석, 행정심판 등 8개 소스 레인에 걸쳐 있다. 그만큼 전처리 단계가 감당해야 ...

Apr 28, 20265 min read3

법률 AI 검색 실험기 (9) — Source Router: 8개 컬렉션을 지능적으로 라우팅하기

법률 QA 시스템을 만들면서 가장 먼저 부딪힌 현실이 있다. 우리가 다루는 법률 데이터는 하나의 벡터 DB에 넣고 검색하면 끝나는 구조가 아니라는 것이다. 법령 조문, 판례, 공식 법령해석, 부처 실무 해석, 행정심판 재결례, 헌재 결정, 위원회 결정, 지자체 조례까지 --- 성격이 완전히 다른 8개 컬렉션, 총 300만 건 이상의 문서가 Qdrant에 올라가 있다. 이 글에서는 사용자 질문 하나가 들어왔을 때 어떤 컬렉션을 열고, 어떤 컬렉션...

Apr 26, 20266 min read31

법률 AI 검색 실험기 (8) — 1차 아키텍처 확정: 실험에서 운영으로

실험이 끝나는 순간은 생각보다 조용하다. 극적인 성능 점프가 아니라, "더 이상 구조를 바꿔도 의미 있는 차이가 나지 않는다"는 판단이 쌓이면서 자연스럽게 온다. 법률 검색 서비스의 RAG 파이프라인을 약 한 달간 실험한 끝에, 나는 1차 아키텍처를 확정하고 운영 전환 준비에 들어갔다. 이 글은 그 과정에서 내린 결정들과 그 이유를 정리한 기록이다. 최종 아키텍처: 여섯 단계의 파이프라인 확정된 구조는 다음과 같다. 질문 -> PreRewri...

Apr 21, 20266 min read5

법률 AI 검색 실험기 (6) — 하이브리드 검색과 쿼리 분해 실험기

벡터 검색만으로 단답형 질문은 거의 100% recall을 달성했다. 그런데 "부당해고 구제절차와 관련 판례"처럼 정답이 여러 개인 복수정답 질문에서는 벡터 검색이 한계를 드러냈다. 정답 조문 중 일부만 Top-10에 들어오고, 나머지는 빠지는 문제가 반복됐다. 자연스럽게 다음 질문이 떠올랐다. 벡터만으로 안 되면 키워드를 섞으면 어떨까? 업계의 하이브리드 검색 흐름 하이브리드 검색은 RAG 파이프라인에서 이미 표준에 가까운 접근법이 됐다. D...

Apr 16, 20265 min read18
D

Dongjun's Blog

22 posts