지난 글 끝에서 권한 값이 낡으면 필터가 소용없다는 이야기를 했다. 그 문제가 가장 커지는 자리가 여기다 — 한 시스템이 여러 고객사의 문서를 함께 다룰 때.
사내용 RAG에서는 검색이 조금 새도 대개 사고까지는 안 간다. 같은 회사 사람이 못 볼 문서를 본 것이다. 그런데 다른 회사의 문서가 검색되면 그것은 품질 문제가 아니라 계약 위반이고, 대개 한 건으로도 서비스가 끝난다. 그래서 이 설계는 성능보다 격리를 먼저 놓고 시작한다.
가르는 방법이 셋이다
고객사마다 색인을 따로 두는 방식이 가장 세다. 파일이 아예 다르니 조건을 빼먹어도 남의 것이 나올 수 없다. 고객사별로 임베딩 모델이나 청크 크기를 다르게 가져갈 수도 있고, 이탈하면 색인 하나를 지우면 끝난다.
한계는 개수다. 근사 탐색 색인은 하나마다 고정 비용이 붙는다 — 그래프 구조, 메모리에 상주하는 부분, 연결 자원. 문서가 열 개뿐인 고객사도 그 비용을 그대로 쓴다. 수백 개까지는 버티지만 수천 개가 되면 대부분이 거의 빈 색인인 채로 메모리를 먹는 상황이 된다.
한 색인 안에 칸을 나누는 방식이 절충이다. 저장소마다 이름이 다르지만(네임스페이스, 파티션, 컬렉션 안의 구획) 하는 일은 같다. 검색할 때 칸을 지정하면 그 안에서만 찾고, 다른 칸의 벡터는 탐색 대상에 아예 안 들어간다. 조건으로 거르는 것이 아니라 찾는 범위 자체가 다르다는 점이 중요하다. 앞 글에서 본 선택도 문제가 생기지 않는다.
값 하나로 구분하는 방식은 모든 청크에 tenant 값을 넣고 검색할 때마다 조건을 거는 것이다. 비용이 거의 안 붙어 고객사가 수만이어도 된다. 대신 격리가 조건 하나에 달려 있다. 그 조건을 빠뜨린 코드 경로가 하나만 있어도 샌다.
| 전용 색인 | 색인 안의 칸 | 값으로 구분 | |
|---|---|---|---|
| 격리 | 물리적 | 탐색 범위가 갈림 | 조건 하나 |
| 고객사당 고정 비용 | 크다 | 작다 | 거의 없다 |
| 현실적 상한 | 수백 | 수만 | 사실상 없음 |
| 고객사별 설정 | 자유롭다 | 일부 가능 | 어렵다 |
| 이탈 처리 | 색인을 지운다 | 칸을 지운다 | 조건으로 골라 지운다 |
하나를 고르는 문제가 아니다. 문서가 많고 요구가 까다로운 큰 고객사 몇 곳만 전용 색인으로 떼고, 나머지 대다수는 한 색인에 담는 구성이 흔하다. 요금제와 나란히 가는 경우도 많다.
조건 하나에 맡기지 않는다
값으로 구분하는 방식을 쓴다면 — 대부분 여기서 시작한다 — 격리를 코드 한 줄에 걸어 두지 않는 것이 핵심이다.
테넌트는 요청에서 받지 않는다. 세션이나 인증 토큰에서 서버가 정한다. 요청 본문에 tenant 값을 받아 그대로 쓰는 코드는 그 값을 바꾸면 남의 데이터가 나온다는 뜻이다. 모델이 질의에서 뽑아내게 하는 것은 더 나쁘다 — 프롬프트 주입의 표적이 되기 딱 좋다.
조건은 검색 계층 안쪽에서 붙인다. 호출하는 쪽이 조건을 넘기게 하지 말고, 검색 함수가 세션에서 읽어 무조건 덧붙이게 만든다. 그러면 새 기능을 만들다 빠뜨릴 여지가 없다.
def search(query, k=10, filters=None):
f = dict(filters or {})
f["tenant"] = current_session.tenant_id # 넘겨받지 않고 여기서 정한다
hits = store.query(embed(query), k=k, filter=f)
return [h for h in hits if h.meta["tenant"] == f["tenant"]] # 한 번 더 본다
마지막 줄이 세 번째 층이다. 결과에 다른 테넌트의 것이 섞여 있으면 버리고 경보를 올린다. 정상이라면 절대 걸리지 않는 검사이므로 평소에는 비용이 0에 가깝다. 걸린다면 조건이 안 먹었거나 적재 때 값이 잘못 들어갔다는 뜻이고, 그 사실을 아는 것 자체가 이 검사의 값어치다.
캐시와 로그도 함께 갈라야 한다. 격리를 꼼꼼히 해 놓고 질의 결과 캐시의 키를 질의 문자열로만 만들면, 같은 질문을 한 다른 고객사에게 앞 사람의 답이 그대로 간다. 임베딩 캐시는 질의 텍스트만 다루니 공유해도 되지만, 검색 결과와 생성된 답의 캐시에는 테넌트를 키에 넣는다. 로그도 마찬가지다 — 근거 문서 내용이 로그에 남는데 로그 색인이 하나면 운영 화면에서 다 보인다.
시끄러운 이웃
격리를 다 해도 자원은 공유한다. 한 고객사가 문서 백만 건을 한꺼번에 올리면 색인 작업이 그쪽으로 쏠리고, 그동안 다른 고객사의 갱신은 대기열 뒤에 선다. 앞 글에서 본 처리 지연이 남의 사정으로 몇 시간이 되는 것이다.
셋으로 나눠 막는다.
- 대기열을 고객사별로 나눈다. 하나의 큰 대기열 대신 고객사마다 줄을 세우고 돌아가며 처리하면, 대량 적재가 남의 순서를 밀어내지 못한다
- 적재 속도에 상한을 둔다. 시간당 처리할 문서 수를 고객사별로 정해 둔다. 급한 대량 이관은 별도 경로로 받는다
- 검색 쪽에도 동시 실행 상한을 둔다. 속도 제한에서 다룬 그 장치가 여기서도 필요하다. 한 고객사의 트래픽 급증이 전체 지연으로 번지는 것을 막는다
이탈을 미리 설계해 둔다
계약이 끝나면 데이터를 지워야 하고, 대개 기한과 증빙이 함께 요구된다. 이것을 나중에 생각하면 곤란해지는 이유는 데이터가 색인 한 곳에만 있는 것이 아니어서다.
지울 자리를 세어 보면 보통 이만큼이다.
| 어디 | 흔히 빠뜨리는 이유 |
|---|---|
| 벡터 색인의 청크 | 여기는 안 빠뜨린다 |
| 원문 보관소 | 파싱 전 원본 파일을 따로 두는 경우가 많다 |
| 결과·응답 캐시 | 만료만 걸어 두고 삭제 대상으로 안 본다 |
| 질의 로그·대화 기록 | 보존 기간 정책과 삭제 요구가 서로 어긋난다 |
| 평가용 데이터셋 | 실제 질의를 골라 만들어 둔 것이 남는다 |
| 백업 | 지운 뒤 만든 백업에는 없지만 옛 백업에는 있다 |
전용 색인 방식이 여기서 값을 한다. 색인 하나와 그에 딸린 저장소를 지우면 끝나므로 증빙도 간단하다. 값으로 구분하는 방식은 위 목록을 하나씩 훑어야 하고, 그래서 삭제 절차를 코드로 만들어 두고 시험 고객사로 정기적으로 돌려 보는 편이 안전하다. 계약 종료 통보를 받고 처음 짜기 시작하면 반드시 뭔가 빠진다.
앞 글에서 다룬 지움 표시가 여기서도 쓸모 있다. 계약 종료 즉시 표시를 세워 검색에서 빼고, 유예 기간이 지난 뒤 실제로 지우면 노출은 곧바로 멎으면서 되돌릴 여지도 남는다.
평가도 고객사마다 다르다
마지막으로 자주 놓치는 것 하나. 전체 평균 검색 품질이 좋아도 특정 고객사에서는 나쁠 수 있다. 문서 종류가 다르고, 용어가 다르고, 질문 방식이 다르기 때문이다.
RAG 평가를 돌릴 때 결과를 고객사별로 나눠 보면 대개 편차가 꽤 크다. 문서가 많은 몇 곳이 평균을 끌어올리고, 문서가 적거나 형식이 특이한 곳은 평균 아래에 조용히 깔려 있다. 그리고 불만은 그 아래쪽에서 나온다.
작은 고객사에서 잘 되는지를 따로 본다. 문서가 스무 건뿐인 고객사에서는 검색이 뽑을 후보 자체가 적어, 어떤 질문에도 그 스무 건 중 열 개가 나온다. 관련 없는 것을 근거로 답을 만들지 않도록 최소 점수 기준을 두고, 못 미치면 「자료에 없습니다」라고 답하게 하는 편이 낫다. 이 판단은 고객사 규모에 따라 달라져야 한다.
정리
- 가르는 방법은 전용 색인 · 색인 안의 칸 · 값으로 구분 셋이고, 격리와 비용이 맞바뀐다
- 전용 색인은 개수가 벽이다. 색인마다 고정 비용이 붙어 빈 색인도 자원을 쓴다
- 칸으로 나누면 탐색 범위 자체가 갈려 선택도 문제가 안 생긴다
- 값으로 구분할 때는 층을 여러 겹 둔다 — 세션에서 확정, 계층에서 강제 주입, 결과 재확인
- 캐시 키와 로그에도 테넌트를 넣는다. 격리를 다 해 놓고 캐시로 새는 일이 흔하다
- 대기열과 속도 상한을 고객사별로 나눠 대량 적재가 남을 밀지 않게 한다
- 이탈 삭제는 색인 밖에도 자리가 많다. 절차를 코드로 만들어 미리 돌려 본다
- 평가는 평균이 아니라 고객사별로 나눠 본다. 작은 고객사가 평균 아래에 깔린다
읽어주셔서 감사합니다. 😊

