LLM 응답을 임베딩과 함께 저장할 때 자주 나오는 스키마 실수

LLM 응답을 임베딩과

결론부터 말씀드리면, LLM 응답을 임베딩과 함께 저장할 때 나는 대부분의 문제는 임베딩 차원을 고정하지 않은 테이블 설계, 원본 응답과 임베딩 대상 텍스트를 한 컬럼에 몰아넣는 구조, 그리고 모델 버전 기록 누락에서 시작됩니다. 이 세 가지만 스키마 단계에서 잡아도 나중에 벡터 차원 불일치 에러나 검색 품질 저하로 테이블을 갈아엎는 일을 피할 수 있습니다. 아래에서는 PostgreSQL과 pgvector 확장을 기준으로, 실제로 마주치는 스키마 설계 실수를 코드와 함께 짚어보겠습니다.

임베딩 차원을 고정하지 않고 테이블을 만들면 생기는 문제

pgvector에서 vector 타입 컬럼은 생성 시점에 차원 수를 명시해야 합니다. 그런데 초기 프로토타입 단계에서 급하게 테이블을 만들다 보면 이 부분을 대충 잡고 넘어가는 경우가 많습니다.

-- 실수: 차원을 하드코딩했지만 어떤 모델 기준인지 남기지 않음
CREATE TABLE responses (
    id SERIAL PRIMARY KEY,
    prompt TEXT,
    response TEXT,
    embedding vector(1536)
);

여기서 1536은 OpenAI의 text-embedding-3-small 기본 출력 차원입니다. 문제는 나중에 text-embedding-3-large(기본 3072차원)로 바꾸거나, dimensions 파라미터로 차원을 줄여 저장하기로 정책을 바꾸는 순간 이 컬럼 자체가 막힌다는 점입니다. INSERT 시 차원이 다르면 pgvector가 바로 에러를 던지기 때문에, 마이그레이션 없이는 새 임베딩을 같은 테이블에 넣을 수 없습니다.

실무에서는 embedding_model, embedding_dim 컬럼을 별도로 두고, 모델이 바뀔 가능성이 있다면 아예 임베딩 버전별로 테이블을 분리하거나 뷰로 합치는 방식을 씁니다. 처음부터 완벽하게 대비할 필요는 없지만, 최소한 “지금 이 벡터가 어떤 모델의 출력인지”는 컬럼 하나로 남겨두는 편이 안전합니다.

원본 응답과 임베딩 대상 텍스트를 같은 컬럼에 넣는 실수

LLM 응답을 임베딩과 함께 저장할 때 흔히 response 컬럼 하나에 원문을 넣고, 그 컬럼 값을 그대로 임베딩 API에 보내는 구조를 짜게 됩니다. 처음엔 문제없어 보이지만, 응답에 마크다운 문법이나 코드 블록, 시스템 프롬프트 조각이 섞여 있으면 임베딩 품질이 흔들립니다.

예를 들어 응답이 3000자짜리 긴 설명문인데 임베딩 모델의 토큰 한도 때문에 청크로 쪼개야 하는 상황이라면, 원문 하나만 저장해서는 “어떤 청크가 어떤 벡터에 대응하는지”를 알 방법이 없습니다. 이럴 땐 다음과 같이 응답과 청크를 분리하는 편이 낫습니다.

데이터베이스 스키마 설계 코드 화면

CREATE TABLE responses (
    id SERIAL PRIMARY KEY,
    prompt TEXT,
    raw_response TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE response_chunks (
    id SERIAL PRIMARY KEY,
    response_id INT REFERENCES responses(id),
    chunk_index INT,
    chunk_text TEXT,
    embedding vector(1536),
    embedding_model TEXT DEFAULT 'text-embedding-3-small'
);

이렇게 나누면 원본 응답은 그대로 보존하면서, 검색에 쓰이는 청크 단위 임베딩만 독립적으로 재생성하거나 재인덱싱할 수 있습니다. 응답 원문을 수정할 일이 거의 없는 로그성 데이터라면 이 정도까지 분리하지 않아도 되지만, 검색 품질을 반복해서 튜닝해야 하는 서비스라면 분리 구조가 유지보수 비용을 줄이는 데 도움이 될 수 있습니다.

모델 버전을 기록하지 않으면 나중에 무슨 일이 생기나요?

임베딩 모델은 서비스 초기에 정한 뒤 바뀌지 않는 경우가 오히려 드뭅니다. 비용 절감을 위해 더 저렴한 모델로 옮기거나, 검색 정확도를 높이려고 상위 모델로 갈아타는 일이 몇 달 안에 벌어집니다. 이때 embedding_model 컬럼이 없으면, 테이블 안의 벡터들이 서로 다른 임베딩 공간에서 나왔다는 사실 자체를 구분할 방법이 없습니다.

서로 다른 모델에서 나온 벡터는 같은 차원 수를 가지고 있어도 거리 계산이 의미를 갖지 않습니다. 코사인 유사도 값이 같은 스케일처럼 보여도 실제로는 완전히 다른 좌표계에서 비교하는 셈이기 때문입니다. 마이그레이션 중간에 새 모델과 예전 모델 벡터가 같은 컬럼에 섞여 들어가면, 검색 결과가 뒤죽박죽되고 원인을 찾는 데만 며칠이 걸릴 수 있습니다.

최소한의 대비책은 다음 세 컬럼을 함께 저장하는 것입니다.

컬럼명 역할 예시 값
embedding_model 임베딩 생성에 쓴 모델명 text-embedding-3-small
embedding_dim 벡터 차원 수 1536
embedded_at 임베딩 생성 시각 2025-03-14 09:12:00

이 세 컬럼만 있어도 모델 전환 시 WHERE 조건으로 버전을 나눠 조회하거나, 예전 벡터를 일괄 재임베딩하는 배치 작업을 짤 때 훨씬 수월해집니다.

메타데이터 필드를 빈약하게 설계하면 검색 품질이 떨어집니다

벡터 유사도 검색만으로 원하는 결과를 다 걸러낼 수 있다고 가정하는 것도 자주 보이는 설계 실수입니다. 실제 서비스에서는 “최근 7일 이내 응답만”, “특정 사용자의 응답 제외”, “프롬프트 버전 2 이후 데이터만” 같은 조건이 반드시 따라붙습니다. 이런 필터를 임베딩 검색과 함께 걸려면 메타데이터 컬럼이 처음부터 설계에 들어가 있어야 합니다.

AI 벡터 임베딩 신경망 시각화

SELECT id, chunk_text
FROM response_chunks
WHERE embedding_model = 'text-embedding-3-small'
  AND created_at > now() - interval '7 days'
ORDER BY embedding <=> '[0.012, -0.045, ...]'::vector
LIMIT 5;

여기서 <=> 연산자는 pgvector가 제공하는 코사인 거리 연산자입니다. 필터 조건과 벡터 정렬을 한 쿼리에서 같이 처리하려면 created_at, embedding_model 같은 컬럼에 일반 B-tree 인덱스도 함께 걸어두는 편이 실제 쿼리 속도에 도움이 됩니다. 벡터 인덱스만 있고 메타데이터 인덱스가 없으면, 필터 조건이 걸린 쿼리에서 옵티마이저가 벡터 인덱스를 건너뛰는 경우도 생깁니다.

ivfflat과 hnsw, 인덱스는 언제 무엇을 골라야 할까요?

pgvector는 두 가지 근사 최근접 이웃 인덱스를 제공합니다. 스키마 설계 단계에서 이 선택을 미뤄두면, 데이터가 몇만 건 쌓인 뒤에 인덱스를 다시 만드느라 서비스 중단이 생길 수 있습니다.

항목 ivfflat hnsw
빌드 속도 상대적으로 빠름 상대적으로 느림
검색 속도/정확도 lists 값 튜닝 필요 대체로 더 안정적
데이터 추가 시 재학습(ANALYZE) 권장 별도 재학습 불필요
메모리 사용량 적은 편 더 많이 씀
-- hnsw 인덱스 예시 (pgvector 0.5.0 이상)
CREATE INDEX ON response_chunks
USING hnsw (embedding vector_cosine_ops);

데이터 건수가 적거나(수천 건 이하) 인덱스 없이도 순차 스캔 성능이 충분한 단계라면, 처음부터 인덱스를 걸 필요는 없습니다. 인덱스는 오히려 쓰기 성능을 떨어뜨리기 때문에, 데이터가 실제로 늘어나는 시점에 맞춰 추가하는 편이 현실적입니다. 이런 인덱스 구조와 연산자 목록은 pgvector 공식 저장소의 README에 자세히 정리되어 있으니, 버전별 지원 여부를 그때그때 확인하는 것이 안전합니다.

지금 당장 스키마를 점검한다면 이 순서로 하세요

LLM 응답을 임베딩과 함께 저장하는 테이블을 이미 운영 중이라면, embedding_model 컬럼이 있는지부터 확인해보시길 권합니다. 없다면 지금 값은 전부 같은 모델이라고 가정하고 기본값으로 채워 넣은 뒤, 앞으로는 반드시 함께 기록하도록 코드를 고치는 것이 첫 단계입니다. 그다음으로 원본 응답과 검색용 청크가 한 컬럼에 뒤섞여 있는지 점검하고, 필요하다면 앞서 본 것처럼 테이블을 분리합니다.

다만 이 모든 구조가 모든 상황에 맞는 정답은 아닙니다. Pinecone이나 Weaviate 같은 매니지드 벡터 DB를 쓴다면 스키마 개념 자체가 달라서, 여기서 설명한 컬럼 분리 방식은 메타데이터 필드 설계 정도로만 참고하시면 됩니다. 또 트래픽이 거의 없는 사이드 프로젝트 단계라면 처음부터 완벽한 스키마를 짜느라 시간을 쓰기보다, 서비스가 커지는 시점에 맞춰 마이그레이션하는 편이 실용적일 수 있습니다.

LLM 함수 호출 파라미터 타입 오류, JSON 스키마로 잡는 법

참고: LLM 응답을 임베딩과 — 위키백과

Leave a Comment