포스트

묵상을 암호화하면서 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 라이센스를 따릅니다.