3072차원 벡터는 반정밀도 인덱스로 후보 30개 검색 후 6개 재선정

핵심 요약

  • Dev Community의 Next.js 멀티테넌트 RAG 구현 가이드는 인증된 조직 정보와 검색 필터, PostgreSQL 행 수준 보안을 결합하는 방법과 SQL 주입 방어의 한계를 설명한다.

여러 기업이 함께 쓰는 Next.js 지식 검색 서비스에서 고객 데이터 유출을 막으려면 검색 결과가 모델에 전달되기 전에 접근 권한을 검증해야 한다. Dev Community에 게재된 멀티테넌트 RAG 구현 가이드는 PostgreSQL과 pgvector, 행 수준 보안(RLS)을 결합해 조직별 검색 범위를 제한하는 방식을 소개했다. 실제 운영 사고를 보고한 글이 아니라 가상의 유출 상황을 바탕으로 한 구현 참고 자료다.

가이드는 첫 검색 경로에서 조직 소속과 tenant_id를 확인하더라도 나중에 추가한 에이전트 도구가 필터를 빠뜨리면 다른 기업의 문서를 반환할 수 있다고 설명한다. 관련성이 높은 문장을 찾아 유창하게 답하더라도, 권한 없는 문서가 프롬프트에 들어간 순간 정보 노출은 이미 발생한다. 시스템 프롬프트를 개선하는 것만으로는 이를 해결할 수 없다. 제시한 방식은 곧바로 배포할 완성 애플리케이션이 아니며, 인증·조직 소속 확인은 서비스 스키마에 맞게 구현하고 코드는 실제 배포 버전에서 시험해야 한다.

테넌트 ID는 신뢰할 수 있는 서버 측 인증과 조직 소속 확인 결과에서 가져와야 한다. 브라우저가 바꿀 수 있는 JSON 필드나 업로드한 파일명·문서 메타데이터를 근거로 삼아서는 안 된다. /org/acme/knowledge 같은 주소는 사용자가 열려는 조직을 가리킬 뿐 접근 권한을 증명하지 않는다. 인증된 사용자의 소속을 확인한 뒤 검증한 ID를 검색에 전달해야 한다. 예제는 조직 구성원 모두가 해당 조직의 모든 문서를 검색할 수 있다고 가정한다. 비공개 폴더, 문서별 접근 제어, 조직 내부 역할이 있다면 해당 권한도 검색 쿼리에 반영해야 한다.

데이터베이스에는 검색 필터를 빠뜨렸을 때를 대비한 RLS도 적용한다. 실행용 역할인 app_runtime은 일반적인 데이터베이스 구성 절차로 별도 생성하고, 슈퍼유저·테이블 소유자·BYPASSRLS 권한 역할을 사용하지 않도록 한다. 마이그레이션은 별도의 권한 있는 역할로 수행한다. public 스키마의 kb_documents와 kb_chunks에 ENABLE ROW LEVEL SECURITY와 FORCE ROW LEVEL SECURITY를 적용하고, app_runtime에는 스키마 사용 권한과 필요한 조회·삽입·수정·삭제 권한만 부여한다. 두 테이블의 정책은 모든 작업에 적용하며 tenant_id를 app.tenant_id 설정과 비교한다. USING은 기존 행의 조회·수정 범위를, WITH CHECK는 삽입·갱신하는 행의 테넌트를 제한한다. 설정이 없으면 어느 테넌트 행에도 일치하지 않아 접근을 차단한다.

테넌트 설정은 연결 풀에서 다른 요청으로 이어지지 않도록 트랜잭션 범위에만 둔다. 검색 함수는 트랜잭션을 시작한 뒤 set_config('app.tenant_id', $1, true)로 검증한 ID를 설정하며, 트랜잭션이 끝나면 설정이 복원된다. 애플리케이션의 다른 곳에서도 이 값을 세션 범위로 설정해서는 안 된다. app_runtime을 사용하는 문서 수집·삭제 코드와 백그라운드 작업도 쓰기 전에 같은 문맥을 설정해야 하며, 관리자 연결로 정책을 우회해서는 안 된다. 검색 쿼리의 명시적인 tenant_id 조건도 유지해 RLS 구성이 바뀌더라도 검색 경로의 경계가 드러나고 보호되도록 한다.

문서와 각 텍스트 조각에는 소유 조직 정보를 함께 저장한다. 예제는 PostgreSQL 13 이상과 pgvector 0.8.0 이상을 전제로, kb_documents에 문서 ID·tenant_id·제목·출처 URL·생성 시각을 두고 kb_chunks에는 tenant_id·문서 ID·조각 순서·본문·임베딩·생성 시각을 저장한다. 조각 순서는 0 이상이며 문서별로 중복되지 않아야 한다. 문서 테이블의 tenant_id와 ID 조합에 고유 제약을 두고, 조각 테이블이 이 조합을 외래 키로 참조하게 한다. 이에 따라 A 조직의 조각이 B 조직 문서를 가리키는 불일치를 막고 문서 삭제 시 조각도 함께 삭제한다. tenant_id 인덱스도 만든다. 수집 단계에서는 검증한 조직 문맥에서 tenant_id를 가져와 문서 소유권을 확인하고 문서·조각을 하나의 트랜잭션으로 기록한다. 임베딩을 백그라운드에서 생성한다면 서버가 만든 작업에 문서와 테넌트 ID를 담아 전달하고, 업로드 파일의 메타데이터로 소유권을 정하지 않는다.

임베딩 모델은 3072차원의 text-embedding-3-large를 사용한다. 원본 임베딩은 일반 vector 형식으로 저장하되 HNSW 인덱스는 halfvec로 변환해 구성한다. pgvector의 HNSW 인덱스가 vector에서는 최대 2000차원, halfvec에서는 최대 4000차원을 지원하기 때문이다. 반정밀도로 검색한 순서를 최종 결과로 쓰지 않도록 후보를 원본 벡터로 다시 정렬한다. 검색은 여전히 근사 최근접 이웃 방식이므로 정확히 가장 가까운 6개가 후보에 모두 포함된다고 보장하지 않는다.

pg 패키지를 쓰는 검색 함수는 임베딩이 3072차원이고 모든 값이 유한한지 검사한다. 제한된 app_runtime 역할을 사용하는 DATABASE_URL로 연결 풀을 만들고, 트랜잭션에서 테넌트 문맥과 hnsw.iterative_scan의 strict_order 설정을 적용한다. 문서와 조각은 문서 ID와 tenant_id가 모두 일치하도록 결합하며, 모델에 보낼 문맥을 고르는 쿼리에 WHERE c.tenant_id = $1 조건을 둔다. 벡터 검색 뒤나 화면에서 결과를 걸러내는 것으로 접근 제어를 대신할 수 없다. 내부 쿼리는 반정밀도 HNSW 인덱스로 후보 30개를 찾고 외부 쿼리는 저장된 원본 벡터의 코사인 거리로 다시 정렬해 상위 6개를 반환한다. 30과 6은 조정의 출발점이며 모든 환경에 통용되는 값은 아니다. 예제는 정상 완료 시 커밋하고 오류 시 롤백을 시도한 뒤 원래 오류를 전달하며, 마지막에 연결을 반환한다.

RLS의 한계도 명확하다. app.tenant_id는 암호학적으로 검증한 신원이 아니라 PostgreSQL의 사용자 정의 설정이므로 임의의 SQL을 실행할 수 있는 역할은 대체로 이 값도 직접 바꿀 수 있다. 따라서 이 정책은 누락된 필터와 실수로 범위를 제한하지 않은 쿼리를 막는 보완책이며 SQL 주입을 무해하게 만들지는 않는다. 매개변수화한 SQL과 최소한의 데이터베이스 권한을 사용하고, 위협 모델에 애플리케이션 역할로 임의 SQL을 실행하는 공격자가 포함된다면 더 강한 데이터베이스 측 신원 검증 설계를 선택해야 한다.

모델에 전달할 때도 Next.js 라우트가 요청 JSON 대신 인증된 세션에서 테넌트를 확인해야 한다. 예제의 requireActiveTenant()는 사용자의 조직 소속을 검증해 허용된 테넌트 ID 또는 null을 반환하도록 애플리케이션별로 구현해야 한다. 답변 생성에는 오픈AI(OpenAI)의 Responses API를 쓰며 생성 모델은 설정으로 바꿀 수 있게 한다. 가이드는 기본 예시로 gpt-6.1-sol을 제시하면서 배포 전에 현재 모델 목록과 가격을 확인하도록 안내했다.

인공지능(AI)과 스타트업 분야의 주요 소식을 전합니다.