묵상을 암호화하면서 RAG 검색도 유지하는 방법
묵상을 암호화하면서 RAG 검색도 유지하는 방법
사용자 묵상은 개인적인 내용이라 암호화해서 저장해야 했다. 문제는 묵상 텍스트를 암호화하면 RAG(시맨틱 검색)이 안 된다는 것.
딜레마
암호화된 텍스트로는 임베딩을 만들 수 없고, 검색도 안 된다. 그렇다고 평문을 DB에 두면 유출 위험이 있다.
처음에는 클라이언트 사이드 E2E 암호화를 고려했다. 서버가 평문을 아예 모르는 방식. 하지만 그러면:
- 임베딩 생성을 클라이언트에서 해야 함 → OpenAI API 키가 클라이언트에 노출
- 카드 생성 시 GPT가 묵상 텍스트를 읽어야 하는데 서버가 복호화를 못 함
해결: 서버 사이드 암호화 + 임베딩 분리
1
2
3
4
5
6
7
8
9
10
11
12
묵상 저장 시:
서버가 평문 수신
→ 임베딩 벡터 생성 (RAG용)
→ AES-256-GCM 암호화
→ DB에 암호문 + 벡터 저장
→ 평문 폐기
묵상 조회 시:
DB에서 암호문 → 복호화 → 본인에게만 반환
RAG 검색 시:
벡터 유사도 검색 (평문 불필요)
핵심은 임베딩 벡터에서 원문을 복원할 수 없다는 점이다. 벡터는 의미의 방향만 담고 있어서, DB가 유출되어도 벡터로 묵상 원문을 읽을 수 없다.
구현
1
2
3
4
5
6
7
8
9
10
11
12
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(plaintext: str) -> str:
nonce = os.urandom(12)
ciphertext = AESGCM(KEY).encrypt(nonce, plaintext.encode(), None)
return "ENC:" + base64.b64encode(nonce + ciphertext).decode()
def decrypt(stored: str) -> str:
if not stored.startswith("ENC:"):
return stored # 마이그레이션 전 평문 호환
raw = base64.b64decode(stored[4:])
return AESGCM(KEY).decrypt(raw[:12], raw[12:], None).decode()
ENC: prefix로 암호문과 평문을 구분한다. 기존 데이터를 점진적으로 마이그레이션할 수 있다.
완벽하지 않은 점
서버가 순간적으로 평문을 처리한다. 완벽한 E2E가 아니다. 하지만 DB에는 평문이 없고, 공개 갤러리에서도 묵상이 노출되지 않는다.
“회사가 절대 못 본다”고는 말할 수 없어서, FAQ에는 “DB 유출 시 보호”와 “본인만 조회 가능”만 적었다. 과장하지 않는 게 맞다.
비용
추가 비용 없음. 임베딩 생성은 원래 하던 것이고, AES 암호화/복호화는 서버 CPU로 수 밀리초. 체감 차이 없다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.