교육비 한도가 9월부터 바뀌었다. 새 문서를 벡터로 만들어 추가하면 끝일까. 이전 문서가 검색에 남아 있으면 질문 표현에 따라 다른 금액이 1위에 오른다. 삭제한 문서를 오래된 수집 작업이 다시 넣고, 퇴사자의 권한이 검색용 복제본에 남는 일도 생긴다. 문제는 자료의 생명주기다. 파일을 읽고, 청크로 나누고, 벡터와 메타데이터를 함께 저장하고, 한 문서의 검색용 스냅샷을 트랜잭션으로 교체하고, 시행일과 최신 권한으로 후보를 제한한다. 검색은 전수 검색을 그대로 둬서 순위 알고리즘의 변화와 데이터 관리의 변화를 따로 검증한다.
문서의 정체성
파일 이름이나 제목으로 문서를 식별하면 이름 변경이 새 문서로 보이고, 같은 이름을 쓰는 다른 조직 자료와 충돌한다. 예제는 조직과 문서 식별자의 조합을 문서 키로 쓴다. 한울연구소의 교육비 규정은 hanul/edu다. edu는 규정 자체, v1·v2는 특정 내용의 판, 청크 식별자는 그 판에서 검색에 쓴 구간이다. 최종 답변의 출처는 청크에서 문서와 판으로 되돌아갈 수 있어야 한다.
@dataclass(frozen=True)
class Revision:
id: str
text: str
valid_from: str
valid_until: str | None = None
시행 구간은 시작일을 포함하고 종료일을 제외한다. v1이 2026-01-01부터 2026-09-01 직전까지, v2가 2026-09-01부터면 9월 1일 질문에는 v2만 후보다. 같은 문서의 구간이 겹치면 수집에서 거부하고, 구간 사이에 빈 날짜가 있으면 그날에는 후보가 없다. 날짜는 시간대 변환이 필요 없는 정책 시행일로 한정한다.
문서의 시행 순서와 수집 이벤트의 도착 순서는 다르다. 뒤늦게 도착한 작업이 오래된 문서를 다시 색인할 수 있어서, 예제는 신뢰하는 수집 계층이 매긴 원본 변경 순번 sequence를 문서별로 받는다. 각 호출은 그 문서의 전체 리비전 스냅샷을 넘긴다. 일부만 보내면 나머지는 새 스냅샷에 없는 것으로 처리되므로, 호출자가 전체 목록을 만들 책임이 있다.
| 구분 | 예 | 역할 |
|---|---|---|
| 조직·문서 키 | hanul/edu | 권한과 갱신의 단위 |
| 리비전 | v2 | 특정 원문 내용의 식별 |
| 시행 구간 | 2026-09-01부터 | 질문 기준일의 후보 결정 |
| 원본 변경 순번 | 12 | 오래된 작업의 덮어쓰기 방지 |
| 청크 식별자 | 해시 문자열 | 검색 결과와 원문 구간 연결 |
| 임베딩 공간 | 모델·리비전·전처리 설정 | 질문·문서 벡터의 호환성 |
수집기는 검색 계정이 아니라 원본 자료를 읽을 권한이 있는 별도 주체다. 갱신·권한 변경 메서드는 신뢰된 관리 경로고, 모델이 부르는 업무 도구로 공개하지 않는다.
파싱과 청킹
대표 입력은 UTF-8 텍스트 파일이다. read_utf8는 1MB를 넘는 입력을 거부하고 디코딩할 수 없는 바이트는 오류로 처리한다. 잘못된 바이트를 임의 문자로 바꿔 계속 가지 않는다. 실습 파일은 examples/fixtures/policies/edu-v1.txt와 edu-v2.txt다.
source = Path(__file__).parent / "fixtures" / "policies"
text = read_utf8(source / "edu-v2.txt")
PDF와 HTML은 이 코드가 지원하지 않는다. PDF는 읽기 순서, 표의 행·열 관계, 머리말과 본문 구분이 단순 문자열 추출과 어긋나고, 스캔 PDF는 OCR이 필요하다. 표가 “분기 / 30만 원”이라는 관계를 담는데 두 열이 떨어져 나오면 임베딩 전에 이미 뜻이 깨진다. 검색 모델을 바꾸기 전에 파싱 산출물을 표본 원문과 대조한다. pypdf 텍스트 추출 안내
청킹 함수는 줄바꿈을 LF로, 유니코드를 NFC로 정규화한다. 내부 공백이나 숫자 표기, 부정 표현은 손대지 않는다. 뜻이 바뀔 수 있기 때문이다. 원문·리비전·시행일·임베딩 공간·청킹 설정을 묶어 SHA-256 지문을 만들고, 같은 순번에 같은 지문이면 unchanged, 같은 순번에 다른 내용이면 충돌로 거부한다. 다른 문서에 같은 문장이 있어도 하나를 지우지 않는다. 출처와 권한, 갱신 시점이 다르기 때문이다.
count = lambda text: len(model.tokenizer("passage: " + text)["input_ids"])
parts = chunks(text, count, limit=480)
청크는 검색과 인용에 쓰는 문서의 일부다. 너무 크면 여러 주제가 섞이고 입력 한도를 넘고, 너무 작으면 제목·적용 대상·예외 조건이 떨어져 나간다. chunks는 전체 토큰 수가 예산(기본 480) 안이면 그대로 두고, 넘으면 가운데 근처의 문단 경계에서 나누고, 경계가 없으면 문자 위치에서 나눈 뒤 각 부분에 같은 검사를 다시 한다. 겹침은 없다. 계수 함수는 실제 E5 입력처럼 접두사와 특수 토큰을 포함해야 하고, len(text)를 쓰는 단위 테스트는 분할 규칙만 검증하는 대역이다. 겹침을 주면 경계 정보 누락이 줄지만 저장량과 중복 후보가 늘어난다. 후보 셋이 사실상 같은 문단이면 다른 근거가 들어올 자리를 차지하므로, 겹침 비율과 함께 검색 후 인접 청크를 합치는 규칙도 설계한다.
SQLite 저장소
| 테이블 | 저장하는 것 |
|---|---|
| config | 임베딩 공간 식별자와 차원 |
| heads | 문서별 마지막 순번·스냅샷 지문·삭제 상태 |
| chunks | 리비전·청크·시행 구간·정규화 원문·벡터 |
| acl | 조직·문서·사용자의 현재 읽기 권한 |
대표 구현은 표준 라이브러리의 SQLite다. 별도 서버 없이 지속 저장과 트랜잭션을 쓸 수 있어 생명주기 검증에 맞는다. 벡터는 JSON 숫자 배열로 저장하고, SQL로 허용 후보를 읽은 뒤 Python에서 코사인 전수 검색을 한다. SQLite에 근사 색인이 있는 것은 아니다. JSON은 들여다보기 쉽지만 크기와 파싱 비용이 커서 대규모에는 배열 타입이나 바이너리로 바꾸되 차원·자료형·정규화는 그대로 유지한다. 저장소를 열 때 지정한 공간과 차원이 기존 config와 다르면 거부한다. 한 파일에는 한 공간만 두고, 모델을 바꾸면 새 저장소를 만들어 원문에서 재색인한다.
전수 검색은 허용 후보의 모든 벡터를 비교한다. 자료가 커져 지연 목표를 넘으면 일부 후보만 탐색하는 근사 최근접 이웃(ANN) 검색을 검토한다. HNSW는 여러 층의 그래프로 탐색 범위를 좁히고, IVF 계열은 공간을 묶음으로 나눠 가까운 묶음만 본다. pgvector는 정확 검색과 HNSW·IVFFlat 색인을 모두 제공한다. 근사 검색을 평가할 때는 같은 벡터·필터·k로 전수 결과를 먼저 구하고, 전수 상위 집합을 근사가 얼마나 회수했는지 잰다. 이것은 ‘업무에 필요한 정답 문서를 찾았는가’와 다른 재현율이다. 임베딩이 틀린 문서를 1위에 놓으면 ANN이 그대로 재현해도 업무 품질은 그대로다. 권한 필터가 좁은 사용자와 넓은 사용자도 나눠 본다. ANN 엔진은 돌리지 않았고 전수 저장소로 생명주기만 검증했다. HNSW 원 논문, pgvector 색인 안내
권한을 검색에 반영하기
SELECT c.*
FROM chunks c JOIN acl a
ON c.tenant = a.tenant AND c.doc = a.doc
WHERE c.tenant = ? AND a.user = ? AND c.valid_from <= ?
AND (c.valid_until IS NULL OR ? < c.valid_until)
검색 권한은 모델이 판단하지 않는다. retrieve는 SQL에서 조직·사용자의 ACL과 시행 구간을 먼저 적용하고, 읽어 온 후보를 요청 주체의 readable_docs와 다시 교차한 뒤 벡터를 복원해 점수를 낸다. 값은 SQL 문자열에 넣지 않고 매개변수로 넘긴다. Python sqlite3 매개변수 바인딩
ACL은 조회한 시점의 값이다. 검색 직후 권한이 회수되는 경쟁 조건은 이 함수 하나로 없어지지 않는다. 후보를 외부 모델에 보내거나 최종 답변을 내보내는 단계에서 권한을 다시 검사하고, 이미 밖으로 나간 자료는 사후 검사로 되돌릴 수 없으니 권한 회수가 언제부터 효력을 갖는지 요청 처리 단계와 함께 정한다. Qdrant처럼 payload 필터를 제공하는 저장소도 필터가 있다는 사실과 우리 정책이 맞게 구현됐다는 사실은 다르다. 모든 검색 경로가 필터를 강제하는지, 빠지면 거부하는지, ACL 갱신이 언제 반영되는지를 검사한다. Qdrant 필터링 안내
테스트는 같은 Principal 객체를 든 채 DB의 ACL을 지우고 결과가 빈 목록이 되는지 본다. 주체 객체가 바뀌어서 통과하는 약한 테스트를 피하고, 다른 조직에 같은 문서 ID와 사용자 ID가 있어도 교차 접근이 안 되는 경로도 본다.
갱신·삭제·재색인
문서 갱신은 메모리에서 청크와 임베딩을 먼저 준비한다. 입력 오류나 모델 실패가 여기서 나면 DB의 기존 자료는 그대로다. 벡터의 공간·차원·유효성을 다 검사한 뒤 짧은 쓰기 트랜잭션을 연다. SQLite의 BEGIN IMMEDIATE는 쓰기 트랜잭션을 바로 시작하고, 다른 쓰기가 진행 중이면 대기 설정에 따라 실패한다. 동시 읽기는 되지만 쓰기는 한 시점에 하나라서 느린 임베딩을 밖에 둔다. SQLite 트랜잭션 문서
임베딩 전에 최신 순번을 봤더라도 그 사이 더 새로운 작업이 끝날 수 있다. 그래서 교체 직전 트랜잭션 안에서 순번을 다시 확인한다. 테스트는 순번 2의 벡터를 만드는 동안 별도 연결에서 순번 3을 저장하고, 뒤늦게 끝난 2가 거부되는지 본다. 새 청크 삽입 중 오류가 나면 롤백한다. 테스트는 삽입을 실패시키는 SQLite 트리거를 심어 이 경로를 확인한다. 임베딩 함수만 실패시키면 트랜잭션 안의 원자성은 못 본다. 재처리 입력도 벡터를 먼저 만든 뒤 DB에서 중복을 판정하므로 저장은 중복되지 않지만 계산 비용은 든다.
빈 리비전 목록은 삭제 스냅샷이다. 청크와 ACL은 지우고 heads의 마지막 순번과 삭제 표시(tombstone)는 남긴다. 삭제 뒤 오래된 수집 이벤트가 와도 새 문서로 오해하지 않기 위해서다. 순번 13의 명시적 복원은 되지만 삭제 전 ACL은 자동으로 돌아오지 않는다. 복원한 자료를 누가 읽을 수 있는지는 다시 정한다. 물리적 삭제 요구가 있으면 원본 저장소·백업·모델 요청 기록·응답 캐시에 별도 정책을 둔다.
임베딩 모델이나 청킹 규칙을 바꾸면 같은 질문의 결과도 바뀐다. 운영 파일을 덮어쓰는 대신 새 저장소를 만들어 같은 원문 스냅샷을 재처리하고, 문서 수·청크 수·실패 목록·권한과 시행일 경계·정답 질의 순위를 확인한 뒤 읽기 경로를 바꾼다. 전환 중 원본이 계속 바뀌면 시작 시점의 순번을 기록하고 이후 변경을 새 저장소에도 반영한다. 이전 저장소로 되돌릴 때도 그 저장소의 ACL이 오래됐으면 정보가 새므로 현재 권한을 다시 적용한다. 두 색인의 자동 동기화나 온라인 전환은 구현하지 않았다.
저장소 선택
| 선택지 | 검토할 조건 | 확인할 비용과 제약 |
|---|---|---|
| SQLite + Python 전수 검색 | 작은 자료에서 로컬 지속 저장과 변경 규칙을 검증한다 | 전체 후보 로드 · JSON 파싱 · 단일 쓰기 구간 |
| PostgreSQL + pgvector | 기존 관계형 데이터와 벡터 질의를 함께 관리한다 | 색인 설정, 필터 포함 실행 계획, DB 운영 부담 |
| 별도 벡터 저장소 | 벡터 검색의 용량·확장·운영을 독립적으로 다룬다 | 권한·원본 복제의 일관성, 장애·복구 범위 |
제품을 고르기 전에 필요한 규칙을 적는다. 원문·벡터·권한 변경을 어느 범위에서 함께 커밋해야 하는가, 후보 수와 동시성은 얼마인가, 삭제가 검색에 반영되는 시간을 어떻게 재는가, 정확 검색과 근사 검색을 둘 다 비교할 수 있는가, 백업·복원이 공간 식별자를 보존하는가. 교체 비용은 클라이언트 호출을 바꾸는 비용보다 크다. 벡터 직렬화, 거리 함수, 동점 순서, 필터 의미, 삭제 가시성, 식별자와 오류 상태가 다 달라진다. retrieve의 반환 형식을 고정하고 같은 권한·시행일·정답 질의로 대체 구현을 시험하면 차이가 드러난다. 저장 기술을 바꿔도 정확한 원본 판을 찾고, 허용된 자료만 돌려주고, 실패한 갱신이 정상 자료를 부수지 않는다는 규칙은 그대로다. pgvector, Qdrant 필터
실습: 문서 생명주기를 끝까지 실행하기
python books/ai-engineering/examples/store_demo.py
python -m unittest discover -s books/ai-engineering/examples -p 'test_*.py' -q
전체 구현은 examples/document_store.py, 실제 모델 시연은 store_demo.py다. 시연은 임시 디렉터리에 DB를 만들고 끝나면 지운다. 실제 실행은 SQLite 3.53.2와 E5 고정 리비전으로 했고 결과는 experiments/store-lifecycle.json에 있다.
- 시연을 돌려 8월 31일 질문에 20만 원인
v1, 9월 8일 질문에 30만 원인v2가 나오는지 본다. 파일을 닫고 다시 열어도 같아야 한다. 같은 주체의 ACL을 회수하면 빈 목록, 삭제 뒤 오래된 순번을 다시 보내면 거부다. - 단위 테스트는 입력 디코딩, 분할 경계, 조직과 사용자 격리, 시행 구간, 중복 이벤트, 삭제 기록, 모델 실패, DB 삽입 실패, 갱신 경쟁을 다룬다.
- 긴 규정의 끝에 예외 조항을 넣고 청크를 본다. 답에 본문과 예외가 다 필요한 질문에서 상위 후보가 둘 다 포함하는지 확인한다. 새 판 수집 중 임베딩 오류를 일으켜 기존 답변 근거가 유지되는지도 본다.
이제 검색 결과는 원문에서 만든 청크고, 시행일과 현재 권한을 통과한 자료다.