지식베이스 하나 만드는데 세 번을 엎었다
English version: Three Failures Before a Knowledge Base That Held
연구과제를 하다 보면 문서가 쌓인다. 사업계획서, 협약서, 연차보고서, 정산 서류, 출장 신청서와 복명서. 그것도 몇 년치가, 그해에 정리한 사람 마음대로 만들어진 폴더 구조 속에 흩어져 있다.
정작 필요한 정보는 다 그 안에 있다. 이 과제가 어느 사업에 속하는지, 어느 부처 소관이고 어떤 규정을 따르는지, 참여기관은 어디어디이고 각 기관 담당자가 누구인지, 처음에 내건 목표가 뭐였고 실제로 뭐가 나왔는지, 그 출장은 어느 과제로 다녀온 건지.
어느 파일을 열어야 하는지만 알면 찾는 건 일도 아니다. 문제는 대개 그걸 모른다는 거고, “24년 독일 출장은 어떤 과제였지?” 같은 단순한 질문의 답이 서로를 전혀 참조하지 않는 문서 네 개에 나눠 담겨 있다는 거다.
그래서 이 문서 더미 위에 지식베이스를 올리기로 했다. 사실을 뽑고, 엮고, 계속 최신으로 유지하는 것.
사실 이 주변을 맴돈 게 처음은 아니다. 얼마 전에는 반나절 동안 마크다운 파일과 작은 스킬로 개인 지식 베이스를 만들어 본 적이 있고(그 글은 영문으로만 있다), 이틀 뒤에는 그게 삐걱거리다 결국 그래프가 되고 싶어 하는 이야기를 썼다. 그때는 마음 편한 축에 속했다. 코퍼스가 머릿속에 들어올 만큼 작았고, 설계가 마음에 안 들면 그냥 버리면 그만이었다.
이번 이야기는 같은 생각을 진짜 문서 더미에 들이댔을 때 벌어진 일이다. 그리고 지금까지 세 번을 말아먹었다. 아래는 각 시도가 뭘 놓쳤는지, 그리고 네 번째는 어떻게 생겼는지에 대한 기록이다.
첫 번째 — 파일에서 데이터베이스까지 한 줄로
제일 먼저 떠오르는 설계다. 폴더를 훑고, 파일을 읽고, 내용을 뽑아서, DB에 쓴다. 앞에서 뒤까지 직렬로 이어진 파이프라인 하나.
한 번은 잘 돌았다. 문제는 두 번째부터였다.
지식베이스는 한 번 뽑아 놓고 덮어 두는 보고서가 아니다. 새 문서를 계속 받아들여야 하고, 이미 있던 문서도 계속 바뀐다. 파일이 하나 새로 생기고, 하나 지워지고, 누가 사업계획서 열어서 숫자 하나 고쳐 저장한다. 이런 일이 생길 때마다 DB에는 낡았거나 서로 어긋나는 행이 남을 위험이 생기는데, 직렬 파이프라인에는 그 처리를 넣을 자리가 마땅히 없다. 그래서 사방에 퍼진다. 읽는 쪽에도 방어 코드, 쓰는 쪽에도 방어 코드, 정합성 맞추는 패스, 삭제 감지.
결국 추출하는 코드보다 DB를 변경으로부터 지키는 코드가 더 많아졌다. 존재 이유 자체가 ‘계속 갱신되는 것’인 지식베이스를 만들면서 그 유지보수 비용을 감당하는 건 아무리 봐도 수지가 안 맞았다. 자기가 쓰일 상황을 못 견디는 설계였던 셈이다.
두 번째 — 생성자와 소비자로 나누기
다음 설계에서는 일을 자연스러워 보이는 이음매를 따라 갈랐다. 한쪽은 문서를 읽어서 특성을 뽑는다. 생성자, 주요 폴더 단위로 돈다. 다른 쪽은 생성된 걸 읽어서 특성들 사이의 관계를 정리한다. 소비자, 관계를 갱신하고 정돈한다.
구조적으로는 훨씬 나았다. 생성자는 소비자가 그걸로 뭘 하든 상관하지 않고, 소비자는 특성이 어디서 왔는지 신경 쓰지 않는다. 폴더 하나 바뀌면 그 폴더만 다시 생성하면 된다.
이건 비용에서 죽었다. 추출한다는 건 결국 문서 텍스트를 모델에 밀어 넣는다는 뜻이고, 코퍼스는 크고, 다시 돌릴 때마다 그 값을 또 낸다. 전량 한 바퀴 도는 토큰 비용이 부담스러운 수준이라 반복 실험 자체가 어려워졌다. 두 번 돌릴 엄두가 안 나는 설계는 디버깅을 할 수 없는 설계다. 구조는 멀쩡했는데 경제성이 발목을 잡았다.
세 번째 — 로컬 LLM과 Claude를 반씩
비용 문제의 해법은 뻔해 보였다. 양 많고 비싼 부분을 이미 갖고 있는 하드웨어로 내리면 된다. 로컬 LLM이 문서를 읽어 특성을 뽑고, 그걸 훨씬 잘하는 Claude가 그 위에서 관계를 짠다. 이 엔티티들이 어떻게 연결되는지, 이 언급이 어느 기관을 가리키는지.
한동안은 답인 것 같았다. 로컬 추출은 사실상 공짜였고 관계 추출 품질도 괜찮았다.
그러다 슬슬 밀리기 시작했다. 코퍼스에 새로운 종류의 관계가 나올 때마다 그걸 처리할 프롬프트가 필요했고, 그 프롬프트들이 계속 쌓였다. Claude로 보내야 하는 컨텍스트도 같이 불어났다. 지시문이 늘고, 딸려 보내는 추출 결과가 늘고, 설명해야 할 관계 종류가 늘고. 하나하나는 다 그럴 만한 증가였다. 합이 문제였다. 비싼 쪽으로 가는 컨텍스트가 계속 커지더니, 추출을 로컬로 돌려서 아낀 몫이 조용히 다 갉아 먹혔다.
비용을 없앤 게 아니라 옮겼을 뿐이었다. 그리고 이 하이브리드에는 자기 비싼 절반이 커지는 걸 막을 장치가 없었다.
다시 처음부터 — 원장이 곧 시스템이다
네 번째는 질문을 바꾸는 데서 시작했다. ‘어떻게 하면 잘 뽑을까’가 아니라, ‘이게 계속 바뀌는 걸 견디려면 뭐가 참이어야 하나’.
각 절차를 명세하고, 파일마다 원장에 한 줄을 준다. 재설계의 핵심은 문서마다 지금 어느 단계까지 왔고 결과가 뭐였는지를 적어 두는 평범한 표 하나다. 로그가 아니라 원장이고, 각 단계는 여기서 읽는다. 단계끼리는 서로를 부르지 않는다. 각 단계가 원장에 “남은 일이 뭐냐” 물어보고, 하고, 상태를 다시 써 넣는다. 한 단계가 멈춰도 나머지는 돈다. 장애가 타고 번질 통로 자체가 없다. 앞선 설계들이 죽은 이유 중 하나가 분류가 추출을 직접 호출하는 구조여서, 하나가 막히면 전부 막혔다는 거였다.
각 단계는 다른 단계가 아니라 원장하고만 이야기한다. 그리고 검토에서 나온 교정이 각 절차로 되먹여진다.
진짜로 독립적인 절차들로 쪼갠다. 이 문서가 무슨 종류인지 판단하는 부분, 문서에서 필드를 뽑아내는 부분, 뽑힌 엔티티들을 서로 연결하는 부분. 각각 따로 돌릴 수 있고, 따로 다시 돌릴 수 있고, 따로 디버깅할 수 있다. 그리고 각각이 자기한테 맞는 모델을 고른다. 로컬 LLM이 필요한 단계도 있지만, 언어 모델을 아예 안 쓰고 규칙이나 간단한 회귀로 판정하는 게 나은 단계가 생각보다 훨씬 많다. 더 싸고 더 예측 가능하다.
사람을 루프 안에 넣고, 원장 위에 UI를 얹는다. 이게 그동안 통째로 빠져 있던 부분이다. 이제 파이프라인이 내린 판단을 들여다보고 고치는 것만 하는 웹 인터페이스가 있다. 잘못 분류된 문서, 뽑히긴 했는데 해석이 틀린 필드, 같은 기관으로 합쳐졌어야 하는데 따로 남은 두 레코드 같은 것들. 내가 고친 값이 이 시스템에서 가장 높은 권위를 가진다. 뒤에 오는 어떤 단계도 그걸 덮어쓰지 못한다.
중요한 건 교정이 그 행 하나만 고치고 끝나지 않는다는 점이다. 교정 결과가 각 단계의 절차에 되먹여진다. 고쳐 준 별칭은 다음번 검증기가 쓰는 규칙이 되고, 고쳐 준 분류는 분류기가 참고하는 근거가 된다. 내가 코드를 다시 짜야 좋아지는 게 아니라, 쓰면 쓸수록 좋아진다.
묻는 것과 판정하는 것을 분리한다. 이번에 얻은 것 중 제일 쓸모 있었던 구분이다. 필드 정의에는 얼굴이 둘 있다. 하나는 모델에게 보내는 프롬프트를 이루는 쪽(이름, 타입, 설명). 다른 하나는 돌아온 답을 판정하는 데 쓰는 쪽(허용 별칭, 정규화, 필수 여부). 앞엣것을 바꾸면 다시 추출해야 하고 GPU 시간을 쓴다. 뒤엣것을 바꾸면 이미 받아 둔 출력을 다시 판정만 하면 된다. 모델 호출 0회.
이 둘을 뭉뚱그리면 눈에 잘 안 띄는 방식으로 비싸진다. 첫 시스템에서 어떤 필드 하나의 허용값 목록이 너무 좁게 잡혀 있었다. 문서에는 그 단어의 더 긴 공식 표기가 적혀 있는데 목록이 그걸 몰라서, 모델이 제대로 읽어 온 답을 검증기가 갖다 버렸다. 그 필드 하나가 전체 거부의 72.5%였다. 고치는 건 데이터 한 줄이면 되는 일이었다. 그런데 그 수정이 추출 경로에 엉켜 있어서, 적용하려면 코퍼스를 통째로 다시 뽑아야 했다. 그래서 안 고쳤다. 알고 있던 이슈 284건 중 279건이 그대로 남았다.
배운 것들
지식베이스는 산출물이 아니라 과정이다. 위의 실수는 거의 다 ‘만들 수 있는 것’을 설계하고 ‘유지할 수 있는 것’을 설계하지 않아서 생겼다. 어떤 설계든 첫 버전은 돌아간다. 열 번째 실행이 어떤 모습일지를 물어야 한다.
흐름보다 상태다. 파이프라인이라는 비유는 그럴듯한데, 정작 중요한 걸 가린다. 각 항목이 지금 어디에 있는지, 하나가 걸리면 무슨 일이 벌어지는지. 순서가 아니라 원장이 시스템의 중심이 되고 나니, 그동안 붙들고 있던 변경 관리 코드 대부분이 그냥 필요 없어졌다.
옳은 일의 비용이 잘못 매겨지면 아무도 옳은 일을 하지 않는다. 계속 곱씹게 되는 교훈이다. 가장 강한 증거는 실패가 아니라 회피였다. 언젠가 규칙대로면 스키마 버전을 올려야 하는 상황에서 안 올린 적이 있다. 도구가 “이 변경이 실제로 영향 주는 문서만 다시 해라”를 표현할 방법이 없어서, 규칙대로 하면 아무 상관 없는 파일들까지 재작업하게 되기 때문이었다. 판단은 옳았다. 도구가 규율의 가격을 잘못 매긴 거였다. 변경 비용을 실제 영향 범위에 비례하게 만들지 않으면 사람은 그 절차를 우회한다. 그리고 그게 합리적인 선택이 된다.
규칙으로 되는 걸 언어 모델한테 시키지 마라. 연차별 예산의 합이 총액과 맞는지 보는 건 산수다. 지출 날짜가 과제 종료일 뒤인지 보는 건 날짜 비교다. 이걸 LLM에 보내면 열 줄짜리 코드보다 느리고, 비싸고, 덜 정확하다. 모델은 진짜 판단이 필요한 데만 쓰는 게 맞다.
모르면 모른다고 말하게 만들어야 한다. 첫 버전의 가장 큰 문제는 틀린 답이 아니라 자신 있는 답이었다. 전체 문서의 절반 넘게가 포괄 분기 하나로 뭉개졌고, 모델이 “문서에 명시되어 있지 않습니다”라고 답한 것까지 추출 성공으로 기록되고 있었다. 불확실은 스키마가 슬그머니 값으로 반올림해 버릴 게 아니라, 검토 큐로 가야 하는 일급 결과여야 한다.
사람의 교정은 최후의 수단이 아니라 구성 요소다. 설계를 세 번 하는 동안 수동 수정을 ‘자동화가 실패했다는 증거’ 취급했고, 그래서 그걸 받아 줄 자리를 아예 안 만들었다. 결과적으로 교정 인터페이스가 이 시스템에서 제일 값진 부분이 됐다. 파이프라인이 나빠서가 아니라, 파이프라인이 자기가 뭘 틀렸는지 알게 되는 통로가 거기 하나뿐이라서다.
특별할 것 없는 이야기다. 배울 마음이 생기는 만큼의 속도로 찾아오는 평범한 교훈에 가깝다. 언어 모델 위에 뭔가를 얹을 때 어려운 건 좀처럼 모델이 아니고, 거의 항상 그 주변 전부라는 것.