지난 글에서 언어 모델이 텍스트를 생성하는 원리를 살펴봤다. 이번에는 생성된 텍스트를 이해하는 쪽의 과제인 지시 해소를 다룬다. 「김철수가 회사에서 그의 동료를 만났다. 그는 매우 기뻤다」에서 「그의」와 「그」가 둘 다 김철수를 가리킨다는 것을 알아내는 일이다. 사람에게는 읽는 순간 끝나는 일이라 문제로 보이지 않지만, 기계에게는 후보가 수만 개인 탐색 문제이고 정답 하나를 놓치면 문서 전체의 해석이 어긋나는 연쇄 구조를 갖는다. 이 글은 그 수만 개를 어떻게 줄이고, 줄인 것들 사이에서 무엇을 어떻게 고르는지를 따라간다.
멘션 후보
스팬의 개수
지시 해소는 먼저 멘션을 찾아야 한다. 멘션은 개체를 가리킬 수 있는 연속된 단어 구간, 곧 스팬이다. 「그」 같은 대명사, 「김철수」 같은 고유명사, 「그의 오래된 동료」 같은 명사구가 전부 후보다.
후보가 정해져도 고르는 일이 쉽지 않은 데는 이유가 셋 있다. 하나는 중의성이다 — 남성 인물이 둘 등장하는 문단에서 「그」가 누구인지는 문법이 아니라 내용이 정한다. 다른 하나는 거리다. 정답이 같은 문장에 있으면 쉽지만 열 문장 앞에 있는 경우가 드물지 않고, 그 사이에 끼어든 다른 개체들이 전부 오답 후보로 작동한다. 마지막은 앞에서 말한 연쇄 구조다. 한 번 틀리면 그 오류가 뒤의 판단에 근거로 쓰인다.
그런데 이 셋보다 먼저 걸리는 벽이 후보의 개수다. 어디까지가 한 덩어리인지 미리 알 수 없기 때문이다. 「그의 오래된 동료」에서 「동료」도 「오래된 동료」도 「그의 오래된 동료」도 전부 말이 되는 후보라, 초기 신경망 모델은 아예 모든 연속 구간을 후보로 놓고 시작한다. 토큰 개짜리 문서의 스팬 수는 다. 500토큰이면 125,250개이고, 이 후보들 사이의 쌍을 전부 따지면 78억 쌍이 된다. 짧은 뉴스 한 편도 이대로는 계산이 끝나지 않는다.
두 번의 가지치기
그래서 후보를 두 번 자른다. 첫 번째는 길이다. 실제 멘션의 거의 전부가 짧은 구간이므로 길이 10토큰 이하만 남기면 125,250개가 4,955개로 줄어든다. 스물다섯 분의 일이고, 이 단계에서 잃는 정답은 아주 적다.
두 번째는 점수다. 남은 후보마다 「이것이 멘션일 확률」을 매기는 작은 신경망을 두고, 상위 개만 남긴다. 500토큰이면 200개다. 여기에 선행사 후보를 앞쪽 50개로 제한하면 실제로 따지는 쌍이 1만 개 수준이 된다. 78억에서 1만으로 내려온 것이고, 이 세 번의 제한이 없으면 신경망 지시 해소는 구현 자체가 성립하지 않는다.
무엇을 멘션으로 셀 것인가
자르는 기준보다 앞서 정해야 하는 것이 있다. 어떤 표현을 애초에 멘션으로 칠 것인가다. 데이터셋마다 답이 다르고, 그 차이가 점수를 통째로 흔든다.
가장 크게 갈리는 것이 단독 멘션이다. 문서에 한 번만 나오고 아무것과도 묶이지 않는 개체를 클러스터 크기 1로 셀 것인가, 아예 빼 버릴 것인가. OntoNotes는 빼는 쪽이고 그래서 이 데이터로 학습한 모델은 단독 멘션을 잘 찾지 않는다. 다른 하나는 총칭 표현이다. 「대한민국의 수도는 서울이다. 그곳은 인구가 많다」에서 「그곳」은 특정 개체를 가리키지만, 「요즘 학생들은 책을 안 읽는다」의 「학생들」은 세상의 어떤 개인도 특정하지 않는다. 총칭을 멘션으로 셀지는 데이터셋의 선택이며, 모델을 갈아 끼우기 전에 이 기준부터 맞춰야 점수를 견줄 수 있다.
점수 함수
두 점수의 합
가지치기를 통과한 스팬 와 그보다 앞에 있는 스팬 가 같은 개체인지를 하나의 점수로 잰다. 그 점수는 셋의 합이다.
은 그 스팬이 멘션일 만한 정도이고 는 둘이 같은 개체를 가리킬 만한 정도다. 이렇게 쪼개 두면 멘션 감지와 연결이 하나의 목적 함수 안에서 함께 학습된다. 「그」가 멘션인지 판단하는 일과 「그」가 김철수를 가리키는지 판단하는 일을 따로 배우면 앞 단계의 오류가 뒤로 그대로 넘어가는데, 합으로 묶으면 연결이 잘 되는 쪽으로 멘션 점수도 함께 밀린다.
더미 선행사
모든 멘션에 선행사가 있는 것은 아니다. 문서에서 처음 등장하는 개체는 가리킬 앞 자리가 없고, 잘못 잡힌 스팬은 아예 멘션이 아니다. 이 「없음」을 표현하려고 후보 목록 맨 앞에 더미 선행사 을 하나 끼워 넣고 그 점수를 0으로 고정한다.
고정값 0이 기준선 노릇을 한다. 어떤 실제 후보의 점수도 0을 못 넘으면 소프트맥스에서 더미가 이기고, 모델은 「선행사 없음」을 고른 것이 된다. 새 개체 판정을 위한 별도 분류기가 필요 없어지고, 학습할 때도 정답 선행사가 없는 스팬은 더미에 확률을 몰아주도록 같은 손실로 다루면 된다.
스팬 표현
점수를 매기려면 스팬 하나를 벡터 하나로 만들어야 한다. 구성은 보통 셋을 잇는다. 시작 토큰의 표현, 끝 토큰의 표현, 그리고 구간 안의 토큰들을 어텐션으로 가중 평균한 값이다. 셋째 항이 사실상 그 구를 대표하는 머리 단어를 모델이 스스로 고르게 하는 자리다 — 「그의 오래된 동료」라면 「동료」에 가중치가 몰린다.
BERT 계열 인코더 위에 이 구성을 얹은 것이 지금의 기본형이고, 특히 SpanBERT는 사전학습 때부터 단어 하나가 아니라 연속 구간을 통째로 가리고 맞히도록 학습해 스팬 표현이 필요한 과제에서 강하다. OntoNotes 기준 CoNLL F1 80 안팎이 이 계열에서 나온 값이다.
클러스터링
최우선 선행사
점수가 나오면 묶어야 한다. 가장 널리 쓰이는 방식은 각 멘션이 자기 앞의 후보 중 점수가 가장 높은 것 하나만 고르는 것이다. 모든 쌍에 대해 「같다/다르다」를 독립적으로 판정하지 않고 멘션당 화살표를 하나씩만 긋는 셈이라, 계산이 가볍고 판정끼리 모순될 일이 없다.
이렇게 그어진 화살표들을 따라가면 클러스터가 저절로 만들어진다. A가 B를 가리키고 B가 C를 가리키면 셋이 한 묶음이다. 명시적인 병합 절차 없이 연결 성분을 구하는 것으로 끝난다.
사슬 오류
이 구조의 대가가 분명하다. 화살표 하나가 틀리면 두 클러스터가 통째로 붙는다. 「김철수」 묶음 다섯 개와 「이영희」 묶음 네 개가 잘 만들어져 있어도, 「그는」이 이영희를 가리키는데 김철수로 잘못 이으면 아홉 개가 한 개체로 합쳐진다. 개별 판정 아홉 개 중 하나가 틀린 것인데 결과로는 두 개체가 사라진다.
그래서 지시 해소의 오류는 국소적이지 않다. 점수를 볼 때도 「정확도 95%」 같은 표현이 거의 쓰이지 않고, 뒤에 볼 클러스터 단위 지표 셋을 나란히 보는 관행이 생긴 이유가 이것이다.
방향에도 비대칭이 있다. 붙여야 할 둘을 안 붙이면 개체가 둘로 나뉘어 각각은 그대로 남지만, 붙이지 말아야 할 둘을 붙이면 두 개체의 정보가 섞여 둘 다 못 쓰게 된다. 그래서 대부분의 구현은 점수가 애매할 때 잇지 않는 쪽으로 기울어 있다. 뒤에서 임계값 이야기를 다시 하는데, 그 기울기를 어디에 둘지가 바로 이 비대칭을 얼마나 무겁게 볼 것인가의 문제다.
고차 추론
한 번에 하나씩 잇는 방식에는 또 하나의 빈틈이 있다. 멘션 A가 B를 가리킬지 판단할 때 B가 이미 C와 묶여 있다는 사실을 쓰지 못한다. C를 보면 답이 분명한데 B만 보면 애매한 경우가 실제로 많다.
이를 메우려고 클러스터가 한 번 만들어진 뒤 그 클러스터 전체의 표현을 다시 계산해 점수를 갱신하고, 그 갱신된 점수로 다시 묶는 반복을 두세 번 돌리는 방식이 쓰인다. 반복을 늘릴수록 좋아지지는 않는다 — 두 번쯤에서 이득이 멈추고, 잘못 붙은 클러스터가 있으면 그 오류까지 함께 강화된다.
한국어에서 갈리는 자리
조사와 멘션 정규화
한국어에서는 같은 개체가 「철수가」·「철수를」·「철수의」·「철수는」으로 제각기 다른 문자열이 된다. 문자열 비교를 그대로 하면 같은 이름조차 다른 멘션으로 잡히므로, 형태소 분석으로 조사를 떼어 어근을 맞추는 단계가 앞에 붙는다.
from kiwipiepy import Kiwi
kiwi = Kiwi()
def normalize(text: str) -> list[str]:
"""조사를 떼고 명사류만 남겨 멘션 후보를 만든다"""
tokens = kiwi.analyze(text)[0][0]
return [t.form for t in tokens if t.tag.startswith("NN")]
print(normalize("철수가 철수를 찾았다"))
# ['철수', '철수']
다만 조사를 떼는 것이 언제나 이득은 아니다. 조사는 그 자체로 강력한 단서이기도 하다 — 「가/이」가 붙었으면 주어이고 「를/을」이면 목적어라, 뒤에 오는 대명사가 무엇을 가리킬지 좁히는 데 쓰인다. 그래서 실무에서는 멘션 문자열은 정규화하되 조사 정보는 자질로 따로 남긴다.
제로 대명사
영어 지시 해소는 화면에 보이는 대명사를 앞의 명사구에 잇는 일이다. 한국어에서는 그 대명사가 아예 없는 경우가 훨씬 흔하다. 「밥을 먹었다」에는 주어가 없고, 「어제 만났어요」에는 주어와 목적어가 둘 다 없다. 이것이 제로 대명사이고, 문제의 성격이 다르다 — 무엇을 가리키는지 고르기 전에 빈자리가 있다는 사실부터 찾아야 한다.
빈자리를 찾는 단서는 서술어에 남는다. 서술어의 자릿수가 필요한 논항 수를 알려 주므로, 「먹다」가 나왔는데 주어와 목적어 자리가 둘 다 비었으면 생략이 둘이다. 높임 어미도 단서다 — 「가셨어요」는 생략된 주어가 높임 대상임을 알려 주므로 앞 문맥에서 후보를 크게 좁힌다.
높임 표현의 자질 일치
영어 지시 해소가 후보를 좁히는 데 쓰는 자질은 성과 수다. 「he」는 남성 단수 명사구만, 「they」는 복수만 받는다. 한국어에는 이 두 자질이 사실상 없다 — 대명사가 성을 구분하지 않고 복수 표지도 필수가 아니다. 대신 다른 축이 있다. 높임이다.
「선생님께서 오셨다. 그분이 말씀하시길」에서 「그분」은 높임 대상인 개체만 받을 수 있다. 앞 문맥에 김 선생과 어린 학생이 함께 있었다면 「그분」은 자동으로 김 선생 쪽으로 좁혀진다. 단서는 셋에 나뉘어 있다. 주어에 붙는 「께서」, 서술어의 「-시-」, 그리고 「그분」·「당신」 같은 높임 대명사다. 이 셋이 한 문장 안에서 일관되게 나타나므로 하나만 잡아도 나머지를 추정할 수 있다.
주의할 점은 높임이 개체의 고정된 속성이 아니라는 것이다. 같은 사람이 사내 문서에서는 높임 대상이고 기사에서는 아니며, 화자가 바뀌면 같은 문서 안에서도 달라진다. 그래서 높임을 성·수처럼 딱딱한 일치 규칙으로 걸면 오히려 정답을 걸러 낸다. 자질을 넣되 점수에 더해지는 가중치로 두고, 불일치를 금지가 아니라 감점으로 다루는 편이 안전하다.
어디까지가 지시 해소인가
여기서 경계 문제가 생긴다. 제로 대명사 복원은 지시 해소인가, 그 앞 단계인 논항 구조 분석인가. 실무에서는 답이 갈리고, 어느 쪽으로 정하느냐에 따라 데이터도 지표도 달라진다.
현실적인 선은 목적에 두는 편이 낫다. 문서 요약이나 정보 추출을 위해 「누가 무엇을 했는지」를 채우는 것이 목적이면 복원까지 한 묶음으로 다루고, 벤치마크 점수를 내는 것이 목적이면 해당 데이터셋의 기준을 그대로 따른다. 섞으면 점수가 다른 연구와 비교 불가능해진다.
경계를 어디에 긋든 평가는 따로 해야 한다. 제로 대명사는 화면에 문자열이 없으므로 앞에서 본 스팬 기반 지표가 그대로 적용되지 않는다. 빈자리를 찾았는지와 그 자리를 옳게 채웠는지가 다른 일이라, 둘을 한 점수로 묶으면 어느 쪽이 부족한지가 보이지 않는다. 복원 대상 자리를 먼저 세고 그중 몇 개를 찾았는지, 찾은 자리 중 몇 개를 옳게 이었는지를 나누어 적는다.
LLM 프롬프팅
스팬 대신 목록
한국어 지시 해소는 전용 데이터셋과 공개 모델이 영어만큼 갖춰져 있지 않다. 그래서 지금 가장 빠르게 쓸 만한 결과를 얻는 길은 LLM에 직접 시키는 것이다.
import json
from anthropic import Anthropic
client = Anthropic()
def resolve(text: str) -> dict:
prompt = f"""다음 텍스트에서 같은 개체를 가리키는 표현을 묶어
JSON으로 반환하세요. 각 표현은 텍스트에 나온 그대로 적습니다.
텍스트: {text}
형식: {{"clusters": [{{"entity": "대표 표현", "mentions": ["표현1", "표현2"]}}]}}"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return json.loads(response.content[0].text)
출력이 스팬 인덱스가 아니라 문자열 목록이라는 점이 전용 모델과 결정적으로 다르다. 편하지만 위험이 따라온다. 같은 문자열이 문서에 여러 번 나오면 어느 것을 가리키는지 알 수 없고, 모델이 텍스트에 없는 표현을 살짝 다듬어 내놓으면 원문과 대응이 끊긴다. 그래서 받은 표현을 원문에서 되찾아 인덱스로 바꾸는 검증 단계를 반드시 붙이고, 못 찾은 표현은 버린다.
긴 문서 나누기
문서가 길어지면 한 번에 넣을 수 없고, 나누면 조각 경계를 넘는 지시가 끊긴다. 쓸 만한 절충은 앞 조각에서 확정된 클러스터의 대표 표현만 요약해 다음 조각의 프롬프트에 함께 넣는 것이다. 전체 원문이 아니라 개체 목록만 넘기므로 길이가 거의 늘지 않고, 새 조각의 대명사가 앞 개체를 가리킬 길이 열린다.
언제 전용 모델이 낫나
LLM이 언제나 답은 아니다. 문서 수만 건을 매일 처리해야 하면 비용과 지연이 먼저 걸리고, 같은 입력에 같은 출력이 나와야 하는 파이프라인에서는 생성 모델의 흔들림이 문제가 된다. 스팬 인덱스가 정확히 필요한 경우에도 전용 모델이 낫다. 반대로 문서가 적고 도메인이 특수하며 학습 데이터를 만들 여력이 없다면 LLM 쪽이 압도적으로 빠른 길이다.
둘을 섞는 구성도 있다. 전용 모델로 전부 처리하되 점수가 애매한 연결만 골라 LLM에 다시 물어보는 방식이다. 애매한 자리는 전체의 일부이므로 비용이 크게 늘지 않고, 전용 모델이 약한 자리가 대개 문맥 지식이 필요한 자리라 LLM의 강점과 맞물린다. 다만 그러려면 전용 모델의 점수가 실제 정답률과 얼추 맞아야 한다 — 점수가 높은데 자주 틀리는 모델이면 무엇을 물어볼지 고르는 단계부터 어긋난다.
평가 지표
MUC
MUC는 클러스터를 연결의 집합으로 본다. 정답 클러스터에 멘션이 개 있으면 그것을 잇는 데 필요한 연결이 개라고 보고, 예측이 그중 몇 개를 맞혔는지로 정밀도와 재현율을 잰다.
계산이 직관적인 대신 두 가지 편향이 있다. 큰 클러스터일수록 연결 수가 많아 점수에 크게 기여하므로, 작은 클러스터를 다 놓쳐도 큰 것 하나를 잘 맞히면 점수가 높게 나온다. 그리고 멘션이 하나뿐인 클러스터는 연결이 0개라 아예 점수에 잡히지 않는다.
B³
B³은 반대로 멘션 하나하나를 기준으로 센다. 멘션마다 자기가 속한 예측 클러스터와 정답 클러스터를 겹쳐 보고 그 비율로 정밀도·재현율을 구한 뒤, 전체 멘션에 대해 평균 낸다. 클러스터 크기와 무관하게 멘션마다 같은 무게를 가지므로 MUC의 큰 클러스터 편향이 사라지고, 단독 멘션도 제대로 평가된다.
CEAF와 CoNLL F1
CEAF는 예측 클러스터와 정답 클러스터를 일대일로 최적 짝짓기한 뒤 짝지어진 것들의 겹침만 점수로 인정한다. 짝을 못 지은 클러스터는 통째로 버려지므로 클러스터 개수 자체가 어긋나는 것에 가장 민감하다. 하나를 둘로 쪼개거나 둘을 하나로 붙인 오류가 여기서 제일 크게 벌점을 받는다.
셋 중 어느 하나도 단독으로는 신뢰할 수 없다는 것이 이 분야의 결론이고, 그래서 표준 지표는 셋의 F1을 평균한 CoNLL F1이다. 논문의 점수를 읽을 때도 CoNLL F1만 보지 말고 셋을 나란히 보는 편이 낫다 — MUC만 높고 CEAF가 낮으면 클러스터를 지나치게 크게 붙이고 있다는 뜻이다.
그리고 이 지표들은 전부 정답 멘션 경계를 맞혔다는 전제 위에서 계산된다. 「그의 오래된 동료」를 「동료」로 잡으면 개체를 옳게 묶었더라도 그 멘션은 정답과 짝이 지어지지 않아 전부 오답으로 센다. 그래서 점수가 낮게 나왔을 때 연결을 의심하기 전에 경계부터 본다. 정답 멘션을 그대로 주고 연결만 시켰을 때의 점수와 처음부터 시킨 점수를 함께 재면, 떨어진 몫이 감지에서 온 것인지 연결에서 온 것인지가 한 번에 갈린다.
상위 태스크로 번지는 오류
요약과 정보 추출
지시 해소는 그 자체로 쓰이는 일이 드물고 대개 다른 과제의 앞단에 놓인다. 그래서 오류도 그 과제의 결과로 바뀌어 나타난다. 요약에서는 주체가 뒤바뀐 문장이 만들어진다 — 「그는 사임했다」의 「그」를 잘못 이으면 요약문에 엉뚱한 사람이 사임한 것으로 적히고, 요약문 자체는 문법적으로 완벽해서 읽는 사람이 의심할 단서가 없다.
정보 추출에서는 같은 개체가 둘로 쪼개지거나 다른 개체가 하나로 합쳐진다. 개체별 집계를 내는 시스템이면 숫자가 그대로 틀리고, 지식 그래프를 쌓는 시스템이면 잘못된 관계가 영구히 저장된다.
검색 쪽에서는 다른 모양으로 나타난다. 문서를 조각내 색인하는 구조에서는 「그 회사는 지난해 흑자로 돌아섰다」 같은 문장이 회사 이름 없이 저장되므로, 그 이름으로 검색했을 때 이 조각이 걸리지 않는다. 조각마다 지시를 미리 풀어 대표 표현을 넣어 두면 걸리게 되고, 이것이 지시 해소를 색인 전처리로 쓰는 전형적인 자리다. 다만 이때도 원문은 그대로 두고 검색용 사본만 바꿔야 한다 — 사용자에게 보여 줄 문장까지 치환하면 사람이 쓰지 않는 어색한 문장이 화면에 나간다.
대명사 치환의 함정
지시 해소 결과로 문서의 대명사를 대표 표현으로 바꿔 두면 뒤 단계가 쉬워진다. 다만 문자열 치환으로 구현하면 새 오류가 생긴다.
def normalize_pronouns(text: str, clusters: list) -> str:
for cluster in clusters:
rep = cluster["entity"]
for mention in cluster["mentions"][1:]:
text = text.replace(mention, rep) # 위험: 전역 치환
return text
replace는 문서 전체를 바꾸므로 같은 글자가 다른 뜻으로 쓰인 자리까지 함께 바뀐다. 「그」 하나를 치환하면 「그림」·「그때」의 첫 글자가 걸리고, 짧은 이름은 다른 이름의 일부와 겹친다. 실제로는 스팬의 시작·끝 인덱스를 받아 뒤에서부터 잘라 붙여야 한다 — 앞에서부터 바꾸면 길이가 달라지며 뒤쪽 인덱스가 전부 밀린다. 앞 절에서 LLM 출력을 반드시 인덱스로 되돌리라고 한 이유가 여기서 드러난다.
얼마나 고쳐야 하는가
마지막으로 균형의 문제가 남는다. 지시 해소를 완벽하게 만드는 것보다 틀렸을 때 뒤 단계가 덜 다치게 만드는 것이 대개 값이 싸다. 점수가 낮은 연결은 잇지 않고 남겨 두는 임계값을 두면, 잘못 붙어 두 개체가 합쳐지는 최악의 오류가 줄고 대신 못 이은 멘션이 는다. 요약처럼 틀린 문장이 나가는 것이 치명적인 곳에서는 이 거래가 남는 장사고, 검색처럼 재현율이 중요한 곳에서는 반대다. 지시 해소의 임계값은 모델의 문제가 아니라 그것을 쓰는 쪽의 결정이다.
읽어주셔서 감사합니다. 😊

