증분 빌드라 불렀지만 비용은 전량이었습니다: 청크 기준선, 조회 상한 500, 이름 색인으로 끊은 비례
문서를 추가할 때마다 온톨로지 빌드가 전체를 다시 처리하거나, 새 문서가 없는데도 같은 빌드를 반복하고 있었습니다. 빌드된 청크 집합을 PostgreSQL 기준선으로 두고 SDK 기본 조회 상한 500 때문에 문서 57.8%가 그래프에서 빠지던 결함을 고친 뒤, 대상 조회·후처리·감분 단계마다 비용이 전체가 아니라 변경분에 비례하도록 바꿨습니다.
Key Results
- 6콜 → 2콜
- 이어서 빌드 LLM 호출 수샘플 컬렉션 11청크 중 신규 3청크 시뮬레이션, 토큰 25,755 → 7,789
- 57.8%
- 그래프에서 빠져 있던 문서 비율(수정 전)공공기관 컬렉션 문서 1,186개 중 686개. 청크 기준으로는 29,506개 중 19,450개(65.9%)
- 88.3초 → 1.06초
- 빌드 진행 조회문서 443·청크 30,780 컬렉션, 문서별 Qdrant 필터 스크롤을 한 번 훑기로 바꾼 전후, 응답 동일
XGEN의 온톨로지는 문서를 올리면 자동으로 빌드됩니다. 9월부터는 업로드마다 LLM을 쓰지 않는 기본 빌드가 실행되고, 미빌드 컬렉션을 주기적으로 훑는 백필 스윕도 켜졌습니다. 자동으로 자주 실행되는 빌드에서 증분은 선택이 아니라 전제입니다. 새 문서 몇 개를 위해 컬렉션 전체를 다시 처리하면 비용을 감당할 수 없습니다.
문제는 증분이라고 불리던 것이 실제로는 그렇지 않았다는 점입니다. 6월에는 이어서 빌드가 처음부터 다시 했고, 9월에는 새 문서가 하나도 없는데 164초짜리 빌드가 11.6분마다 반복됐습니다. 이 글은 증분이 성립하려면 파이프라인의 단계마다 무엇이 필요했는지, 그리고 그것을 찾는 과정에서 처음에 틀린 곳이 어디였는지를 다룹니다.
증분이 걸려야 하는 곳은 네 군데였습니다
업로드 → 임베딩 → (자동) 기본 빌드
① 대상 조회 문서 목록 + Qdrant 청크 스냅샷
② 차집합 스냅샷 − 기준선(최근 완료 잡의 built_chunk_ids)
③ 추출 기본 빌드: LLM 0회 / 관계 형성: LLM
④ 후처리 이름 병합 · 계층 유도 · 중복 제거 · 품질 검사
⑤ 적재·기록 PostgreSQL 그래프, 완료 시 기준선 저장
(+) 감분 기준선 − 스냅샷 = 지워진 청크 → 그래프에서 걷어내기
백필 스윕: 기동 90초 뒤, 미빌드 컬렉션을 순차 1개씩(상한 20) 빌드증분이라는 말이 의미를 가지려면 ①의 조회가 새 것만 가져와야 하고, ②의 기준선이 정확해야 하고, ④의 후처리가 그래프 전체를 다시 훑지 않아야 하며, 지워진 문서는 그 몫만 걷어내야 합니다. 실제 결함은 이 단계마다 하나 이상 있었습니다.
6월: 기준선을 그래프가 아니라 PostgreSQL에 두었습니다
첫 증상은 이어서 빌드가 남은 청크만이 아니라 모든 문서 그룹을 다시 실행하는 것이었습니다. 진행 상태를 그래프 마커에서 PostgreSQL 카운터로 옮길 때, 마커를 역질의하는 지연을 피하려고 청크 필터를 꺼 둔 부작용이었습니다. 문서를 추가해도 이어서 빌드 버튼이 나타나지 않는 문제도 겹쳐 있었습니다. 진행 조회가 전체 청크 수를 마지막 빌드 때 고정된 값으로 읽어 남은 청크가 늘 0이었고, 프론트엔드는 완료 상태면 무조건 버튼을 숨겼습니다.
기준선은 완료된 빌드가 처리한 chunk_id 집합(built_chunk_ids, JSONB)으로 PostgreSQL에 두고, 빌드 시작 때 한 번 읽어 스트림 필터로 썼습니다. 그래프는 역질의하지 않습니다. 기준선은 최근 완료 잡의 것만 쓰고, 재빌드하면 옛 잡의 기준선은 비웁니다. 정형 표 청크는 필터 앞에서 따로 빼서 통째로 다시 처리합니다. 행 일부만 다시 들어오면 클래스 구조가 어긋나기 때문입니다. 전체 청크 수는 빌드와 같은 집계로 실시간 계산하게 바꿨습니다. 저장에서는 직렬화 함정이 하나 있었습니다. DB 계층이 전부 문자열인 list를 TEXT[]로 오인해 JSONB 컬럼에 ARRAY로 넣으려다 실패해서, 명시적으로 json.dumps를 거치게 했습니다.
11개 청크 중 8개를 기준선으로 두고 3개를 새로 넣은 시뮬레이션에서 이어서 빌드는 LLM 2회·7,789토큰, 전체 빌드는 6회·25,755토큰이었습니다. 전체 빌드 직후 새 청크가 0이면 남은 청크도 0으로 표시돼 가짜 버튼이 생기지 않았습니다.
9월: 새 문서가 없는데 164초 빌드가 11.6분마다 반복됐습니다
8월 말 기본 빌드를 자동화하고 백필 스윕을 넣은 뒤, 공공기관 컬렉션의 빌드 이력 102건이 거의 모두 같은 모양이었습니다. 164초, 청크 10,056개, 트리플 54,809개. 그리고 11.6분 간격으로 끝없이 반복됐습니다. 새 문서는 없었습니다.
처음 세운 가설은 "증분 필터가 동작하지 않는다"였습니다. 확인해 보니 필터 자체는 동작했지만 청크 본문을 Qdrant에서 전부 받아 온 뒤에 걸리고 있었습니다. 새 청크가 0건이어도 Qdrant 전수 조회 두 번(ID, 본문)과 그래프 전체 중복 제거·품질 검사가 그대로 실행됐습니다. 그래서 기준선을 읽은 직후 현재 스냅샷이 모두 기준선에 들어 있으면 거기서 완료하도록 고쳐 배포했습니다.
이 수정은 절반만 맞았습니다. 조기 완료는 기준선이 정상일 때만 동작하는데, 반복의 원인은 그 위에 있었습니다. 백필이 왜 이 컬렉션에 미빌드 청크가 있다고 판단하는지를 숫자로 대조했습니다.
문서 테이블 1,186 행 ← 실제 문서
청크 메타 테이블 29,506 청크 ← 실제 청크 (Qdrant 와 일치)
빌드가 본 청크 10,056 ← 문서 500 개 몫빌드가 문서를 읽는 네 경로가 모두 SDK 조회 함수의 기본값 limit=500을 넘기지 않고 있었습니다. 6월에 클래스 5,408개 중 스키마 요약이 119개(2.2%)만 만들어졌던 원인과 같은 기본값이었습니다. 결과는 두 가지였습니다. 문서 686개(문서 기준 57.8%, 청크 19,450개로는 65.9%)가 그래프에 한 번도 들어가지 않았습니다. 검색은 Qdrant를 직접 보니 이 문서들도 찾았지만 그래프 근거는 없었습니다. 그리고 백필 스윕은 Qdrant 전수(29,506)와 기준선(10,056)을 비교하는데, 기준선이 자랄 수 없으니 이 비교가 영원히 참이었습니다.
문서 조회를 한 함수로 모아 limit을 명시하고, 상한(10만)에 닿으면 경고를 남기게 했습니다. 조용히 잘리면 왜 문서가 빠졌는지 아무도 찾을 수 없다는 것을 이 결함이 정확히 보여 줬기 때문입니다. 한 곳만 고치면 다른 경로로 되살아나므로, 빌드 경로가 SDK 조회를 직접 부르지 않는지 소스를 검사하는 회귀 테스트도 넣었습니다.
같은 점검에서 증분 이력이 통째로 죽어 있던 배포도 찾았습니다. 빌드 잡 테이블에 빌드 모드, 토큰 사용량, 적재 세션, built_chunk_ids 컬럼이 없었는데, SDK의 INSERT 함수가 실패를 {"success": True, "id": None}으로 반환해 아무도 몰랐습니다. 코어에 컬럼을 추가하고 저장 실패 원인을 남기는 진단을 넣었습니다.
순서에 대한 반성도 남겼습니다. 증상(헛빌드)을 고쳐 배포한 뒤에야 원인(조회 상한)이 그 위에 있다는 것을 알았습니다. 이후로는 고치기 전에 같은 유형의 결함을 코드 전체에서 먼저 훑고 한 번에 묶습니다.
상한을 고친 뒤에도 비용은 청크 총수에 비례했습니다
조회 상한을 고치자 빌드는 정확해졌지만 여전히 느렸습니다. 재설계 문서를 쓰며 비효율 지점을 다시 정리했습니다.
| 지점 | 실측 | 원인 |
|---|---|---|
| 헛빌드 | 새 청크 0건인데 164초, 백필 주기마다 반복 | 증분 필터가 전량을 받아 온 뒤에 걸림 |
| 전량 스캔 | 매 빌드마다 Qdrant 전수 2회(ID·본문) | 차집합 조회가 아니라 전수 조회 후 필터 |
| 전체 후처리 | 중복 제거·품질 검사를 매번 그래프 전체에 | 변경분 범위로 좁혀지지 않음 |
| 문서 캡(해소) | 1,186개 중 500개만 읽힘 | SDK 기본값 limit=500 |
결론은 "증분 빌드라고 부르지만 실제로는 전량 처리 후 결과만 증분"이라는 것이었습니다. 청크가 10배면 시간도 10배였습니다. 성공 기준은 "청크 총수와 빌드 시간의 비례가 끊긴다, 신규 N개에 O(N)"으로 정했습니다.
외부 구현도 비교했습니다. LightRAG는 새 문서마다 부분 그래프를 만들어 합집합으로 병합하고 커뮤니티를 다시 만들지 않습니다. 여기서 "차집합만 읽고, 병합은 합집합으로, 후처리는 변경분만"이라는 원칙을 가져왔습니다. EDC의 스키마 되먹임은 관계 형성의 다음 단계 근거로, GraphRAG의 gleaning은 LLM을 쓰는 관계 형성에서만 검토할 대상으로 분류했습니다. 앞선 초안에 넣었던 GraphRAG식 커뮤니티 요약과 검색 모드 분기는 철회했습니다. 검색은 이미 별도 라이브러리로 검증돼 있었고, 이번 문제는 검색이 아니라 빌드의 비용 구조였기 때문입니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 청크 필터(본문 수신 뒤) | 이미 구현돼 있음 | 비용이 청크 총수에 비례 | 유지하되 앞단을 추가 |
| 기준선 직후 조기 완료 | 새 청크 0건이면 즉시 끝 | 기준선이 정상일 때만 동작 | 적용 |
| 문서 단위 사전 필터(본문 수신 전) | 새 문서만 본문을 받음 | 효과 실측은 남기지 못함 | 적용 |
| 후처리 판정 근거를 노드에 저장 | 후처리도 변경분 범위로 | 컬럼·인덱스 마이그레이션 필요, 컬렉션 첫 빌드는 전량 비용 | 적용 |
후처리의 증분: 판정 근거를 노드에 남겼습니다
추출 단계를 증분으로 만들어도 빌드 마무리의 정리 7단계는 매번 컬렉션 전체를 다시 훑었습니다. 라벨 15만 개 규모에서 같은 대상 묶기의 정규화 키 재계산이 42초, 상하위 판정을 위한 형태소 경계 계산이 103초였습니다. 원인은 "같은 대상인가, 상하위인가"를 판정한 근거를 어디에도 남기지 않은 데 있었습니다. 그래서 문서를 하나 더해도 처음부터 다시 계산했습니다.
판정 근거를 노드에 적었습니다. ontology_nodes.nkey 컬럼과 (collection_id, nkey) 인덱스입니다. 이름 키는 노드가 처음 들어올 때 한 번만 계산하고, 같은 대상 묶기는 인덱스와 GROUP BY nkey로 형태소 분석 없이 합니다. 키가 비어 있는 노드가 곧 이번 빌드의 증분이므로 별도 표시가 필요 없습니다. 계층 유도 대상은 새 이름과, 새 이름 아래에 붙을 수 있는 옛 이름으로 좁혔습니다. 옛 이름 선별은 끝 두 글자 사전 조회로 15만 건에서 0.06초였습니다.
증분 결과가 전량 결과와 같은지는 직접 확인했습니다. 옛 이름 10개로 한 번 빌드한 뒤 새 이름 6개를 더해 전량 유도와 증분 유도의 결과 간선을 비교하니 같았습니다(4건). 그런데 4건 모두 옛 이름이 새 이름의 하위가 되는 역방향이었습니다. 새 이름만 보고 옛 이름을 훑지 않았다면 통째로 빠졌을 관계입니다. 새 이름이 건드리는 옛 이름을 따로 챙기는 단계가 그래서 필요했습니다. 컬럼이 없는 환경에서는 옛 경로로 돌아가 배포 순서를 타지 않게 했습니다.
후처리의 다른 병목은 실행 계획이었습니다. 자기 타입 인스턴스를 고치는 UPDATE가 라벨 등호 조인에서 trigram GIN 인덱스 플랜을 타 16분 넘게 걸렸고, 그동안 진행 메시지는 "그룹 8/10 추출 중"에 멈춰 있었습니다. (collection_id, label) btree 인덱스를 추가해 해시 조인으로 바꿨습니다.
대상 조회에도 비슷한 구조가 남아 있었습니다. 빌드 준비의 청크 스냅샷이 문서마다 Qdrant를 document_id 필터로 스크롤했는데, 이 필드에 페이로드 인덱스가 없어 요청마다 컬렉션 전체를 훑었습니다. 문서 443개·청크 30,780개 컬렉션에서 문서별 443회가 약 94초, 한 번 훑어 document_id로 묶으면 7회 0.8초였습니다. 온톨로지 페이지를 열 때의 진행 조회도 같은 함수를 쓰고 있어, 수정 후 진행 조회가 88.3초에서 1.06초가 됐습니다.
증분의 반대편: 지운 문서, 실패한 청크, 옮긴 컬렉션
증분이 정확하려면 기준선이 그래프의 실제 상태와 같아야 합니다. 기준선이 틀어지는 경로가 더 있었습니다.
문서를 지우면 기준선에서 스냅샷을 뺀 차집합이 지워진 청크입니다. 그 청크의 링크를 지우고, 근거를 잃은 인스턴스와 아무도 참조하지 않는 개념, 그 개념만 선언한 속성 노드와 닿는 간선을 정리합니다. 링크 삭제를 마지막에 둬서 중간에 실패해도 다시 실행하면 같은 결과가 나오게 했습니다. 고객 라인 검수에서 복제본의 문서 하나를 지우자 링크 9, 노드 4, 관계 2가 정리되고 기준선이 138에서 137이 됐습니다. 반대로 md 문서 하나를 올리자 자동 빌드가 새 청크 1개만 추출해 노드가 859에서 874, 기준선이 138에서 139가 됐습니다. 빌드 라이브러리로 옮긴 감분은 "걷어낸 결과가 남은 코퍼스만으로 새로 빌드한 결과와 같다"를 테스트로 확인합니다.
모델 호출이 실패한 청크도 문제였습니다. 추출기가 배치 실패를 삼키고 정상 반환해 실패한 청크까지 처리됨으로 기록됐고, 기준선에 들어간 청크는 다시는 시도되지 않았습니다. 실패한 청크를 모아 기준선에서 빼고 경고를 남기게 했습니다. 컬렉션 복제는 그래프만 복사하고 기준선을 옮기지 않아, 복제본이 백필에서 전 청크를 다시 빌드했습니다(노드 859 → 2,426). 원본의 최근 완료 잡을 새 잡 ID로 복사하게 했습니다.
다른 빌드 모드로 만든 그래프에 이어서 빌드를 걸면 새 청크가 0건이라 잡은 완료되는데 아무것도 바뀌지 않았고, 처음에는 이를 "차이 없음이니 정상"으로 오판할 뻔했습니다. 지금 그래프가 걸려는 모드로 만든 것이 아니면 처음부터 다시 빌드하게 했습니다. 10월에는 다른 작업이 실행되는 동안 올라온 문서가 다음 업로드까지 반영되지 않는 빈틈을, 작업이 끝난 뒤 미반영 청크를 이어서 빌드하는 훅으로 막았습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 이어서 빌드 LLM 호출 | 6회 · 25,755토큰 | 2회 · 7,789토큰 | 11청크 중 신규 3청크 시뮬레이션 |
| 후처리 이름 키·계층 판정 | 매 빌드 42초 + 103초 | 노드 진입 시 1회, 옛 이름 선별 0.06초 | 라벨 15만 규모, 배포 후 재측정 기록 없음 |
| 빌드 진행 조회 | 88.3초 | 1.06초 | 문서 443 · 청크 30,780, 응답 동일 |
| 업로드 1건 증분 | - | 새 청크 1개만 추출, 기준선 138 → 139 | 고객 라인 검수 복제본 |
| 문서 1건 삭제 감분 | - | 링크 9 · 노드 4 · 관계 2 정리, 기준선 138 → 137 | 같은 복제본 |
문서 단위 사전 필터링은 실측 수치를 남기지 못했습니다. 본문을 읽는 단계도 문서마다 필터 스크롤이라 약 90초가 남아 있고, 근본 해결은 Qdrant document_id keyword 인덱스인데 모든 컬렉션의 저장소 구성을 바꾸는 일이라 결정이 필요합니다. 매 빌드의 Qdrant 전수 ID 조회와 합집합 병합도 다음 단계로 남겼습니다. 감분은 제품 라인이 고아 개념을 한 번만 판정해 잔재가 남을 수 있고, 한 번 접히거나 버려진 이름이 새 근거가 와도 되살아나지 않는 순서 의존도 아직 풀지 않았습니다.
이 작업에서 남은 교훈은 두 가지입니다. 증분은 추출 한 단계의 성질이 아니라 조회, 기준선, 후처리, 감분이 모두 갖춰야 성립하는 파이프라인 전체의 성질이었습니다. 그리고 조용히 잘리는 기본값은 결함 하나가 아니라 여러 증상의 공통 원인이 될 수 있어서, 상한에는 반드시 경고가 붙어야 했습니다.