SHIFTONE

사내 문서 RAG 권한 관리, 필터는 검색 앞인가요 뒤인가요 — 부서 문서가 남의 답변에 섞이는 걸 20문항으로 재봤어요

사내 문서 RAG 는 검색 품질보다 권한에서 먼저 사고가 나요. 권한 필터가 없을 때 · 검색 뒤에 걸 때 · 검색 앞에 걸 때를 같은 골든셋 20문항으로 실측해 누출률과 답이 사라지는 비율을 비교하고, 선필터가 요구하는 ACL 동기화까지 정리했어요.

콘크리트 벽에 붙은 검은 카드 리더기와 그 너머의 닫힌 문 — 검색 앞에서 권한을 확인하는 RAG 필터

일반 직원 계정으로 "야근 식대 한도가 얼마예요"라고 물었어요. 검색기가 1순위로 올린 조각은 경비 규정이 아니라 재무팀 내부 감사 보고서였어요. "오후 8시 이전 퇴근 기록과 야근 식대 정산이 겹치는 건을 표본 점검함." 재현 실험이었으니 다행이지, 운영이었다면 사고예요.

검색이 못한 게 아니에요. 문제는 누가 묻는지를 검색기가 몰랐다는 거예요.

권한 문제는 검색 품질과 다른 자리에서 터져요

파일럿 때는 공개 규정만 넣으니 권한이 문제될 게 없어요. 운영으로 넘어가며 부서 폴더를 통째로 색인하는 순간부터 "일부만 봐야 하는 문서"가 섞여요. 검색기는 유사도만 보니까, 감사 보고서가 질문 어휘를 더 담고 있으면 감사 보고서가 위로 올라와요. OWASP LLM Top 10 (2025) 이 LLM08 "벡터 · 임베딩 취약점"에서 권한을 아는 벡터 저장소를 권고하는 이유예요.

권한 필터를 둘 수 있는 자리는 세 곳이에요. 없거나, 검색 뒤에 두거나, 검색 앞에 두거나. 저희는 이 셋을 같은 골든셋으로 재봤어요.

A. 필터 없음 질문일반 직원 검색기 · 전체 색인공개 8건 + 제한 15건 상위 k 조각제한 문서가 섞임 (k=3: 80%) 생성기그대로 인용 → 누출

B. 후필터 질문일반 직원 검색기 · 전체 색인상위 k 반환 권한 필터권한 없는 조각 제거 남은 조각k=1: 45% 비어요 → 답 실종

C. 선필터 질문 + 그룹일반 직원 검색기 · 권한 조각만그룹 ⊂ 조각의 ACL 상위 k 조각전부 열람 가능 · 자리 손실 없음 생성기Recall@3 1.00 조건: 조각마다 ACL 이 색인에 들어 있고, 원본 권한이 바뀌면 색인도 따라가야 해요.

세팅 — 공개 규정 8건에 부서 전용 문서 15건을 섞었어요

지난 골든셋 글의 재현용 코퍼스와 20문항에, 감사 결과 · 예외 승인 대장처럼 공개 규정과 어휘는 같은데 담당 부서만 봐야 하는 문서를 부서별 3건씩 더했어요. 전부 이 글을 위해 지어낸 문서이고 고객사 데이터는 없어요.

항목
문서공개 8건 (전 직원) + 제한 15건 (부서별 3건)
조각300자 · 겹침 80자 → 32개, 일반 직원이 볼 수 있는 조각 16개
검색기문자 2-gram BM25 (지난 글과 동일)
질문골든셋 20문항, 정답은 전부 공개 규정에 있음
잰 것누출률(상위 k 에 권한 없는 조각 포함) · 빈 결과율 · Recall@k

결과 — 없으면 열에 여덟은 새고, 뒤에 걸면 열에 넷은 답이 사라져요

kA. 필터 없음 — 누출률B. 후필터 — 빈 결과B. 후필터 Recall@kB'. 10개 받아 후필터C. 선필터 Recall@k
10.450.450.500.900.90
20.650.050.901.001.00
30.800.001.001.001.00

일반 직원 계정 기준이에요. 필터가 없으면 k=3 에서 스무 문항 중 열여섯 문항의 컨텍스트에 부서 전용 조각이 들어갔고, 1순위가 제한 문서인 문항도 아홉이었어요. 인사팀 계정으로 바꿔도 누출률은 0.60 이에요. 재무 · IT 문서는 여전히 남의 것이니까요. 권한은 "직원 대 관리자"가 아니라 부서마다 다른 격자예요.

후필터는 새지는 않아요. 대신 답이 사라져요. k=1 이면 절반의 문항에서 유일한 조각이 제한 문서라 지우고 나니 아무것도 안 남았어요. 화면에는 "관련 문서를 찾지 못했어요"가 뜨고, 담당자는 검색 품질을 의심하게 돼요. k=3 에서는 회복됐지만 제한 문서가 15건뿐이라서예요. 실제 코퍼스는 부서 문서가 훨씬 많고, 그 비율에서도 버티는지는 확인하지 못했어요.

선필터의 Recall@1 은 0.90, 지난 글에서 같은 청크 설정으로 잰 값과 똑같아요. 제한 문서가 애초에 후보에 없으니까요.

후필터가 무너진 건 순위가 아니라 자리 수 때문이에요

검색기는 k 개 자리를 채웠고, 그 자리를 권한 없는 조각이 먼저 차지한 거예요. 지운 뒤 빈 자리는 다시 채워지지 않아요. 그래서 흔히 k 의 서너 배를 받아 놓고 거르는 과다 조회(표의 B')를 써요. 여기서는 잘 됐지만, 질문 어휘가 제한 문서에 몰려 있으면 10개를 받아도 전부 제한 문서일 수 있어요. 보장이 아니라 확률이에요.

벡터 검색도 같은 고민을 먼저 했어요. Qdrant 가 2019년에 "필터 가능한 HNSW"를 설계한 출발점이 바로 결과를 받은 뒤 거르는 방식의 한계였어요.

선필터도 공짜는 아니에요 — 권한이 색인 안에 있어야 해요

선필터가 되려면 조각마다 "누가 볼 수 있는가"가 색인에 들어가야 해요. 저희는 사용자가 아니라 그룹을 넣기로 했어요. 인사 이동마다 문서를 다시 색인하고 싶지 않았고, 원본 시스템의 권한 모델도 대부분 그룹 기준이거든요.

def search(query, user_groups, k=3):
    groups = set(user_groups) | {"all"}
    # 색인 시점에 조각마다 원본 문서의 ACL 을 복사해 둠
    candidates = [c for c in index if c.acl & groups]
    return rank(query, candidates)[:k]

이렇게 두고 달라진 건 k 가 얼마든 돌아오는 조각이 전부 열람 가능하다는 거예요. 지울 게 없으니 빈 결과도 없어요. Azure AI Search 의 보안 필터 패턴도 같은 구조예요. 그룹 ID 필드를 filterable 로 두고 질의마다 search.in 으로 요청자의 그룹만 남겨요.

두 가지가 남아요. 첫째는 동기화예요. 원본에서 권한이 바뀌어도 색인의 ACL 은 스스로 바뀌지 않아요. Azure 문서도 원본 권한 변경은 색인에 동기화된 뒤에야 결과에 반영된다고 명시해요. 저희는 권한 변경 이벤트를 받아 그 문서의 조각만 다시 쓰고, 놓친 이벤트에 대비해 야간에 전체 ACL 을 대조해요.

둘째는 마지막 확인이에요. 생성기에 조각을 넘기기 직전에 권한을 한 번 더 봐요. 동기화 지연 사이에 끼는 조각을 잡는 용도이고, 비용은 거의 없어요. 파일럿에서 운영으로 넘어갈 때 무너지는 지점에서 꼽은 최소 권한의 실체가 RAG 에서는 이 두 겹이에요.

잠정 결론

AS-ISTO-BE
필터 자리없음, 또는 상위 k 받은 뒤 제거검색 전에 그룹으로 후보 축소 + 생성 직전 재확인
색인에 담는 것본문 · 출처본문 · 출처 · 조각별 ACL(그룹)
권한 변경 시다음 전체 재색인까지 방치이벤트로 해당 문서 조각 갱신, 야간 전체 대조
실패의 모습남의 부서 문서 인용동기화 지연 창 안의 조각 (재확인이 잡음)
평가검색 품질만골든셋에 "계정별 누출률" 열 추가

다시 처음 장면으로 돌아가면, 검색기는 잘못이 없었어요. 질문자가 누구인지 알려주지 않은 설계가 잘못이었어요. 권한도 검색 품질처럼 감이 아니라 숫자로 남겨야 운영에서 버텨요. 저희가 골든셋에 계정별 누출률을 한 열 더 넣고, RAG 구축에서 청크 크기보다 ACL 을 먼저 묻는 이유예요.

자주 묻는 질문

Q. 벡터 DB 의 메타데이터 필터를 쓰면 선필터가 되는 건가요? A. 대개 그래요. 다만 검색 중에 적용되는지 결과를 받은 뒤 적용되는지는 제품과 설정마다 달라요. 조건을 아주 좁게 걸었을 때 결과가 k 개보다 적게 오는지 직접 재보면 알 수 있어요.

Q. 권한이 사용자 단위로만 관리되는 시스템이면요? A. 색인에 사용자 ID 를 넣을 수도 있지만 인사 이동마다 조각을 갱신해야 해요. 원본 쪽에서 그룹으로 묶는 걸 먼저 검토해요.

Q. 프롬프트에 "권한 없는 내용은 답하지 마"라고 쓰면 안 되나요? A. 모델은 조각이 누구 것인지 알 방법이 없어요. 컨텍스트에 들어온 문서는 인용될 수 있다고 봐야 해요. 권한은 검색 앞에서 끊고, 프롬프트는 보조 장치예요.

Sources

사내 문서 RAG 권한 관리, 필터는 검색 앞인가요 뒤인가요 — 부서 문서가 남의 답변에 섞이는 걸 20문항으로 재봤어요 | SHIFTONE Blog