법률 AI 검색 실험기 (7) — Graph RAG 도입기: Neo4j로 조문 간 의미 관계 구축
법률 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 회수에 정확히 맞았다.
대형 법률 처리
| 법률 | 조문 수 | 사용 모델 | 비고 |
| 주택임대차보호법 | 42 | gemini-2.5-flash-lite | 문제 없음 |
| 근로기준법 | 145 | gemini-2.5-flash-lite | 문제 없음 |
| 국세기본법 | 167 | gemini-2.5-flash | lite 모델 JSON 깨짐 |
| 법인세법 | 247 | gemini-2.5-flash | 요약 모드 + JSON 복구 필요 |
| 소득세법 | 382 | gemini-2.5-flash | 요약 모드 |
| 민법 | 1,307 | - | 편별 분할 필요 |
조문 수가 늘어나면서 두 가지 문제가 발생했다. 하나는 LLM의 JSON 출력이 깨지는 것이고, 다른 하나는 컨텍스트 윈도우 한계다. 167조 이상부터는 flash-lite에서 flash로 모델을 올렸고, 247조 이상부터는 조문 본문을 요약 모드로 전달했다. 민법(1,307조)은 편(채권편, 물권편 등)별로 분할해서 별도 처리했다.
5. 6개 법률 적재와 검증
적재 결과
| 법률 | 조문 | Concept | Membership | Relations |
| 주택임대차보호법 | 42 | 6 | 60 | 39 |
| 근로기준법 | 145 | 19 | 131 | 163 |
| 국세기본법 | 167 | 24 | 175 | 98 |
| 법인세법 | 247 | 24 | 202 | 165 |
| 소득세법 | 382 | 45 | 422 | 119 |
| 합계 | 983 | 117 | 971 | 584 |
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의 역할이 명확하게 나뉘면서 각 구성 요소를 독립적으로 개선할 수 있게 되었다.
벡터 검색만으로 충분하지 않다는 걸 인정하고, 그래프라는 다른 축을 추가한 것이 이 프로젝트에서 가장 큰 전환점이었다.

