총무팀에서 또 개정된 교육비 규정을 보내왔다. 이번에는 10월부터 한도가 바뀐다고 했다. 첫 주에 봇이 개정 전 금액을 답하는 바람에 한 번 데인 적이 있어서, 개발팀은 이번에는 파일을 받자마자 색인부터 다시 돌렸다.
새 문서를 벡터로 만들어 추가하면 끝일 것 같지만, 이전 문서가 검색에 남아 있으면 질문 표현에 따라 다른 금액이 1위에 오른다. 삭제한 문서를 오래된 수집 작업이 다시 넣거나, 퇴사자의 권한이 검색용 복제본에 남는 일도 생긴다. 결국 문제는 자료의 생명주기다. 파일을 읽고, 청크로 나누고, 벡터와 메타데이터를 함께 저장하고, 한 문서의 검색용 스냅샷을 트랜잭션으로 교체하고, 시행일과 최신 권한으로 후보를 제한한다. 검색 알고리즘은 전수 검색으로 그대로 둔다. 그래야 결과가 달라졌을 때 순위를 매기는 방식 탓인지 자료 관리 탓인지 갈라 볼 수 있다.
문서의 정체성
파일 이름이나 제목으로 문서를 식별하면 이름 변경이 새 문서로 보이고, 같은 이름을 쓰는 다른 조직 자료와 충돌한다. 그래서 조직과 문서 식별자의 조합을 문서 키로 쓴다. 한울연구소의 교육비 규정은 hanul/edu다. edu는 규정 자체, v1·v2는 특정 내용의 리비전, 청크 식별자는 그 리비전에서 검색에 쓴 구간이다. 답변에 붙은 출처를 따라가면 어느 문서의 어느 판에서 뽑은 청크인지 알 수 있어야 한다.
시행 구간은 시작일을 포함하고 종료일을 제외한다. v1이 2026-01-01부터 2026-09-01 직전까지, v2가 2026-09-01부터면 9월 1일 질문에는 v2만 후보다. 같은 문서의 구간이 겹치면 수집에서 거부하고 구간 사이에 빈 날짜가 있으면 그날에는 후보가 없다. 날짜는 시간대 변환이 필요 없는 정책 시행일로 한정한다.
수집 작업은 문서가 시행된 순서를 지키지 않고 뒤섞여 도착한다. 늦게 닿은 작업이 오래된 문서를 다시 색인해 최신 리비전을 덮어쓸 수 있다. 그래서 신뢰하는 수집 계층이 매긴 원본 변경 순번을 문서별로 받는다. 호출할 때마다 그 문서의 리비전 전체를 스냅샷으로 넘긴다. 일부만 보내면 저장소는 빠진 리비전을 새 스냅샷에 없는 것으로 본다. 전체 목록은 호출자가 만든다.
| 구분 | 예 | 역할 |
|---|---|---|
| 조직·문서 키 | hanul/edu | 권한과 갱신의 단위 |
| 리비전 | v2 | 특정 원문 내용의 식별 |
| 시행 구간 | 2026-09-01부터 | 질문 기준일의 후보 결정 |
| 원본 변경 순번 | 12 | 오래된 작업의 덮어쓰기 방지 |
| 청크 식별자 | 해시 문자열 | 검색 결과와 원문 구간 연결 |
| 임베딩 공간 | 모델·리비전·전처리 설정 | 질문·문서 벡터의 호환성 |
수집기는 검색 계정이 아니라 원본 자료를 읽을 권한이 있는 별도 주체다. 갱신·권한 변경 메서드는 신뢰된 관리 경로고 모델이 부르는 업무 도구로 공개하지 않는다.
파싱과 청킹
대표 입력은 UTF-8 텍스트 파일이다. 1MB를 넘는 입력은 거부하고, 디코딩할 수 없는 바이트가 나오면 임의 문자로 바꿔 넘어가지 않고 그 자리에서 오류를 낸다.
PDF와 HTML은 이 경로가 받지 않는다. PDF에서 문자열만 뽑으면 읽기 순서와 표의 행·열 관계, 머리말과 본문 구분이 흐트러진다. 스캔한 PDF는 OCR부터 거쳐야 한다. 표가 “분기 / 30만 원”이라는 관계를 담는데 두 열이 떨어져 나오면 임베딩 전에 이미 뜻이 흐트러진다. 검색 모델을 바꾸기 전에 파싱 산출물을 표본 원문과 대조한다. pypdf 텍스트 추출 안내
청킹 함수는 줄바꿈을 LF로, 유니코드를 NFC로 정규화한다. 다만 내부 공백이나 숫자 표기, 부정 표현은 뜻이 바뀔 수 있어 손대지 않는다. 원문·리비전·시행일·임베딩 공간·청킹 설정을 묶어 SHA-256 지문을 만든다. 순번과 지문이 둘 다 같으면 unchanged로 판정하고 순번은 같은데 내용이 다르면 충돌로 보고 거부한다. 다른 문서에 같은 문장이 있어도 출처와 권한, 갱신 시점이 다르므로 전부 따로 저장한다.
청크는 검색과 인용에 쓰는 문서의 일부다. 나누는 크기가 검색 결과를 직접 바꾼다. 청크 하나의 벡터는 그 안의 토큰 전체를 평균한 값이라, 한 청크에 교육비와 휴가와 출장이 함께 들어 있으면 그 벡터는 셋 중 어디에도 가깝지 않은 자리에 놓인다. 그래서 교육비만 묻는 질문에는 그 청크가 뒤로 밀린다. 반대로 너무 잘게 나누면 ‘분기당 30만 원’만 남고 그 금액이 무엇의 한도인지, 어떤 예외가 붙는지는 다른 청크로 떨어져 나간다. 검색은 맞는 조각을 찾아냈는데 그 조각만으로는 답할 수 없다.
청크를 크게 잡을 때는 모델의 입력 한도도 걸린다. 나누는 규칙은 단순하다. 전체 토큰 수가 예산(기본 480) 안이면 그대로 두고, 넘으면 가운데 근처의 문단 경계에서 나누고, 경계가 없으면 문자 위치에서 나눈 뒤 각 부분에 같은 검사를 다시 한다. 겹침은 없다. 세기 함수는 실제 E5 입력처럼 접두사와 특수 토큰까지 포함해 세야 한다. 글자 수로 대신 세는 것은 분할 규칙만 확인할 때 쓰는 가짜다. 겹침을 주면 경계 정보 누락이 줄지만 저장량과 중복 후보가 늘어난다. 후보 셋이 사실상 같은 문단이면 다른 근거가 들어올 자리를 차지하므로, 겹침 비율과 함께 검색 후 인접 청크를 합치는 규칙도 설계한다.
SQLite 저장소
| 테이블 | 저장하는 것 |
|---|---|
| config | 임베딩 공간 식별자와 차원 |
| heads | 문서별 마지막 순번·스냅샷 지문·삭제 상태 |
| chunks | 리비전·청크·시행 구간·정규화 원문·벡터 |
| acl | 조직·문서·사용자의 현재 읽기 권한 |
대표 구현은 표준 라이브러리의 SQLite다. 별도 서버 없이 지속 저장과 트랜잭션을 쓸 수 있어 생명주기 검증에 맞는다. 벡터는 JSON 숫자 배열로 저장하고 SQL로 허용 후보를 읽은 뒤 Python에서 코사인 전수 검색을 한다. SQLite에는 근사 색인이 없기 때문이다. JSON은 들여다보기 쉽지만 크기와 파싱 비용이 커서 대규모에는 배열 타입이나 바이너리로 바꾸되 차원·자료형·정규화는 그대로 유지한다. 저장소를 열 때 지정한 공간과 차원이 기존 config와 다르면 거부한다. 한 파일에는 한 공간만 두고 모델을 바꾸면 새 저장소를 만들어 원문에서 재색인한다.
전수 검색은 허용 후보의 모든 벡터를 비교한다. 자료가 커져 지연 목표를 넘으면 일부 후보만 탐색하는 근사 최근접 이웃(ANN) 검색을 검토하는데, HNSW는 여러 층의 그래프로 탐색 범위를 좁히고 IVF 계열은 공간을 묶음으로 나눠 가까운 묶음만 본다. pgvector는 정확 검색과 HNSW·IVFFlat 색인을 모두 제공한다. 근사 검색을 평가할 때는 같은 벡터·필터·k로 전수 결과를 먼저 구한 뒤, 전수 상위 집합을 근사가 얼마나 다시 찾아냈는지 잰다. 이 재현율은 후보 목록에 들어왔는지까지만 잰다. 임베딩이 틀린 문서를 1위에 놓으면 ANN이 그 순위를 그대로 재현해도 업무 품질은 나아지지 않기 때문이다. 권한 필터가 좁은 사용자와 넓은 사용자도 나눠 본다. HNSW 원 논문, pgvector 색인 안내
권한을 검색에 반영하기
검색 권한은 모델이 판단하지 않는다. 검색기는 SQL에서 조직·사용자의 ACL과 시행 구간을 먼저 적용하고, 읽어 온 후보를 요청 주체가 읽을 수 있는 문서 목록과 다시 교차한 뒤 벡터를 복원해 점수를 낸다. 값은 SQL 문자열에 넣지 않고 매개변수로 넘긴다. Python sqlite3 매개변수 바인딩
ACL은 조회한 시점의 값이다. 검색 직후 권한이 회수되는 경쟁 조건은 이 함수 하나로 없어지지 않는다. 후보를 외부 모델에 보내거나 최종 답변을 내보내는 단계에서 권한을 다시 검사한다. 이미 밖으로 나간 자료는 사후 검사로 되돌릴 수 없으니, 권한을 거둔 것이 언제부터 효력을 내는지를 요청 처리 단계와 함께 정한다. Qdrant처럼 payload 필터를 제공하는 저장소를 써도 그 필터가 이 서비스의 정책대로 걸리는지는 직접 확인해야 한다. 모든 검색 경로가 필터를 강제하는지, 빠지면 거부하는지, ACL 갱신이 언제 반영되는지를 검사한다. Qdrant 필터링 안내
확인할 때는 같은 주체를 그대로 둔 채 저장소의 권한만 지우고 결과가 비는지 본다. 주체가 바뀌어서 통과하는 약한 확인을 피하려는 것이다. 다른 조직에 같은 문서 ID와 사용자 ID가 있어도 교차 접근이 막히는지도 본다.
갱신·삭제·재색인
문서를 갱신할 때는 청크와 임베딩을 메모리에서 먼저 만들어 둔다. 그래야 입력 오류나 모델 실패가 나도 DB의 기존 자료가 그대로 남는다. 벡터의 공간·차원·유효성을 다 검사한 뒤 짧은 쓰기 트랜잭션을 연다. SQLite의 BEGIN IMMEDIATE는 쓰기 트랜잭션을 바로 시작하고, 다른 쓰기가 진행 중이면 대기 설정에 따라 실패한다. 동시 읽기는 되지만 쓰기는 한 시점에 하나라서 느린 임베딩을 트랜잭션 밖에 둔다. SQLite 트랜잭션 문서
임베딩 전에 최신 순번을 봤더라도 그 사이 더 새로운 작업이 끝날 수 있다. 그래서 교체 직전 트랜잭션 안에서 순번을 다시 확인한다. 테스트는 순번 2의 벡터를 만드는 동안 별도 연결에서 순번 3을 저장하고, 뒤늦게 끝난 2가 거부되는지 본다. 새 청크 삽입 중 오류가 나면 롤백한다. 테스트는 삽입을 실패시키는 SQLite 트리거를 심어 이 경로를 확인한다. 임베딩 함수만 실패시키면 트랜잭션 안의 원자성은 못 본다. 재처리 입력도 벡터를 먼저 만든 뒤 DB에서 중복을 판정하므로 저장은 중복되지 않지만 계산 비용은 든다.
빈 리비전 목록은 삭제 스냅샷이다. 청크와 ACL은 지우되 heads의 마지막 순번과 삭제 표시(tombstone)는 남기는데, 삭제 뒤 오래된 수집 이벤트가 들어와도 저장소가 새 문서로 착각하지 않기 위해서다. 삭제한 문서는 순번 13으로 다시 보내면 되살릴 수 있지만 삭제 전 ACL은 자동으로 돌아오지 않으므로, 복원한 자료를 누가 읽을 수 있는지는 다시 정한다. 물리적 삭제 요구가 있으면 원본 저장소·백업·모델 요청 기록·응답 캐시에 별도 정책을 둔다.
임베딩 모델이나 청킹 규칙을 바꾸면 같은 질문의 결과도 바뀐다. 운영 파일을 덮어쓰는 대신 새 저장소를 만들어 같은 원문 스냅샷을 재처리하고, 문서 수·청크 수·실패 목록·권한과 시행일 경계·정답 질의 순위를 확인한 뒤 읽기 경로를 바꾼다. 전환 중 원본이 계속 바뀌면 시작 시점의 순번을 기록하고 이후 변경을 새 저장소에도 반영한다. 이전 저장소로 되돌릴 때도 그 저장소의 ACL이 오래됐으면 정보가 새므로 현재 권한을 다시 적용한다.
저장소 선택
| 선택지 | 검토할 조건 | 확인할 비용과 제약 |
|---|---|---|
| SQLite + Python 전수 검색 | 작은 자료에서 로컬 지속 저장과 변경 규칙을 검증한다 | 전체 후보 로드 · JSON 파싱 · 단일 쓰기 구간 |
| PostgreSQL + pgvector | 기존 관계형 데이터와 벡터 질의를 함께 관리한다 | 색인 설정, 필터 포함 실행 계획, DB 운영 부담 |
| 별도 벡터 저장소 | 벡터 검색의 용량·확장·운영을 독립적으로 다룬다 | 권한·원본 복제의 일관성, 장애·복구 범위 |
제품을 고르기 전에 필요한 규칙을 적는다. 원문·벡터·권한 변경을 어느 범위에서 함께 커밋해야 하는가, 후보 수와 동시성은 얼마인가, 삭제가 검색에 반영되는 시간을 어떻게 재는가, 정확 검색과 근사 검색을 둘 다 비교할 수 있는가, 백업·복원이 공간 식별자를 보존하는가. 교체 비용은 클라이언트 호출을 바꾸는 비용보다 크다. 벡터 직렬화, 거리 함수, 동점 순서, 필터 의미, 삭제 가시성, 식별자와 오류 상태가 다 달라진다. 검색기가 돌려주는 형식을 고정하고 같은 권한·시행일·정답 질의로 대체 구현을 시험하면 어디가 다른지 바로 보인다. 저장 기술을 바꿔도 정확한 원본 리비전을 찾고, 허용된 자료만 돌려주고, 실패한 갱신이 정상 자료를 부수지 않는다는 규칙은 그대로다. pgvector, Qdrant 필터
10월 개정판을 받은 날 개발팀은 색인부터 돌렸는데, 색인만 다시 돌려서는 9월판이 그대로 검색에 남아 있었다. 그래서 새 판을 넣는 일과 옛 판을 내리는 일을 따로 두지 않고, 한 문서의 스냅샷을 통째로 바꾸기로 했다.