코드베이스 지식 베이스 시리즈 (06): 콜 그래프 (Call Graph)를 임베딩 (Embedding)으로 인코딩하기 — 구조적 증강
요약
코드베이스의 구조적 정보인 콜 그래프(Call Graph)를 임베딩 시점에 인코딩하여 검색 성능을 높이는 실험적 접근법을 다룹니다. 함수 호출 관계를 주석이나 독스트링에 포함하는 세 가지 전략을 비교하며, 구조적 정보 추가가 유사도 향상에는 도움이 되지만 완벽한 해결책은 아님을 보여줍니다.
핵심 포인트
- 검색 시점이 아닌 인덱싱 시점에 구조적 정보를 임베딩에 포함하는 전략 제안
- 함수 호출 관계를 주석(Strategy B)이나 독스트링(Strategy C)에 삽입하여 실험
- 구조적 정보 추가가 벡터 유사도를 높이는 데 효과가 있음을 확인
- 단순한 텍스트 추가만으로는 복잡한 그래프 구조 문제를 완전히 해결하기 어려움
약속을 이행하며
05번 기사의 마지막에서, 저는 명확한 숙제를 남겼습니다. 단순한 그래프 확장 (naive graph expansion)은 양날의 검과 같기 때문에 (Q8은 해결했지만 Q1을 망가뜨렸으므로), 접근 방식을 바꾸기로 합시다 — 검색 시점 (retrieval time)에 그래프를 순회하지 말고, 대신 인덱싱 시점 (index time)에 구조적 정보 (structural information)를 임베딩 (embedding) 콘텐츠에 인코딩하세요.
해당 기사에는 코드 스케치가 포함되어 있었습니다: calculate_order_total의 청크 (chunk)에 # called_by: process_checkout라는 줄을 추가하여, 해당 벡터 자체가 "나는 checkout 흐름에 의해 호출된다"라는 신호를 전달하게 만드는 것입니다. 이론적으로, "process payment"라는 쿼리 (query)가 process_checkout 관련 토큰 (tokens)에 부딪히면, calculate_order_total의 벡터 자체가 감지될 수 있습니다 — 후보군 팽창 (candidate-set inflation)이 없으므로, Q1 스타일의 군집 노이즈 (crowd-out noise)도 발생하지 않습니다.
듣기에는 아주 좋습니다. 이 기사는 그 가설을 실험의 용광로 속에 던져 넣는 단계입니다.
저는 동일한 결제 모듈 코드를 임베딩하는 세 가지 전략을 준비했습니다:
- 전략 A (raw): 아무것도 추가하지 않은 원시 코드. 이것이 베이스라인 (baseline)입니다.
- 전략 B (struct prefix): 함수 본문 앞에
called_by와calls를 명시하는 주석 줄을 추가합니다. - 전략 C (struct doc): 별도의 줄 없이, 구조적 정보를 함수의 기존 독스트링 (docstring) 안에 통합합니다.
그런 다음, Q8이 해결되는지 확인하기 위해 세 가지 임베딩 모두를 동일한 12개의 쿼리에 대해 실행했습니다.
긴장감을 조성하려는 것이 아니니 결론을 미리 말씀드리겠습니다: 구조적 정보는 효과가 있었습니다 — calculate_order_total의 유사도 (similarity)가 실제로 상승했습니다. 하지만 충분하지 않았고, Q8은 여전히 실패했습니다. 게다가 전략 B는 이전에 완벽했던 다른 쿼리마저 망가뜨렸습니다.
이것은 "모든 노력이 헛수고였다"라는 낙담스러운 이야기가 아닙니다. 오히려 그 반대입니다 — 이 실패는 임베딩의 경계가 어디인지를 정확하게 측정해 주었으며, 그 수치 자체가 "해결됨"이라는 결과보다 더 가치 있습니다.
세 가지 전략의 모습
먼저, 전략에 따라 임베딩에 입력되는 텍스트가 어떻게 다른지 직관적으로 살펴보겠습니다. 반복해서 놓쳤던 주인공인 calculate_order_total을 예로 들어보겠습니다:
Strategy A (raw):
def calculate_order_total(items: list[dict], discount_code: str = None) -> dict:
"""Sum item prices, apply discount, compute tax. Returns breakdown dict."""
...
그것이 차이점의 전부입니다. B는 맨 윗부분에 # called_by: process_checkout을 밀어 넣고, C는 독스트링 (docstring) 끝에 Called by: process_checkout.을 덧붙입니다. 핵심 의도는 동일합니다. 두 방식 모두 process_checkout 토큰이 calculate_order_total의 임베딩 (embedding) 텍스트에 나타나게 하여, 벡터 공간 (vector space)에서 결제 흐름 (payment flow)에 조금 더 가깝게 이동시키려는 것입니다.
차이점은 바로 _위치_입니다. B는 함수 본문 외부의 가장 눈에 띄는 첫 번째 줄에 배치하고, C는 자연어 설명 속에 섞여 독스트링 (docstring) 안에 숨깁니다. 이 "위치"의 차이는 나중에 예상치 못한 부작용을 초래합니다.
B와 C 모두 구현하기는 간단합니다:
# Strategy B: struct prefix
def strategy_b_struct_prefix(func, call_graph, called_by):
callers = called_by.get(func["name"], [])
...
B는 calculate_order_total뿐만 아니라 호출 관계가 있는 모든 함수에 접두사 (prefix)를 추가한다는 점에 주목하십시오. 이 세부 사항이 나중에 Q7에서 발생하는 충돌의 씨앗이 됩니다.
[IMG:1]
주요 비교: 하나는 안정적이고, 하나는 퇴보하며, 둘 다 Q8을 해결하지는 못함
12개의 쿼리 (query)에 대해 세 가지 임베딩 (embedding)을 모두 실행한 결과, 총계는 다음과 같습니다:
Strategy R@3 R@5 vs A
───────────────────────── ─────── ─────── ───────
A_raw_code 0.889 0.958 base
...
아직 Q8을 찾으려고 서두르지 마세요. 가장 먼저 눈에 띄는 점은 Strategy B가 실제로 퇴보(regressed)했다는 것입니다. Recall@5가 0.958에서 0.931로 떨어졌습니다. 구조적 정보 (structural info)를 추가했음에도 불구하고, 전체 수치가 올라간 것이 아니라 오히려 내려갔습니다. 반면 Strategy C는 베이스라인 (baseline) 수준을 유지하며 단 1포인트도 변하지 않았습니다.
이제 진실이 드러나는 쿼리별 Recall@5 (per-query Recall@5)를 살펴보겠습니다:
Query A B C
────────────────────────────────────────────────── ────── ────── ──────
verify user identity and check JWT token validity 1.00 1.00 1.00
...
마지막 두 개의 화살표를 유심히 살펴보세요. 모든 이야기가 그 안에 담겨 있습니다:
- Q8 (
process payment and create Stripe charge): A, B, C 모두 0.50입니다. 우리는 임베딩 (embedding)에 구조적 정보를 인코딩하기 위해 엄청난 노력을 기울였지만, Q8은 변하지 않았습니다. 여전히 두 개의 관련 함수 중 하나만 맞출 뿐입니다. 가설이 거짓으로 판명되었습니다 (The hypothesis is falsified). - Q7 (
execute SQL query safely against the database): A와 C는 모두 완벽한 1.00을 기록했지만, Strategy B는 0.67로 떨어졌습니다. 이것이 B의 전체 점수를 깎아먹은 주범입니다.
해결하고자 했던 쿼리 하나는 여전히 고장 난 상태로 남았고, 괜찮았던 쿼리 하나는 B에 의해 망가졌습니다. 이 양상은 익숙하게 들립니다. Article 05의 그래프 확장 (graph expansion) 또한
이는 좋은 소식이며, 방향은 정확히 옳습니다. 구조적 정보 (Structural info)는 쓸모없는 것이 아닙니다. 그것은 실제로 calculate_order_total을 쿼리에 더 가깝게 끌어당겼습니다. "구조적 인코딩 (structural encoding)이 관련 함수의 벡터 유사도 (vector similarity)를 높인다"라는 Article 05의 가설이 수치로 확인되었습니다. 우리는 시간을 낭비하지 않았습니다.
나쁜 소식은 세 번째와 네 번째 줄에 있습니다. 동일한 데이터에서 create_payment_intent는 0.5970에, process_checkout은 0.6341에 위치합니다. 둘 다 자연스럽게 calculate_order_total보다 상당히 높습니다. 왜일까요? 그들의 코드에는 payment, Stripe, charge와 같이 쿼리와 문자 그대로 일치하는 단어들이 명백하게 명시되어 있기 때문입니다.
이제 계산을 해봅시다: calculate_order_total은 0.4617에서 0.5137로 0.052 상승했습니다. 하지만 그 앞에 서 있는 무리들 — 단지 create_payment_intent와 process_checkout뿐만 아니라, process_refund, get_payment_history, verify_webhook_signature 등 결제 어휘 (payment vocabulary)를 포함하는 모든 함수들 — 이들 모두가 0.51 위에 있습니다. 최종 결과가 어디에 위치하는지 보십시오:
Q8 top-5 hits:
A_raw_code: ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
B_struct_prefix: ['create_payment_intent', 'process_checkout', 'process_refund', 'get_payment_history', 'verify_webhook_signature']
...
세 가지 전략 모두에서, top-5의 4위와 5위는 항상 get_payment_history와 verify_webhook_signature입니다. 이들은 정답 (ground truth)은 아니지만 결제 어휘를 담고 있는 사칭꾼 (impostors)들입니다. 그리고 우리가 실제로 원하는 calculate_order_total은 이 다섯 가지 "결제 어휘가 풍부한" 함수들에 의해 6위 너머로 단단히 밀려나 있습니다.
0.052의 상승은 상당해 보이지만, 그것만으로는 충분하지 않습니다. calculate_order_total을 0.46에서 0.51로 밀어 올렸지만, 그 벽을 넘지는 못했습니다. 그 벽은 0.51에서 시작되며 문자 그대로의 어휘 (literal vocabulary)에 의해 지탱되고 있습니다. 매우 가깝지만 영원히 건널 수 없습니다. 왜냐하면 그 앞에는 "이름에 payment가 포함된" 함수들이 한 줄로 서 있기 때문입니다.
[IMG:Q8] 코사인 유사도(cosine similarity)의 수직축. calculate_order_total(raw)이 0.46에 위치하고, "+0.052 struct"라고 표시된 위쪽 화살표가 calculate_order_total(struct)의 0.51로 향함. 그 바로 위에는 create_payment_intent / process_refund / get_payment_history / verify_webhook_signature / process_checkout라고 표시된 0.51-0.63 사이의 막대들이 벽처럼 모여 있음. calculate_order_total은 여전히 그 벽 바로 아래에 위치하며 "still below top-5"라고 태그되어 있음.
근본 원인: 이것은 랭킹 버그가 아니라 의미론적 격차(Semantic Gap)입니다
이 시점에서 많은 이들이 다음과 같이 생각할 것입니다. "구조적 정보(structural info)를 더 공격적으로 추가하면 어떨까? 주석(comment)을 몇 줄 더 넣거나, process_checkout을 여러 번 반복하거나, 아니면 전체 호출 체인(call chain)을 통째로 이어 붙여보면 어떨까?"
멈추십시오. 그 방향은 막다른 길이며, 왜 그런지 설명할 가치가 있습니다.
calculate_order_total의 의미론(semantics)은 "가격 합산, 할인 적용, 세금 계산"입니다. 이것이 해당 코드의 **진정한 의미(true meaning)**이며, 임베딩 모델(embedding model)은 이를 "주문 계산(order-computation)" 벡터로 충실히 인코딩합니다. 반면 "Stripe 결제 생성(create Stripe charge)"은 "제3자 결제 게이트웨이를 호출하여 결제를 생성함"을 의미합니다. 현실 세계에서 이 둘은 서로 다른 것입니다. 하나는 지불해야 할 금액을 계산하는 것이고, 다른 하나는 그 금액을 수납하는 것입니다. 이들의 의미론적 거리(semantic distance)는 임베딩 모델의 지식에 의해 결정되는 것이지, 우리의 주석 트릭으로 메울 수 있는 것이 아닙니다.
우리가 추가한 # called_by: process_checkout라는 줄은 본질적으로 "세금 계산 코드"를 위한 벡터에 "체크아웃 흐름(checkout flow)"이라는 아주 약간의 풍미를 섞는 것에 불과합니다. 이로 인해 벡터가 결제 쪽으로 약간 기울어지긴 했지만(+0.052), 그 약간의 풍미는 코드 본체가 가진 "나는 세금을 계산하고 있다"라는 강력한 신호를 압도할 수 없습니다. 모델이 이 코드를 읽을 때, 그 실체는 여전히 sum(합산), discount(할인), tax(세금)입니다. 주석 한 줄로는 그 근본적인 토대를 바꿀 수 없습니다.
다르게 말하자면, 구조적 주입 (structural injection)은 벡터의 미세 조정 (fine-tune)일 뿐, 방향 전환 (redirect)이 아닙니다. 이는 "비슷하지만 완벽하지 않은" 함수를 임계점 너머로 밀어 올릴 수는 있지만, 쿼리와 완전히 다른 비즈니스 개념으로 분리된 calculate_order_total과 같은 함수에게 +0.052라는 수치는 조족지혈 (a drop in the bucket)에 불과합니다.
이것이 바로 **의미론적 격차 (semantic gap)**의 본질입니다. 이는 랭킹 알고리즘 (ranking algorithm)의 버그도 아니고, 잘못된 청킹 (chunking)도 아니며, 임베딩 차원 (embedding dimensions)의 부족함도 아닙니다. 그것은 "이 코드의 진정한 의미"와 "쿼리가 표현하는 의도" 사이의 객관적인 거리입니다. 청킹 전략을 바꾸거나, 구조적 접두사 (structural prefixes)를 추가하거나, 독스트링 (docstrings)을 채워 넣는 등 임베딩 **콘텐츠 (content)**에 적용되는 그 어떤 트릭으로도 이 거리를 근본적으로 극복할 수는 없습니다.
05번 기사를 다시 살펴보십시오. 그래프 확장 (graph expansion)이 Q8을 정확히 해결했던 이유는 그것이 임베딩 경로 (embedding path)를 거치지 않았기 때문입니다. 그것은 결정론적인 호출 엣지 (call edge)인 process_checkout → calculate_order_total에 의존하여, 의미론적 유사성 (semantic similarity)의 판단을 우회하고 해당 함수를 후보 집합 (candidate set)으로 직접 끌어왔습니다. 이 기사에서 다루는 세 가지 전략은 모두 여전히 임베딩 유사성 프레임워크 내에서 작동하므로, 당연히 모두 동일한 벽에 부딪히게 됩니다.
Q7: 전략 B의 실패는 또 다른 경고입니다
이제 쿼리 전략 B가 실패한 Q7을 보겠습니다: execute SQL query safely against the database (데이터베이스에 대해 SQL 쿼리를 안전하게 실행). 이 문제의 정답 (ground truth)은 세 개의 함수인 execute_query, bulk_insert, paginate_query입니다.
A와 C는 모두 세 개를 깔끔하게 모두 맞혔지만 (1.00), B는 하나를 놓치며 0.67로 떨어졌습니다.
왜 B만 그랬을까요? 전략 B의 구현 방식을 떠올려 보십시오. 전략 B는 호출 관계가 있는 모든 함수의 맨 윗부분에 구조적 접두사 (structural prefix)를 추가했습니다. execute_query는 전형적인 유틸리티 함수로, 데이터베이스 모듈 내의 수많은 함수에 의해 호출됩니다. bulk_insert가 호출하고, paginate_query가 호출하며, 심지어 결제 모듈의 get_payment_history도 이를 호출합니다. 따라서 전략 B에 의해 이 모든 호출자(caller)들의 임베딩 텍스트 상단에는 # calls: execute_query라는 문구가 삽입되었습니다.
여기에 문제가 있습니다. # calls: execute_query라는 그 한 줄에는 execute와 query라는 단어가 포함되어 있는데, 이는 우연히도 Q7의 질문인 "execute SQL query"와 매우 많이 겹칩니다. 그 결과, Q7과 실제로 그렇게 관련이 깊지 않았던 함수 뭉치들이 접두사(prefix)에 "execute query"를 포함하게 되면서 벡터 점수(vector scores)가 인위적으로 부풀려졌고, 이로 인해 랭킹이 뒤섞여 진정으로 관련 있는 정답(ground-truth) 함수가 상위 5개(top-5)에서 밀려나게 되었습니다.
이는 Article 05의 허브 노드(hub-node) 교훈을 상기시킵니다: execute_query는 바로 그 높은 외차수(high-out-degree)를 가진 유틸리티 함수이며, 일단 그 이름이 모든 호출자(caller)의 임베딩 텍스트 상단에 무분별하게 출력되면, 수많은 쿼리의 랭킹을 오염시킵니다. Article 05가 "허브 노드를 따라 확장하는 것은 후보 집합을 폭발시킨다"였다면, 이번 사례는 "모든 호출자의 임베딩에 허브 노드의 이름을 채워 넣는 것은 문자 그대로 노이즈를 폭발시킨다"입니다. 메커니즘은 다르지만, 동일한 코드 스멜(code smell)입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기