지난 글에서 RAG 파이프라인을 실패 지점으로 분해해 봤다. 이번엔 그 설계를 실제 코드로 옮겼다. 정산 플랫폼의 AI 챗봇에 pgvector 기반 지식베이스를 붙이고, Flyway 마이그레이션과 헥사고날 어댑터까지 얹는 작업이다.

옮겨 보니 설계 문서에 없던 함정이 셋 있었다. 셋 다 공통점이 있다 — 예외를 던지지 않는다. 컴파일도 되고, 테스트도 통과하고, 로그도 깨끗한데, 결과만 틀리거나 성능만 무너진다.

그리고 네 번째가 있었다. 앞의 셋과 정반대로 아주 시끄럽게 실패한 것, 내가 직접 만든 것, 그래서 이 글에서 가장 할 말이 많은 것. 마지막 절이 그 이야기다.


0. 출발점 — 프롬프트에 박힌 정책

Phase 1 챗봇이 아는 도메인 지식의 전부는 application.yml의 시스템 프롬프트 한 문단이었다.

system-prompt: >-
  Lemuel 은 주문/결제, 셀러 정산(등급별 수수료 NORMAL 3.5%/VIP 2.5%/STRATEGIC 2.0%,
  정산주기 T+7/T+3/T+1 영업일) … 을 제공합니다.

문제는 길이가 아니라 동기화다. 수수료율은 이미 별도 ADR로 “코드에서 데이터로” 옮겨 유효기간 기반 테이블에 들어가 있었다. 그런데 챗봇이 말하는 수수료율은 여전히 코드에 박힌 문자열이었다. 즉 요율을 바꾸는 순간 두 값이 갈라지고, 갈라진 사실을 아무도 모른다. 챗봇은 자신 있게 옛 숫자를 말한다.

RAG는 이 문제를 프롬프트 확장이 아니라 검색으로 푼다. 그러려면 벡터를 어딘가에 넣어야 한다. 첫 번째 함정은 바로 거기 있었다.


1. 첫 번째 함정 — “PostgreSQL 쓰니까 pgvector 켜면 되지”

플랫폼은 이미 PostgreSQL을 쓴다. 그러니 정산 DB에 확장만 켜면 될 것 같았다. 전용 벡터 DB를 새로 띄우는 것보다 훨씬 싸 보인다.

실제로 확장 목록을 조회하면 정산 DB가 붙은 인스턴스에서도 pgvector가 사용 가능하다고 나온다. 그런데 여기에 함정이 있다.

PostgreSQL 공식 문서는 CREATE EXTENSION의 권한 규칙을 이렇게 정의한다 — 확장 설치는 슈퍼유저 권한을 요구하되, 확장이 trusted로 표시된 경우에 한해 현재 데이터베이스에 CREATE 권한을 가진 사용자도 설치할 수 있다.1

pgvector는 trusted 확장이 아니다. 확장의 control 파일에 trusted = true 선언이 없고, 실제 인스턴스에서 pg_available_extension_versions를 조회하면 superuser = t, trusted = f로 보고된다. 즉 “사용 가능”과 “이 계정이 설치 가능”은 완전히 다른 이야기다.

두 인스턴스를 실측해 보니 이렇게 갈렸다.

DB 앱 계정 슈퍼유저? vector 확장
AI 서비스 DB (DB 소유자) 이미 설치됨CREATE EXTENSION IF NOT EXISTS는 no-op
정산 서비스 DB 아니오 사용 가능하나 미설치

정산 마이그레이션에 벡터 DDL을 넣었다면 insufficient_privilege로 Flyway 체인이 끊기고, 정산 서비스 전체가 CrashLoopBackOff에 빠진다. 부가 기능인 챗봇 지식베이스가 돈을 다루는 서비스의 부팅 경로에 위험을 얹는 것이다.

이건 가정이 아니다. 이틀 전 다른 확장(pg_stat_statements)에서 정확히 같은 일이 있었고, 로컬 개발 DB에서는 슈퍼유저라 조용히 통과했다가 운영에서만 터졌다.

그래서 실패를 어떻게 다룰 것인가

앞선 사고의 수습책은 RAISE WARNING으로 강등하는 것이었다. pg_stat_statements는 관측용이라 없어도 애플리케이션이 돈다 — 그 판단은 그 맥락에서 옳다.

vector는 다르다. 그건 바로 다음 문장의 컬럼 타입이다. 조용히 건너뛰면 다음 줄이 type "vector" does not exist로 죽는다. 진짜 원인(권한 또는 이미지)과 전혀 무관한 메시지로. 그래서 원인을 아는 지점에서, 원인을 말하며 실패하게 했다.

DO $$
BEGIN
    CREATE EXTENSION IF NOT EXISTS vector;
EXCEPTION
    WHEN insufficient_privilege THEN
        RAISE EXCEPTION 'pgvector 확장 생성 권한이 없습니다 — 앱 계정이 슈퍼유저가 아닌 DB 입니다'
            USING HINT = '슈퍼유저로 CREATE EXTENSION vector; 를 먼저 실행한 뒤 재배포할 것 '
                         '(vector 는 trusted 확장이 아니므로 일반 계정에 위임할 수 없다)';
    WHEN undefined_file THEN
        RAISE EXCEPTION 'pgvector 확장 파일이 없습니다 — pgvector 미포함 이미지입니다'
            USING HINT = '이미지를 pgvector 포함 버전으로 교체할 것';
END
$$;

실패는 하되 진단 가능하게. 이게 이 함정의 교훈이다. “일단 켜지면 좋고 아니면 말고”는 확장이 선택적 관측 도구일 때만 성립한다.


2. 두 번째 함정 — 3072차원은 인덱스를 못 만든다

모델을 고르고 나면 차원은 그냥 따라오는 값처럼 보인다. Google의 gemini-embedding-001은 기본 3072차원을 출력한다.2 컬럼을 vector(3072)로 만들면 테이블은 잘 만들어진다. INSERT도 된다. 검색도 된다.

인덱스만 안 만들어진다.

pgvector 공식 문서는 vector 타입에 인덱스를 걸 수 있는 상한이 2,000차원이라고 명시한다.3 저장 자체의 상한은 그보다 훨씬 크다 — 즉 3072차원 컬럼은 만들어지고 값도 들어가지만, HNSW 인덱스는 거부된다. 인덱스가 없으면 매 질의가 전량 스캔이다.

무서운 건 이게 에러로 드러나지 않는다는 점이다. 인덱스 생성만 실패하고 검색 기능 자체는 멀쩡히 동작한다. 청크가 몇백 건인 개발 환경에서는 체감조차 안 된다. 문서가 쌓인 뒤 어느 날 챗봇이 느려질 뿐이다.

절단은 되지만, 정규화는 직접 해야 한다

다행히 gemini-embedding-001은 MRL(Matryoshka Representation Learning)로 학습돼 있어 앞쪽 차원만 잘라 써도 의미가 보존된다. Google 문서는 768 / 1536 / 3072을 권장 절단 지점으로 제시한다.2 그중 인덱스가 가능한 768을 택했다.

여기 함정이 하나 더 겹쳐 있다. 같은 문서가 이렇게 경고한다 — 3072가 아닌 차원을 쓸 때는 임베딩을 직접 정규화해야 한다.2 기본 차원 출력은 이미 단위벡터지만, 잘라낸 벡터는 아니기 때문이다.

\[\|v\|_2 = \sqrt{\sum_i v_i^2}, \qquad \hat{v} = \frac{v}{\|v\|_2}\]

정규화를 빼먹으면? 역시 예외가 없다. 코사인 유사도 계산이 벡터 길이에 오염돼 순위만 조용히 틀어진다. 검색 결과는 항상 그럴듯한 문장이라 눈으로는 구분이 안 된다.

그래서 이 연산을 어댑터의 성실함에 맡기지 않고 값 객체 안으로 밀어 넣었다. 정규화되지 않은 Embedding은 만들어질 수 없고, 영벡터는 생성 시점에 거부한다(정규화하면 NaN이 되어 DB로 흘러들어간다).

인덱스는 거리 연산자와 연산자 클래스가 짝이 맞아야 탄다 — 코사인 거리 <=>에는 vector_cosine_ops.3 짝이 어긋나면, 예상하셨겠지만, 인덱스를 안 타고 조용히 느려진다.

CREATE INDEX idx_knowledge_chunks_embedding_hnsw
    ON knowledge_chunks USING hnsw (embedding vector_cosine_ops);

3. 세 번째 함정 — 모델을 갈면 벡터 공간이 갈린다

임베딩 모델은 언젠가 바뀐다. 더 좋은 모델이 나오거나, 가격이 바뀌거나, 지원이 끊긴다.

모델이 바뀌면 벡터 공간이 바뀐다. 그런데 옛 모델로 만든 청크 벡터와 새 모델로 만든 질의 벡터는 차원만 같으면 아무 문제 없이 비교된다. 거리 계산은 성공하고, 숫자는 0에서 1 사이의 그럴듯한 값이 나오고, 챗봇은 검색된 청크를 근거로 자신 있게 답한다. 그 근거가 무작위로 뽑힌 것이라는 사실만 아무도 모른다.

에러 없는 오답. 세 함정 중 가장 위험하다.

대응은 단순하다. 청크마다 그 벡터를 만든 모델 ID를 남기고, 검색할 때 현재 모델로 필터한다.

SELECT d.title, c.content, 1 - (c.embedding <=> ?::vector) AS similarity
  FROM knowledge_chunks c
  JOIN knowledge_documents d ON d.id = c.document_id
 WHERE c.embedding_model = ?
 ORDER BY c.embedding <=> ?::vector
 LIMIT ?

이렇게 하면 모델을 교체한 순간 매칭이 0건이 된다. 챗봇은 근거 없이 답하게 되고, 그건 “모르면 모른다고 답하라”는 원래 지시로 돌아간다는 뜻이다. 재임베딩이 끝날 때까지 안전하게 착지한다.

참고로 유사도 하한 필터는 SQL의 WHERE가 아니라 애플리케이션에서 건다. 거리식을 WHERE로 옮기면 HNSW 인덱스가 쓰이는 ORDER BY … LIMIT 경로에서 벗어날 수 있다. 인덱스로 top-k를 먼저 뽑고, 그다음에 거른다.


4. 함정은 아니지만 — “아무 일도 안 일어나는” 기본값

기능을 붙일 때 가장 신경 쓴 건 성능도 정확도도 아니었다. 켜기 전까지 아무것도 달라지지 않는다는 보장이었다.

RAG는 시스템 프롬프트를 건드린다. 프롬프트가 바뀌면 기존 답변의 톤과 내용이 전부 흔들릴 수 있다. 그래서 세 겹으로 막았다.

조건 결과
빈 없음 기능 플래그 off (기본값) “근거 0건” 포트가 대신 주입됨
키 없음 임베딩 API 키 미설정 빈 리스트
지식 0건 지식베이스가 비어 있음 임베딩 API를 호출조차 하지 않고 빈 리스트

세 경우 모두 프롬프트 증강 함수가 원본 프롬프트를 동일한 객체 그대로 반환한다. 테스트는 isEqualTo가 아니라 isSameAs로 못박았다. 문자열이 같은 게 아니라 바뀌지 않았음을 검증하고 싶었기 때문이다.

근거를 붙이는 위치도 한 번 더 고민할 값어치가 있었다. 사용자 메시지 앞에 끼워 넣는 편이 구현은 훨씬 쉽다. 하지만 그러면 합성된 텍스트가 대화 이력 테이블에 영구 저장되고, 다음 턴의 히스토리 윈도를 잠식하고, 이력 조회 화면에 참고 자료가 그대로 노출된다. 시스템 프롬프트는 매 요청 새로 조립되고 저장되지 않는다. 근거가 그 턴에만 살아 있다.

검색이 실패하면 어떻게 할까? 근거 0건으로 강등하고 대화를 계속한다. LLM 호출 실패는 503을 던지는데 검색 실패는 아니다 — LLM이 죽으면 답 자체가 없지만, 검색이 죽어도 근거 없는 답은 여전히 가능하기 때문이다. 부가 기능의 장애가 본 기능을 멈추게 두지 않는다.

적재 권한을 ADMIN으로 올린 이유

적재된 텍스트는 이후 모든 사용자의 답변 근거로 프롬프트에 실린다. 즉 지식 적재는 사실상 챗봇의 발언 내용에 대한 쓰기 권한이다. 일반 사용자에게 열면 그건 영구 저장되는 프롬프트 인젝션 창구가 된다.

같은 이유로 적재 본문도 채팅 입력과 똑같은 PII 마스킹을 통과시킨다. 청크는 프롬프트에 실려 외부 LLM으로 나가는 새로운 유출 경로다. 관리자가 넣는 내부 문서라 해도 계좌번호가 섞여 있을 수 있다. 마스킹 규칙을 복사해 오지 않고 기존 것을 그대로 재사용했다 — 규칙이 두 벌이 되면 한쪽만 갱신되는 것이 가장 나쁜 결과다.


5. 네 번째 함정은 내가 만들었다

여기까지 쓰고 PR을 올렸다. 그 시점의 검증 상태는 이랬다.

  • 단위·통합 테스트 145건 통과, 실패 0
  • 커버리지 게이트 통과 (CI 제외경로 적용 LINE 97.6%)
  • 청킹 불변식, 정규화, 모델 필터, 무행동 착지를 각각 테스트로 고정

그런데 실제 pgvector를 띄우는 통합 테스트 7건은 로컬에서 한 번도 돌지 않았다. Docker 데몬을 띄울 수 없는 환경이었기 때문이다. JUnit의 @EnabledIf가 Docker 부재를 감지해 클래스 전체를 조용히 건너뛴다. 빌드는 초록불이다.

이 사실을 ADR 구현 체크리스트에 [ ](미완료)로 열어 두고 PR 본문에도 “이 CI가 처음 검증한다”고 적었다. 돌지 않은 테스트는 통과한 테스트가 아니다 — 이 문장은 원래 이 글의 초고에 그대로 들어 있었다.

그리고 CI가 빨간불로 돌아왔다. 145건 중 4건 실패, 전부 그 통합 테스트였다.

무엇이 깨졌나

실패는 두 종류였고, 둘 다 프로덕션 코드가 아니라 내가 쓴 테스트 코드의 결함이었다.

하나. 테스트 벡터를 3차원으로 만들었다. 컬럼은 vector(768)인데 픽스처는 Embedding.of(1f, 0f, 0f)였다. “축이 다르면 코사인 유사도 0, 같으면 1”이라는 결정론적 랭킹을 만들려다 차원을 놓쳤다. PostgreSQL은 INSERT를 거부한다 — DataIntegrityViolationException.

이건 목(mock)으로는 절대 드러나지 않는다. 목은 차원을 검사하지 않는다. 실제 컬럼 정의를 아는 것은 실제 데이터베이스뿐이다.

둘. 청크 수를 눈대중으로 가정했다. 임베딩 포트를 List.of(A, B) 같은 고정 크기 리스트로 스텁하고 “문단 2개니까 청크 2개”라고 믿었다. 그런데 TextChunker는 문맥 보존을 위해 짧은 문단들을 한 청크로 합친다. 내가 직접 그렇게 만들어 놓고 테스트에서는 잊었다. 결과는 청크 1개 대 임베딩 2개.

여기서 앞 절에 쓴 가드가 작동했다. 저장 전에 IllegalStateException이 터졌고, 그건 내가 이 경우를 위해 심어 둔 바로 그 코드였다.

if (embeddings.size() != chunkTexts.size()) {
    // 순서·개수 대응이 깨지면 청크와 벡터가 어긋난 채 저장된다 — 저장 전에 막는다.
    throw new IllegalStateException("임베딩 개수가 청크 수와 다릅니다: chunks=" + chunkTexts.size()
            + ", embeddings=" + embeddings.size());
}

(Gradle은 실패 요약에 예외 클래스와 줄 번호만 남기고 메시지는 남기지 않았다. 위 코드가 그 예외의 출처이고, 구체적인 개수 값은 CI 로그에서 확인되지 않았다.)

덤으로 하나 더 있었다. 요청 JSON을 문자열 연결로 조립하면서 "...\\n\\n..."를 넣었는데, 이건 문단 구분이 아니라 리터럴 역슬래시다. 청크 수가 조용히 1이 된다.

고친 방식

숫자를 손으로 맞추는 대신 어긋날 수 없게 만들었다.

  • 차원을 상수 하나로 못박고 axisVector(int)로 768차원 단위벡터를 생성한다.
  • 스텁을 고정 리스트가 아니라 thenAnswer로 바꿔 청크 본문을 보고 벡터를 정한다. 청킹 결과가 몇 개가 나오든 개수가 어긋나지 않는다.
  • 청크 상한을 테스트에서 100자로 고정하고 문단마다 패딩을 붙여 경계를 수치로 보장했다. 각 문단 59–66자(상한 이하 → 강제분할 없음), 두 문단을 합치면 127–128자(상한 초과 → 반드시 2청크). 눈대중을 제거했다.
  • 요청 본문은 손으로 이스케이프하지 않고 Jackson으로 직렬화한다.

그래서 최종 결과

CI 아티팩트에서 받은 Gradle 리포트를 그대로 옮긴다.

ai-service                     tests=145  failures=0  ignored=0
RagKnowledgeIntegrationTest    tests=7    failures=0  ignored=0

[passed] 0.016s  pgvector 확장과 HNSW 인덱스가 마이그레이션으로 실제 생성되어 있다
[passed] 0.095s  적재 → 검색 종단: 같은 방향 청크가 상위로, 직교 청크는 하한 미달로 탈락
[passed] 0.062s  같은 출처 재적재 — 본문 같으면 스킵, 바뀌면 청크 전량 교체
[passed] 0.129s  삭제 — FK CASCADE. 없는 출처는 404
[passed] 0.059s  채팅 — 적재된 근거가 시스템 프롬프트에 실려 전달된다
[passed] 0.067s  지식베이스가 비면 프롬프트는 원본 그대로 (무행동 착지)
[passed] 0.036s  보안 — 적재/삭제는 ADMIN 전용, 검색은 USER, 무인증 401

ignored=0이 이 글에서 가장 중요한 숫자다. 로컬에서는 이 7건이 통째로 ignored였고, 나는 그 상태의 “BUILD SUCCESSFUL”을 근거로 삼았다. CI에서는 0건이 건너뛰어졌다 — 실제로 컨테이너에 붙어 다 돌았다는 뜻이다. 같은 초록불도 의미가 전혀 다르다.

부수적으로 하나 더 걸렸다. 기존 통합 테스트가 쓰던 Testcontainers 이미지는 확장이 없는 표준 PostgreSQL이었다. 마이그레이션이 CREATE EXTENSION vector를 수행하는 순간 부팅 자체가 실패한다. 이미지를 pgvector 포함 버전으로 바꾸고, CI가 쓰는 이미지 미러 목록에도 추가해야 했다. RAG 기능과 아무 상관 없어 보이는 파일이 함께 바뀌는 이유다.

이 실패에서 남길 것

앞의 세 함정은 “예외를 던지지 않아서” 위험했다. 네 번째는 정반대다 — 아주 시끄럽게 실패했고, 그래서 안전했다. 차원 불일치는 DB가 거부했고, 개수 불일치는 내가 심어 둔 가드가 저장 전에 막았다. 잘못된 벡터가 조용히 저장돼 몇 주 뒤 “챗봇이 엉뚱한 근거를 댄다”로 나타나는 시나리오가 실제로 차단된 것이다.

진짜 교훈은 따로 있다. 나는 미검증 사실을 성실하게 표시했다 — ADR 체크리스트, PR 본문, 이 글의 초고에까지. 그런데도 결함은 그대로 통과했다. 미검증을 표시하는 것과 검증하는 것은 다르다. 정직한 라벨은 위험을 줄이지 않는다. 위험을 옮길 뿐이다.


6. 안 한 것들, 그리고 왜

  • halfvec(3072) — pgvector의 halfvec 타입은 4,000차원까지 인덱스가 가능해3 절단이 아예 불필요하다. 매력적이지만 지금 품질 문제가 관측되지 않았다. 실측 없이 복잡도를 먼저 사지 않기로 했다. ADR 대안 검토에 남겨 뒀다.
  • 배치 임베딩 API — 청크마다 순차 호출이라 대량 적재가 느리다. 배치 엔드포인트가 있을 법하지만 문서로 확인하지 못한 API를 추측으로 붙이지 않았다.
  • 전용 벡터 DB — 현재 규모(내부 문서 수십 편, 청크 수천 건)에서 전용 엔진의 이점이 운영 대상 +1, 백업 체계 이중화, 네트워크 홉 추가 비용을 넘지 못한다.
  • RAG 품질 평가 파이프라인 — 지난 글에서 “측정 설계가 먼저”라고 썼는데, 이번 구현에는 없다. 정직하게 말하면 이건 1단계를 좁게 자른 결과다. 근거가 붙기 시작해야 측정할 대상이 생긴다.

정리

세 함정을 다시 늘어놓으면 이렇다.

  1. 확장은 “가능”과 “이 계정이 설치 가능”이 다르다. trusted가 아닌 확장은 슈퍼유저만 만든다. 로컬은 대개 슈퍼유저라 운영에서만 터진다.
  2. 차원은 모델이 정하지만 인덱스 상한은 저장소가 정한다. 넘으면 에러가 아니라 전량 스캔이다.
  3. 벡터 공간의 출처를 기록하지 않으면 모델 교체가 조용한 오답이 된다.

관통하는 성질은 하나다 — 셋 다 예외를 던지지 않는다. 그래서 테스트가 초록불이어도 안심할 수 없고, 반대로 말하면 실패를 시끄럽게 만드는 것 자체가 설계 결정이 된다. 확장 생성 실패를 굳이 예외로 올린 것, 임베딩 개수가 청크 수와 다르면 저장 전에 멈추는 것, 모델이 바뀌면 0건이 되게 한 것은 전부 같은 종류의 선택이다.

네 번째는 그 선택이 실제로 값을 한 사례다. 내가 만든 결함이 조용히 지나가지 않고 DB와 가드에 걸려 CI에서 터졌다. 시끄럽게 실패하도록 설계해 둔 덕에, 잘못된 벡터가 저장된 채 몇 주 뒤에 드러나는 대신 3분 만에 드러났다.

거기 붙은 조건 하나가 더 있다. 그 장치들이 작동하려면 테스트가 실제로 돌아야 한다. 로컬에서 조용히 건너뛴 7건은 아무것도 지켜 주지 못했다. ignored=0을 확인하기 전까지 초록불은 “통과했다”가 아니라 “아직 모른다”에 가깝다.

조용히 틀리느니 시끄럽게 실패하는 게 낫다. 특히 그 시스템이 정산을 다룬다면.


한계와 면책

  • 768차원 절단이 3072 대비 검색 품질에 얼마나 영향을 주는지는 이 글에서 측정하지 않았다. 절단을 택한 근거는 품질 비교가 아니라 인덱스 가능성이다. MRL 절단의 유효성은 모델 제공자(Google) 문서의 주장에 근거하며, 중립 제3자의 재현 벤치마크를 확인하지 않았다.
  • HNSW는 근사 최근접 탐색이다. 재현율은 hnsw.ef_search 등 파라미터에 의존하며, 이 글은 기본값을 그대로 썼다. 데이터가 쌓인 뒤 실측으로 조정할 사안이다.
  • 권한·확장 설치 여부는 필자 환경에서 실측한 값이며, 다른 배포 형태(관리형 서비스 등)에서는 다를 수 있다.
  • 5절의 테스트 수치(145건 / 7건, 실패 0, ignored 0)는 필자 프로젝트 CI가 생성한 Gradle 리포트 아티팩트에서 직접 읽은 값이다. 제3자가 재현할 수 있는 공개 벤치마크가 아니라 단일 프로젝트의 실측이다.
  • 통합 테스트가 검증하는 것은 스키마·SQL·배선의 정확성이지 검색 품질이 아니다. 임베딩 포트는 목으로 대체돼 있고, 벡터 값은 테스트가 직접 정한다. 즉 “랭킹이 의도대로 계산되는가”는 검증되지만 “실제 모델이 좋은 벡터를 주는가”는 검증되지 않는다.

References

  1. PostgreSQL 17 Documentation, CREATE EXTENSION — 확장 설치 권한과 trusted 확장 규정. https://www.postgresql.org/docs/17/sql-createextension.html 

  2. Google, Gemini API — Embeddingsgemini-embedding-001의 기본 3072차원, MRL 기반 차원 절단 권장값(768/1536/3072), 비기본 차원 사용 시 정규화 필요, task_type 규정. https://ai.google.dev/gemini-api/docs/embeddings  2 3

  3. pgvector, pgvector/pgvector README — vector / halfvec 타입의 저장·인덱스 차원 상한, 거리 연산자와 연산자 클래스, HNSW 파라미터. https://github.com/pgvector/pgvector  2 3