코드베이스 지식 베이스 시리즈 (05): 벡터 검색 (Vector Retrieval) vs 지식 그래프 (Knowledge Graph) — 콜
요약
벡터 검색의 의미론적 한계를 극복하기 위해 콜 그래프(Call Graph) 기반의 지식 그래프를 활용한 검색 방식을 탐구합니다. 코드의 구조적 관계를 이용해 벡터 검색이 놓치는 함수를 찾아내는 실험 결과와 구현 방법을 다룹니다.
핵심 포인트
- 벡터 검색은 의미론적 간극이 큰 함수를 찾는 데 한계가 있음
- 콜 그래프를 활용한 지식 그래프는 코드의 구조적 관계를 제공함
- Python ast 모듈을 사용하여 가벼운 코드 지식 그래프 구축 가능
- 그래프 증강 검색은 특정 쿼리 해결에는 효과적이나 전체 성능에는 트레이드오프 존재
계속해서 반복되는 그 하나의 쿼리
이 시리즈를 따라오셨다면, 하나의 쿼리가 기억에 강하게 남아있을 것입니다 — Q8: process payment and create Stripe charge.
그것은 유령과 같습니다. 기사 03과 04를 괴롭혔죠. 매번 벡터 검색 (Vector Retrieval)은 이 쿼리에서 걸려 넘어졌습니다: Recall@5 = 0.50으로, 두 개의 관련 함수 중 단 하나만을 찾아냈습니다.
놓친 함수는 calculate_order_total이었습니다. 이 함수의 역할은 "아이템 가격 합산, 할인 적용, 세금 계산"입니다. 함수 본문은 sum, discount, tax로 가득 차 있으며, payment나 Stripe의 흔적은 전혀 없습니다. 벡터 공간 (Vector space)에서 이 함수는 "create Stripe charge"라는 쿼리와 거대한 심연에 의해 분리되어 있습니다. 그 어떤 임베딩 (Embedding) 전략이나 청킹 (Chunking) 전략으로도 그 의미론적 간극 (Semantic gap)을 메울 수 없었습니다.
기사 04의 마지막에서 저는 클리프행어(cliffhanger)를 남겼습니다. 의미론적 유사성 (Semantic similarity)이 실패한다면, 무기를 바꿔야 합니다 — 코드의 **구조적 관계 (Structural relationships)**를 사용하십시오. 의미론적으로 calculate_order_total과 create_payment_intent는 공통점이 없지만, 콜 그래프 (Call graph) 상에서는 이웃입니다. 둘 다 체크아웃 흐름인 process_checkout에 의해 호출되기 때문입니다. 이론적으로는 그 결정론적인 엣지 (Deterministic edge)가 놓친 함수를 다시 불러올 수 있어야 합니다.
그래서 이번 기사를 위해 저는 그래프 증강 검색 (Graph-augmented retrieval) 시스템을 구축하고 이를 Q8에 적용했습니다. 결과는 — Q8은 해결되었습니다. 하지만 전체 점수는 단 1인치도 움직이지 않았고, 오히려 Q1에서 비틀거렸습니다.
이 기사는 그 "하나를 얻고 하나를 잃는 교환"에 대한 이야기이며, 실제로 그래프를 어떻게 사용해야 하는지에 대해 드러나는 진실에 대한 이야기입니다.
지식 그래프 (Knowledge Graphs)는 그렇게 신비롭지 않다
"지식 그래프 (Knowledge graph)"라고 말하면 많은 사람이 Neo4j, 그래프 데이터베이스 (Graph databases), 정교한 온톨로지 모델링 (Ontology modeling)을 떠올립니다. 코드에 적용할 때는 실제로는 훨씬 가볍습니다.
코드 지식 그래프는 가장 기본적인 형태에서 **콜 그래프 (Call graph)**입니다: 노드 (Nodes)는 함수이고, 엣지 (Edges)는 "누가 누구를 호출하는가"입니다. process_checkout이 calculate_order_total을 호출하므로 CALLS 엣지를 그리고, 반대로 calculate_order_total은 process_checkout에 의해 호출되므로 이는 CALLED_BY 엣지가 됩니다.
Python의 ast 모듈을 사용하면 몇십 줄의 코드로 이 그래프를 구축할 수 있습니다:
def parse_call_graph(source: str) -> dict[str, list[str]]:
tree = ast.parse(source)
# 우선 모든 함수 이름을 수집합니다
...
아이디어는 간단합니다. 먼저 트리를 한 번 순회하여 모든 함수 이름을 수집하고(이를 통해 호출이 "내부" 호출인지 확인할 수 있습니다), 그 다음 함수 본문 내부의 각 ast.Call 노드를 조사합니다. 만약 피호출자(callee)의 이름이 함수 집합에 포함되어 있다면, 엣지(edge)를 기록합니다. CALLED_BY는 단순히 CALLS를 역전시킨 것입니다.
이 실험의 결제 모듈(payment module)에 대해 파싱된 콜 그래프(call graph)는 다음과 같습니다:
create_payment_intent CALLS → (none) CALLED_BY ← process_checkout
calculate_order_total CALLS → (none) CALLED_BY ← process_checkout
process_checkout CALLS → [calculate_order_total, create_payment_intent]
...
이 데이터셋에는 새로운 함수인 process_checkout가 있다는 점에 주목하세요. 이는 "주문 총액 계산 → Stripe 결제 의도(payment intent) 생성"을 하나의 완전한 비즈니스 흐름으로 엮어주는 "체크아웃 흐름(checkout flow)" 함수입니다. 이는 calculate_order_total과 create_payment_intent를 연결하는 중간 노드(intermediate node)입니다. 이 노드를 기억해 두세요. 다음에 이어질 내용의 주인공입니다.
[IMG:1]
그래프 증강 검색 (Graph-Augmented Retrieval): 시드(Seed) → 확장(Expand) → 재순위화(Re-rank)
이 그래프를 손에 넣으면, 검색(retrieval)은 "단판(one shot)" 방식에서 세 단계로 진화합니다.
1단계 (seed): 일반적인 벡터 검색(vector retrieval)을 실행하여 상위 3개를 "시드 노드(seed nodes)"로 가져옵니다. 이 단계는 이전 글들의 베이스라인(baseline)과 동일합니다. 즉, AST 함수 수준의 청킹(chunking)과 원시 코드 임베딩(raw-code embedding)을 사용합니다.
2단계 (expand): 시드로부터 콜 그래프를 따라 너비 우선 탐색(BFS)을 수행하여, 2홉(hop)만큼 외부로 확장합니다. 각 홉은 CALLS와 CALLED_BY를 모두 탐색하며, "내가 누구를 호출하는지"와 "누가 나를 호출하는지"를 모두 살펴봅.
3단계 (re-rank): 확장된 후보군 세트가 이제 더 커졌으므로, 벡터 점수 (vector score)를 기준으로 전체 세트를 재순위화 (re-rank)하고 상위 5개 (top-5)를 추출합니다.
코드는 다음과 같습니다:
def graph_retrieve(query_emb, indexed, call_graph, called_by, k=5, seed_k=3, hops=2):
# 1단계: 시드 (seeds)를 위한 벡터 검색 (vector retrieval)
scored = sorted(indexed, key=lambda x: cosine_sim(query_emb, x[1]), reverse=True)
...
설계 의도: 벡터 검색 (vector retrieval)은 "대략적으로 올바른 영역" (seeds)을 찾고, 콜 그래프 (call graph)는 "벡터가 놓친 이웃"을 끌어오며 (expansion), 마지막으로 벡터 점수 (vector score)가 품질 검사를 한 번 수행합니다 (re-rank). 매우 완벽해 보입니다.
동일한 데이터와 동일한 12개의 쿼리를 사용하여 일반적인 벡터 검색 (vector retrieval)과 비교 실험을 진행했습니다. 방식 A는 벡터 전용 (baseline)이며, 방식 B는 그래프 증강 (graph-augmented, seed_k=3, BFS 2 hops) 방식입니다.
결과: 동일한 전체 점수
먼저, 총합입니다:
방식 (Approach) R@3 R@5
─────────────────────────────────── ─────── ───────
A_vector_only 0.889 0.958
...
동일합니다. Recall@3 모두 0.889, Recall@5 모두 0.958입니다. 콜 그래프 (call graph)를 추가하고, BFS 확장을 실행하는 그 모든 노력에도 불구하고, 총점은 단 1점도 움직이지 않았습니다.
이 표만 본다면 다음과 같은 결론을 내리기 쉽습니다: "그래프 증강 (Graph augmentation)은 쓸모없으며, 노력 낭비다." — 하지만 그 결론은 틀렸으며, 흥미롭게도 그렇습니다. 왜냐하면 동일한 총점이 두 방법이 모든 쿼리에서 동일하게 동작한다는 것을 의미하지는 않기 때문입니다.
12개의 쿼리를 모두 분석한 결과, 10개는 두 방식 모두 동일(둘 다 1.00으로 완벽함)했지만, 2개의 쿼리에서 차이가 발생했습니다:
쿼리별 Recall@5 (차이가 발생한 사례):
쿼리 (Query) 벡터 (Vector) 그래프 (Graph)
────────────────────────────────────────────────── ─────── ───────
...
보이시나요? 이것은 "무승부"가 아니라, **일대일 교환 (one-for-one swap)**입니다.
- Q8 (Stripe 결제): 벡터 (vector) 0.50, 그래프 (graph) 1.00. 그래프 검색 (Graph retrieval)이 유령(ghost) 문제를 해결했습니다.
- Q1 (신원 확인 + JWT): 벡터 (vector) 1.00, 그래프 (graph) 0.50. 그래프 검색이 이미 완벽했던 쿼리를 망가뜨렸습니다.
두 개의 쿼리 중 하나는 올라가고 하나는 내려가며 정확히 상쇄됩니다. 따라서 총합은 변하지 않습니다. 하지만 그 이면에서 일어나고 있는 일은 총합보다 훨씬 더 흥미롭습니다.
[IMG:1] (one-for-one swap 다이어그램. 왼쪽, 벡터 전용 (Vector-only) 막대 그래프: Q1=1.00 (녹색), Q8=0.50 (적색); 오른쪽, 그래프 증강 (Graph-augmented) 막대 그래프: Q1=0.50 (적색), Q8=1.00 (녹색). 중앙에는 "하나를 고치면, 하나를 망가뜨린다 (fix one, break one)"라고 표시된 양방향 화살표가 있으며, 아래에는 "총합 불변: R@5=0.958"이라고 적혀 있음.)
Q8: 그래프 검색 (Graph Retrieval)이 문제를 해결한 방법
먼저 좋은 소식부터 전하겠습니다. Q8은 계속해서 놓치던 유령(ghost)이었는데, 그래프 검색 (graph retrieval)이 마침내 이를 잡아냈습니다. 저는 확장 과정을 추적해 보았습니다:
Seeds (벡터 상위 3개): ['process_checkout', 'process_refund', 'create_payment_intent']
process_checkout CALLS→['calculate_order_total', 'create_payment_intent']
create_payment_intent CALLED_BY←['process_checkout']
...
이 줄들을 자세히 살펴보십시오. 벡터 (Vector)의 상위 3개 시드 (seeds)에는 "결제 처리 (process payment)"와 의미론적으로 유사한 체크아웃 흐름 함수인 process_checkout이 포함되어 있습니다. 그다음 너비 우선 탐색 (BFS)이 CALLS 엣지 (edge)를 따라 내려가면서 process_checkout이 calculate_order_total을 호출한다는 사실을 발견합니다. 이 단 한 번의 홉 (hop)이 의미론적 간극 (semantic gap) 밖에 고립되어 있던 고아 함수 (orphan function)를 후보 집합 (candidate set) 안으로 끌어들입니다.
두 방식의 상위 5개 (top-5)를 비교해 보겠습니다:
- **벡터 상위 5개 (Vector top-5)**의 4번과 5번 슬롯은
get_payment_history와verify_webhook_signature입니다. 이들은 겉보기에는 "결제 (payment)" 및 "Stripe"와 관련 있어 보여서 벡터 점수 (vector scores)가 낮지는 않지만, 이 쿼리에는 쓸모가 없습니다. 이들은 정답 (ground truth)이 아닙니다. - **그래프 상위 5개 (Graph top-5)**는
calculate_order_total이 저 "의미론적으로는 관련 있지만 쓸모없는" 두 함수를 밀어내고 정답을 맞혔습니다.
이것이 바로 콜 그래프 (Call Graph)의 가치입니다: process_checkout이 calculate_order_total을 호출한다는 것은 두 함수가 비즈니스 수준에서 협업하고 있음을 의미합니다. 벡터 공간 (Vector space)은 이러한 협업을 볼 수 없지만 (어휘가 겹치지 않기 때문에), 콜 그래프는 이를 명확하게 보여줍니다. 의미론적으로는 멀지만 구조적으로 가까운 함수는 이러한 결정론적인 엣지 (edge)에 의해 다시 끌어올려집니다.
여기서 이야기는 "그래프 검색 (graph retrieval)이 벡터의 의미론적 간극을 메웠다"라며 완벽하게 끝날 수도 있었습니다. 하지만 Q1은 이에 동의하지 않습니다.
Q1: 그래프 검색의 대가
이제 나쁜 소식입니다. Q1은 사용자 신원 확인 및 JWT 토큰 유효성 검사 (verify user identity and check JWT token validity)이며, 정답 (ground truth)은 두 함수인 validate_jwt_token과 verify_password입니다.
단순 벡터 검색 (Plain vector retrieval)은 여기서 1.00이라는 완벽한 점수를 기록했습니다. 두 함수 모두 확실하게 top-5 안에 배치했기 때문입니다. 하지만 그래프 증강 검색 (graph-augmented retrieval)은 0.50점에 그쳤고, verify_password를 놓쳤습니다.
원래 정답이었던 쿼리가 그래프가 추가되자 틀리게 되었습니다. 어떻게 된 일일까요?
문제는 시드 (seeds)와 확장 (expansion) 사이의 연쇄 반응입니다. 벡터의 순위 산정 (ranking)에서 validate_jwt_token은 높은 순위를 차지하며 (top-3 시드에 포함됨), verify_password는 그보다 약간 낮은 4위 또는 5위 정도에 위치합니다. 단순 벡터의 top-5에서 4위와 5위는 정확히 이 함수가 안착한 자리였으므로 정답을 맞힐 수 있었습니다.
하지만 그래프 증강은 "확장 (expand)" 단계를 추가합니다. 너비 우선 탐색 (BFS)이 시드에서 시작하여 시드가 호출하는 모든 함수와 시드를 호출하는 모든 함수를 후보 풀 (candidate pool)에 집어넣습니다. 풀이 갑자기 커집니다. 그 후 재순위 산정 (re-ranking) 과정에서 이렇게 새로 확장된 함수들도 순위 경쟁에 참여하게 되는데, 이들 중 일부가 우연히 verify_password보다 더 높은 벡터 점수를 갖게 되어, 4-5위 자리를 굳건히 지키고 있던 verify_password를 top-5 밖으로 밀어내 버립니다 (crowding it out).
한 문장으로 요약하자면: 그래프 확장 (graph expansion)이 후보 집합을 팽창시켰고, 재순위 산정 (re-rank) 과정에서 "구조적으로는 관련이 있지만 쿼리와는 무관한" 새로운 유입물들이 순위를 희석시켜, 원래라면 정답이었을 함수를 밀어낸 것입니다.
[Image: Q1 crowd-out diagram. Left column, Vector top-5: validate_jwt_token at slot 1 (green), verify_password at slot 5 (green, barely on the list); right column, Graph top-5: two gray "structurally-related but query-irrelevant" functions expanded in at slots 4 and 5, pushing verify_password to slot 6 (red, off the list).]
이것이 바로 순진한 그래프 확장(naive graph expansion)의 대가입니다. 공짜 점심은 없습니다. 의미론적 간극(semantic gap) 바깥에서 회상되는 모든 실제 관련 함수는 벡터 순위 지정(vector ranking)만으로도 도달했을 함수를 밀어낼 위험을 수반합니다.
양날의 검: 후보 집합 확장에는 대가가 따른다
Q8과 Q1을 나란히 놓고 보면 그래프 증강 검색(graph-augmented retrieval)의 본질이 명확해집니다. 그것은 양날의 검입니다.
날카로운 면 (Q8): 의미론적으로는 거리가 멀지만 구조적으로 가까운 함수를 찾아냅니다. calculate_order_total은
이것은 왜 수많은 팀이 GraphRAG를 열정적으로 도입하고, 검색(retrieval) 과정에 콜 그래프(call-graph) 순회(traversal)를 추가한 뒤, A/B 테스트 지표가 정체되거나 심지어 약간 하락하는 것을 목격하는지에 대해서도 설명해 줍니다. 단순한 그래프 순회 결합(naive graph traversal bolt-ons)은 이득과 노이즈를 거의 동일한 크기로 가져오는 경향이 있습니다.
그렇다면 실제로 그래프를 어떻게 사용해야 하는가?
결론은 "그래프는 쓸모없다"가 아닙니다. 오히려 그 반대입니다. Q8은 콜 그래프가 담고 있는 구조적 정보가 벡터 검색(vector retrieval)이 도달할 수 없는 진실임을 증명합니다. 문제는 _사용 방식(usage)_에 있습니다. 검색 시점에 단순히 너비 우선 탐색(BFS) 확장을 수행하고 재순위화(re-ranking)를 하는 것은 그래프를 사용하는 가장 거친 방식입니다.
먼저, 단순한 그래프 확장(naive graph expansion)이 도움이 되는 경우와 해가 되는 경우를 명확히 해봅시다.
그래프 확장이 도움이 되는 경우:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기