
TL;DR
- 전용 벡터DB(Pinecone·Weaviate 등)를 새로 붙이는 대신, 이미 운영하던 PostgreSQL에
pgvector확장 하나만 얹어 사내 지식 RAG 어시스턴트를 만들었습니다. 운영 대상이 하나 줄고, 무엇보다 피드백 벡터와 원본 테이블을 한 번의 SQL로 JOIN할 수 있는 게 결정적이었습니다.- 임베딩은 Gemini
gemini-embedding-2를outputDimensionality: 768로 축소해 씁니다. Prisma가vector타입을 모르는 문제는Unsupported("vector(768)")+$queryRaw(코사인<=>)로 우회했습니다.- 인덱스는 HNSW(
vector_cosine_ops), 검색은 단일 에이전트가 도구를 골라 호출하는 함수콜 루프입니다. 유사도 컷오프는 지어낸 값이 아니라 스테이징에서 실측한 분포(도메인 0.670.79 vs 무관 0.500.59)를 보고 0.63으로 맞췄습니다.
사내 운영툴에 "낙찰 방법이 뭐냐", "이 제보랑 비슷한 기존 건 있냐", "이번 달 API 비용 얼마냐"를 자연어로 묻는 어시스턴트를 붙이는 일을 맡았습니다. 프론트에서 시작해 백엔드·인프라까지 직접 만지는 입장이라, 처음 든 생각은 "그럼 벡터DB부터 하나 띄워야 하나"였습니다. 결론부터 말하면 띄우지 않았습니다. 왜 그렇게 결정했고, 그 결정이 실제로 어떤 코드로 이어졌는지 정리합니다.
운영 지식은 세 군데에 흩어져 있었습니다.
RAG를 하려면 이 중 문서와 피드백은 임베딩해서 벡터 검색을 걸어야 합니다. 여기서 흔한 선택지는 Pinecone·Weaviate·Qdrant 같은 전용 벡터DB입니다. 하지만 저희 상황에서는 두 가지가 걸렸습니다.
첫째, 운영 대상이 하나 더 늘어납니다. 이미 PostgreSQL(Supabase)을 쓰고 있는데, 벡터만을 위해 별도 저장소를 붙이면 백업·모니터링·장애 대응·비용이 전부 두 배가 됩니다. 에이전트가 프로덕션에서 죽는 이유는 대개 모델 성능이 아니라 이런 운영 엔지니어링 쪽입니다.
둘째, 벡터와 원본 데이터가 갈라집니다. 피드백 중복을 찾으려면 "이 벡터와 가까운 피드백"을 찾은 다음, 그 피드백의 제목·상태·담당자·GitHub 이슈 URL을 다시 붙여야 합니다. 벡터DB에 벡터만 있으면 이 조인을 애플리케이션 레벨에서 두 번 왕복하며 손으로 합쳐야 합니다.
pgvector는 이 두 문제를 동시에 지웁니다. 같은 Postgres 안에서 벡터 검색과 관계형 JOIN을 한 문장으로 끝낼 수 있기 때문입니다.
말보다 코드가 분명합니다. 피드백 유사도 검색의 실제 쿼리입니다. 임베딩 테이블(feedback_embedding)과 원본 피드백 테이블을 그 자리에서 조인합니다.
sqlSELECT e.feedback_id, f.title, f.status, f.github_issue_url, 1 - (e.embedding <=> $1::vector) AS similarity FROM public.feedback_embedding e JOIN public.admin_feedback f ON f.id = e.feedback_id WHERE e.source = $2 AND e.feedback_id <> $3 ORDER BY e.embedding <=> $1::vector LIMIT $4
벡터 유사도(<=>)로 정렬하면서, 원본 피드백의 표시용 컬럼을 같은 결과 행에 실어 옵니다. 전용 벡터DB였다면 (1) 벡터DB에서 top-k id를 받고 (2) 그 id들로 Postgres를 다시 조회하고 (3) 코드에서 유사도와 병합하는 3단계가 됩니다. pgvector는 이게 그냥 한 번의 쿼리입니다.
정렬 기준인 <=>는 코사인 거리라 작을수록 가깝습니다. 그래서 ORDER BY ... <=>는 오름차순(가까운 순)이고, 사람이 읽을 유사도는 1 - 거리로 뒤집어 만듭니다. 이 규칙이 코퍼스 검색·피드백 검색 어디서든 동일합니다.
첫 벽은 ORM이었습니다. Prisma는 pgvector의 vector 타입을 네이티브로 지원하지 않습니다. 그래서 스키마에서는 형상만 잡아두고, 실제 읽고 쓰기는 raw SQL로 내려갑니다.
plain/// Prisma 가 pgvector 의 vector 타입을 네이티브 미지원 → /// embedding 은 Unsupported 로 형상만 관리하고 검색은 $queryRaw(코사인 <=>)로 수행. model FeedbackEmbedding { id BigInt @id @default(autoincrement()) source String feedbackId BigInt embedding Unsupported("vector(768)") model String contentHash String // ... }
Unsupported로 잡은 컬럼은 Prisma Client의 타입 세이프한 쿼리로는 손댈 수 없습니다. 그래서 임베딩을 다루는 부분은 전부 $queryRaw / $executeRaw로 격리했습니다. 벡터는 pgvector 리터럴 [v1,v2,...]::vector로 직렬화해서 바인딩합니다.
여기서 한 가지 실수하기 쉬운 지점이 있습니다. 벡터 리터럴을 문자열로 만들다 보면 인젝션·NaN 유입 위험이 생깁니다. 그래서 직렬화 전에 유한 숫자만 통과시키는 가드를 둡니다.
typescriptfunction toVectorLiteral(vector: number[]): string { const parts = vector.map((v) => { if (typeof v !== 'number' || !Number.isFinite(v)) { throw new Error('임베딩 벡터에 유효하지 않은 값이 있습니다.'); } return v; }); return `[${parts.join(',')}]`; }
테이블명처럼 리터럴로 끼워 넣어야 하는 값은 사용자 입력을 절대 받지 않고, admin_feedback/user_feedback 같은 화이트리스트에서만 인터폴레이션합니다. 그 외 모든 값은 파라미터 바인딩으로 넘깁니다.
임베딩 모델·차원은 한 파일(SSOT)에서만 정의합니다. 차원을 바꾸면 pgvector 컬럼 vector(N)과 전체 재인덱싱이 따라오기 때문에, 상수를 모듈마다 복제하지 않고 한 곳에 못박아 드리프트를 구조적으로 막았습니다.
typescriptexport const EMBEDDING_MODEL = 'gemini-embedding-2'; export const EMBEDDING_DIMENSIONS = 768; export type EmbeddingTaskType = | 'RETRIEVAL_DOCUMENT' // 검색 대상(코퍼스 청크) 적재용 | 'RETRIEVAL_QUERY' // 검색 질의(질문) 임베딩용 — DOCUMENT 와 비대칭 매칭 | 'SEMANTIC_SIMILARITY'; // 대칭 유사도 비교(피드백 중복 탐지)용
두 가지가 의도된 결정입니다.
gemini-embedding-2는 기본 3072차원(가변)입니다. outputDimensionality: 768로 줄여 컬럼과 맞췄습니다. 차원이 낮으면 저장·검색 비용이 줄고, HNSW 인덱스도 가벼워집니다.RETRIEVAL_DOCUMENT로, 질문은 검색할 때 RETRIEVAL_QUERY로 임베딩합니다(비대칭 매칭). 피드백 중복처럼 "글끼리 서로 얼마나 닮았나"는 대칭이라 SEMANTIC_SIMILARITY를 씁니다. 이 셋을 뭉뚱그리면 검색 품질이 눈에 띄게 떨어집니다.참고로 처음에는 구형 text-embedding-004를 붙이려다 SDK v1beta의 embedContent에서 NOT_FOUND(404)로 막혔습니다. 이후 gemini-embedding-001을 거쳐 지금의 gemini-embedding-2로 이관했는데, 모델을 바꾸면 벡터 공간 자체가 달라져 기존 임베딩을 전량 재인덱싱해야 합니다. 그래서 임베딩 레코드에 model 컬럼을 두고, 내용 해시(content_hash)가 같아도 모델이 다르면 새 모델로 강제 재임베딩하게 했습니다.
typescriptconst existing = await this.repo.findByFeedback(source, feedbackId); if (existing?.contentHash === contentHash && existing.model === EMBEDDING_MODEL) { return; // 동일 내용 + 동일 모델 — 재임베딩/재과금 skip } // contentHash 만 보면 모델 교체 후 구모델 벡터가 영구 잔존한다(벡터공간 불일치).
이 content_hash(임베딩 대상 텍스트의 sha256)는 재과금을 막는 장치이기도 합니다. 내용이 그대로면 임베딩 API를 다시 부르지 않습니다.
인덱스는 HNSW를 vector_cosine_ops로 걸었습니다.
sqlCREATE EXTENSION IF NOT EXISTS vector; CREATE INDEX IF NOT EXISTS idx_assistant_doc_chunk_embedding ON public.assistant_doc_chunk USING hnsw (embedding vector_cosine_ops);
솔직히 말하면 지금 코퍼스 규모에서는 인덱스 없이 풀스캔해도 충분히 빠릅니다. 그런데도 HNSW를 미리 넣은 건, 앞으로 유입될 데이터가 폭증할 걸 알고 있었기 때문입니다. HNSW는 빌드가 느린 대신 데이터가 적을 때도 정확도가 잘 나옵니다. 반대로 ivfflat은 클러스터가 설 만큼 데이터가 쌓이기 전엔 리콜이 흔들려서 이번엔 배제했습니다. 마이그레이션은 전부 CREATE ... IF NOT EXISTS로 감싸 반복 실행에 안전하게(멱등) 만들었습니다.
문서 청킹은 마크다운 heading을 경계로 섹션을 나누고, 한 섹션이 상한(약 2000자 ≈ 500토큰)을 넘으면 문단 단위로 그리디 패킹합니다. 한 가지 디테일은 heading 라인을 청크 본문에 같이 넣어 임베딩한다는 점입니다. 제목 맥락이 벡터에 실려 검색 정확도가 오르고, 동시에 heading을 출처 라벨로 따로 보관해 답변에 출처 칩으로 노출합니다.
재인덱싱은 source(문서) 단위로 통째로 지우고 다시 넣는 트랜잭션이라 멱등합니다. 문서가 줄어들어도 고아 청크가 남지 않습니다.
RAG를 "질문 → 임베딩 → 검색 → 프롬프트에 끼워넣기" 고정 파이프라인으로 짜지 않았습니다. 대신 단일 에이전트가 필요한 도구를 스스로 골라 호출하는 함수콜 루프로 만들었습니다. 이유는 단순합니다. 사용자가 묻는 게 지식 검색일 수도, 피드백 중복 조회일 수도, 비용 집계일 수도 있는데, 이걸 하나의 라우터 if문으로 분기하고 싶지 않았습니다.
도구는 자기완결적으로 하나씩 만들었습니다.
search_corpus — 질문을 임베딩해 코퍼스 top-k를 코사인 검색find_similar_feedback — 특정 피드백과 의미적으로 유사한 기존 피드백(중복 의심) 조회get_cost_summary — 기간별 외부 API 호출량·추정 비용 집계에이전트 루프는 도구 호출이 없을 때까지 generate를 반복하고, 마지막에 도구를 켠 채 동일 프롬프트로 최종 답변을 스트리밍합니다.
typescript// 함수콜 폭주 차단 상한 — 매 턴 도구를 요청해도 이 횟수에서 루프를 끊는다. export const MAX_TOOL_ROUNDS = 5; for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { const turn = await withTimeout( this.model.generate({ systemPrompt: bot.systemPrompt, history, tools: specs }), MODEL_TIMEOUT_MS, 'model.generate', ); if (turn.toolCalls.length === 0) break; // ...도구 병렬 실행 후 결과를 history 에 넣고 다음 라운드로 }
MAX_TOOL_ROUNDS = 5는 안전장치입니다. 단순 룩업은 1~2라운드면 수렴하지만, 모델이 매 턴 도구를 요청하며 비용·지연을 폭주시킬 수 있어 상한을 둡니다. 모델 호출 30초, 도구 15초의 타임아웃도 걸어 hang이 SSE를 무한정 붙잡지 못하게 했습니다.
도구 실행 실패는 흐름을 끊지 않고 결과에 error를 담아 모델에 되돌립니다. 다만 검색 도구의 임베딩·검색 장애만은 명시적으로 실패 신호(ok:false)를 냅니다. 무음 실패로 빈 결과가 오면 모델이 "지식이 없다"고 착각해 환각하기 때문입니다. "검색 실패"와 "결과 없음"을 모델이 구분하게 만드는 게 핵심이었습니다(앞서 겪은 임베딩 404가 딱 이 함정이었습니다).
RAG에서 놓치기 쉬운 게 접근 제어입니다. 코퍼스에는 운영자만 봐야 할 문서가 섞여 있습니다. 그래서 검색 쿼리에 열람 권한 필터를 강제로 겁니다.
typescriptasync search(embedding: number[], topK: number, viewerRole: string) { const vectorLiteral = `[${embedding.join(',')}]`; const rows = await this.prisma.$queryRaw` SELECT source, heading, content, 1 - (embedding <=> ${vectorLiteral}::vector) AS similarity FROM assistant_doc_chunk WHERE ${viewerRole} = ANY(visible_to) ORDER BY embedding <=> ${vectorLiteral}::vector LIMIT ${topK} `; // ... }
포인트는 viewerRole을 도구 인자(LLM이 채우는 값)로 받지 않는다는 것입니다. 이 값은 요청의 신뢰 경계(ctx.role)에서만 파생합니다. LLM이 스코프를 넓히도록 유도당하는 걸 원천 차단하기 위해서입니다. 게다가 = ANY(visible_to)는 미지의 role이 들어오면 매칭이 0행이라 기본이 거부(default-deny)입니다. 실수로 새 role이 생겨도 문서가 새지 않습니다.
RAG 품질을 좌우하는 숨은 파라미터가 유사도 컷오프입니다. "안녕", "날씨 어때" 같은 무관한 입력에도 벡터 검색은 어쨌든 top-k를 돌려주기 때문에, 일정 유사도 미만은 컨텍스트에서 잘라내야 합니다. 이 값을 감으로 정하지 않고 스테이징에서 분포를 실측했습니다.
typescript/** * 실측(gemini-embedding-2/768, staging 코퍼스, 2026-06-18): * 도메인 질문 top1 0.67~0.79 / 무관 질문 0.50~0.59. * 0.63 컷이면 도메인은 통과(최저 0.669), 무관(안녕·날씨·잡담)은 전부 걸러진다(최고 0.589). * embedding-001(이전 0.75~0.85) 대비 분포가 통째로 내려가, 기존 0.7 컷은 * 도메인 질문까지 탈락시켰음 → 재측정 후 하향. */ export const SEARCH_SIMILARITY_THRESHOLD = 0.63; export const SEARCH_TOP_K = 6;
여기서 배운 게 있습니다. 임베딩 모델을 바꾸면 유사도 분포가 통째로 이동합니다. gemini-embedding-001 시절 도메인 질문은 0.750.85에서 놀았는데, 0.79로 내려앉았습니다. 그때 쓰던 0.7 컷을 그대로 뒀다면 정상적인 도메인 질문까지 잘려나갔을 겁니다. 모델을 교체할 때 컷오프를 재측정하지 않으면 조용히 리콜이 무너집니다.gemini-embedding-2로 오니 0.67
도구 결과는 텍스트로만 흘리지 않고 구조화된 UI 페이로드로도 내보냅니다. 비용 조회는 표 카드로, 유사 피드백은 목록으로 렌더링됩니다.
typescriptreturn { llmContent: { period, total, byService }, ui: { kind: 'cost-summary', payload: { period, total, items: byService } }, };
에이전트 루프가 도구 호출 전후로 tool_call / tool_result 이벤트를 흘려주기 때문에, 프런트에서는 "검색 중 → 완료 → 표"로 이어지는 사고 과정(ThoughtChain)을 그대로 시각화할 수 있습니다. 답변에는 검색에 쓰인 출처가 칩으로 붙어, 사용자가 근거 문서를 바로 확인합니다.
전용 벡터DB가 필요한 순간은 분명 옵니다. 대체로 벡터 수가 수천만~1억을 넘어 손익분기를 지날 때입니다. 하지만 사내 지식·피드백 정도의 규모에서, 이미 PostgreSQL을 운영 중이라면 별도 저장소를 붙이는 건 대개 과한 선택입니다.
이번에 pgvector로 얻은 것을 정리하면 이렇습니다.
대신 Prisma의 Unsupported, raw SQL 격리, 벡터 리터럴 인젝션 가드처럼 ORM 밖으로 내려가는 품이 듭니다. 그 비용을 감수할 만한가가 판단 기준입니다. 저희에겐 JOIN 한 방의 가치가 그 품보다 훨씬 컸습니다.
vector(N)과 재인덱싱이 딸려오는 값을 모듈마다 복제하면 언젠가 드리프트로 터집니다.= ANY(visible_to)로 기본 거부를 만들면 새 role이 생겨도 안전합니다.// Comments