Cheap RAG in Go with Gemini File Search: no vector DB, two calls, one hosted store
Go와 Gemini File Search로 만드는 저렴한 RAG — 벡터 DB 없이 두 번의 호출과 하나의 호스팅 저장소
Go와 Gemini File Search만으로 벡터 DB와 별도 임베딩 파이프라인 없이 RAG 기반 설계 리뷰 도구를 만든 과정을 설명합니다. 문서 변환, 저장소별 키 관리, 증분 색인, 패턴 중심 검색, JSON 검증까지 실제 운영에서 부딪힌 문제와 수치를 함께 다룹니다.
- 주제
AI 요약
이 글은 약 140만 토큰 분량의 기술 서적, 에세이, 내부 글과 회고 문서를 대상으로 설계 제안서의 선례를 찾아주는 RAG 도구를 구현한 과정을 설명합니다. 별도 vector database, embedding pipeline, chunker, reranker를 두지 않고 Go 바이너리 하나와 Gemini File Search, SQLite만 사용합니다. 기본 질문 처리는 두 번의 Gemini 호출로 구성하고, 검토 단계까지 실행하면 세 번째 호출이 추가됩니다.
전체 구조와 비용
Gemini File Search에서는 store를 만든 뒤 문서를 업로드합니다. Google이 문서를 chunking하고 embedding한 뒤 색인합니다. 이후 일반 generateContent 호출에 File Search store를 tool로 붙이면 모델이 답변 도중 저장소를 검색하고, 사용한 문서 조각을 groundingMetadata로 돌려줍니다. 구현은 modernc.org/sqlite를 사용해 cgo 없이 동작하는 Go 바이너리로 만들었고, Gemini SDK 대신 REST client를 직접 작성했습니다. 요청과 응답 본문을 그대로 기록하기 위해서입니다.
무료 등급에서 store 하나의 용량은 1GB입니다. 저자가 Markdown으로 변환한 전체 문서 모음은 5.3MB이며 약 140만 토큰입니다. 저장 비용과 질의 시 embedding 비용은 없고, 색인 시점에는 embedding 가격이 한 번 발생합니다. 검색으로 가져온 chunk는 기존 generateContent 호출의 일반 context token으로 계산됩니다. 질문 하나에는 보통 Flash 호출 두 번이 필요하고, 입력 약 1만 토큰과 출력 약 2천 토큰을 사용합니다. 무료 등급에서는 비용이 없고, 유료 키에서도 1센트보다 훨씬 낮다고 설명합니다. 응답 시간은 30~90초이며, 대부분 두 번째 호출에서 검색과 초안 생성을 수행하는 데 걸립니다.
PDF 변환과 chunking
RAG 품질은 문서가 어떤 텍스트로 저장되는지에 크게 좌우됩니다. markitdown은 PDF의 단어 사이 공백을 잃어 Theubiquityoffrustrating... 같은 결과를 만들었습니다. pdftotext는 공백은 복구했지만 제목을 모두 버려 300쪽 문서를 구분 없는 긴 텍스트로 만들었습니다. 최종적으로 pymupdf4llm을 선택했습니다. 글꼴 크기를 보고 제목을 추정하고 굵은 글씨와 기울임을 유지하며 공백도 제대로 추출했습니다.
예시 명령은 uv run --with pymupdf4llm python -c ... 형태로 PDF를 Markdown으로 변환합니다. 한 에세이에서 실제 제목 44개를 얻었고, 다른 도구에서는 제목이 0개였습니다. File Search는 whitespace와 token budget을 기준으로 chunk를 나누므로 제목에서 시작하는 조각은 단독 의미를 갖기 쉽습니다. 반대로 문장 중간에서 시작하는 조각은 embedding이 붙은 잡음이 되기 쉽습니다. EPUB, PDF, 블로그 저장소를 make process-data로 처리해 post_processed_data/ 아래에 모읍니다.
업로드 chunk 크기는 최대 400 tokens, overlap은 60 tokens로 설정했습니다. 문서 예시인 200 tokens와 20 tokens는 책을 다룰 때 문맥이 너무 좁았기 때문입니다. 71개 파일을 직렬로 업로드하면 약 10분이 걸리지만, 동시에 네 개씩 처리하면 몇 분으로 줄어듭니다. 각 업로드는 multipart POST로 장시간 실행 operation을 반환하므로 완료될 때까지 polling한 뒤에야 문서 ID를 신뢰합니다.
store와 API 키의 결합
File Search store는 생성한 API 키가 속한 Google Cloud project에 묶입니다. 키 A로 만든 store를 다른 project의 키 B로 검색하면 친절한 오류 대신 store가 없는 것처럼 동작합니다. 무료 등급의 분당 한도 때문에 여러 키를 돌려 쓰더라도 tool이 붙은 호출에서는 키를 임의로 교체할 수 없습니다.
그래서 키를 두 역할로 나눕니다. keys/store.txt의 키마다 corpus 전체 사본과 전용 store를 만들고, 검색은 store 0과 키 0을 먼저 사용합니다. quota나 인증 오류가 나면 store 1과 키 1로 넘어갑니다. tool이 없는 분석 호출과 corpus-index 요약 호출은 store에 묶이지 않으므로 keys/rotate.txt에 있는 키를 429, 401, 403, 5xx에서 교체합니다.
503 high demand는 키 문제가 아니라 Gemini 서비스가 바쁜 상태이므로 같은 키로 잠시 기다렸다가 재시도합니다. 키는 URL query string이 아니라 x-goog-api-key header에 넣습니다. URL에 넣으면 transport 오류가 발생할 때 로그에 키가 찍힐 수 있습니다. store를 두 개 운영하면 최초 색인 비용도 두 배가 되지만, 검색이 하나의 quota에 묶이지 않는 대가로 5MB 규모에서는 저자가 커피값 수준이라고 설명합니다.
증분 업로드와 corpus index
각 파일에는 SHA-256을 계산하고 SQLite manifest에 path -> sha -> document id를 기록합니다. 새 파일은 업로드하고, SHA가 바뀐 파일은 기존 문서를 삭제한 뒤 다시 업로드합니다. 디스크에서 사라진 파일은 store에서도 삭제합니다. 변경이 없으면 업로드를 건너뜁니다. 이 과정에서 파일별 문서 ID를 store마다 따로 관리합니다.
파일마다 가장 저렴한 모델인 gemini-3.5-flash-lite로 한 문장 요약을 만들고 파일 SHA를 기준으로 저장합니다. 요약을 path, summary 형식으로 합친 corpus index를 다음 분석 호출의 prompt에 넣습니다. 모델이 실제 저장소에 존재하는 문서를 기준으로 검색 대상을 정하게 하는 장치입니다.
이 manifest는 사용자가 아니라 store의 속성입니다. 초기 구현에서는 각 사용자의 SQLite 파일에 세션, 단계, ingest manifest를 모두 넣었습니다. 새 사용자가 빈 DB로 실행하면 71개 파일을 전부 새 파일로 판단해 다시 업로드하고, 기존 문서 ID를 몰라 삭제도 하지 못합니다. 같은 문서가 store에 중복되면 검색 결과가 동일한 chunk로 채워져 두 번째로 유용한 결과가 밀립니다. 현재는 corpus.db에 store 이름, manifest, corpus index를 보관하고 사용자별 DB에는 세션만 둡니다. corpus.db를 삭제하면 전체 재색인이 일어나고, 사용자 DB를 삭제해도 corpus는 바뀌지 않습니다.
주제 대신 패턴을 검색하는 두 단계 호출
원문 제안서를 그대로 검색하면 문서에 등장한 단어와 비슷한 결과만 찾습니다. 예를 들어 “모든 agent에 하나의 MCP transport를 표준화하자”라는 제안은 MCP, agent, standardize를 포함한 최근 글을 되돌려줄 가능성이 높습니다. 저자는 이것을 선례가 아니라 거울이라고 설명합니다.
먼저 제안서에서 구체적인 명사를 제거한 상황의 구조를 추출합니다. 예를 들어 “서로 호환되지 않는 구현이 흩어져 있다가 하나의 공개 표준으로 통합되고 대중적으로 채택된다”라는 패턴입니다. 이 형태로 검색하면 browser wars, Rickover의 핵무기 해군 표준화 사례, 2,300년 전 Legalist 문서의 uniform law처럼 주제는 다르지만 구조가 비슷한 자료가 반환됩니다.
첫 번째 호출에는 lawbook, corpus index, 제안서를 넣고 tool은 붙이지 않습니다. 응답은 motive, 실제로 벌어지는 일, 행위자, 일반화한 패턴, 그 패턴에서 만든 검색 질의 두세 개를 JSON으로 반환합니다. 두 번째 호출에는 첫 호출의 분석 결과와 제안서 원문을 넣고 File Search tool을 store에 연결합니다. 검색어는 원문 주제가 아니라 첫 호출이 추출한 패턴에서 나옵니다. 두 번째 응답의 groundingMetadata.groundingChunks에는 파일 표시 이름과 검색된 텍스트가 담기며, 이를 초안 옆에 그대로 보여줍니다. 검토에 사용한 근거가 grounding 목록에 없으면 출처 검사를 통과하지 못합니다.
prompt 관리와 검증 절차
자유 형식 system prompt를 두지 않고 AgentLaws lawbook을 사용합니다. 지침은 번호가 붙은 Markdown 파일로 장별 관리하고 Git에서 버전 관리합니다. 시작할 때 한 번 컴파일하며, 모델은 응답에 적용한 법칙 번호를 applied_laws로 반환합니다. 파이프라인은 번호를 lawbook의 파일과 줄 번호로 해석합니다. 존재하지 않는 번호가 나오면 실행이 실패합니다. 모델이 실제로 어떤 지침을 따랐는지 확인하고, 동작을 바꿀 때 Go 문자열을 뒤지는 대신 Markdown 파일을 수정하는 방식입니다.
두 번째 호출의 JSON 응답이 파싱되지 않으면 JSON mode 없이 한 번 다시 호출하고 텍스트에서 첫 번째 {...} 객체를 추출합니다. 두 시도 모두 로그로 남깁니다. 이후 일반 Go 코드가 초안 길이, Markdown 포함 여부, 질문 수, see point N 형태의 참조, 적용 법칙의 해석 가능 여부를 검사합니다. 실패 내용은 다음 재시도의 사용자 메시지에 평문 목록으로 붙입니다. 두 번 재시도한 뒤에도 실패하면 경고를 포함한 초안을 반환합니다.
검증을 통과하면 별도의 reviewer pass가 verdict와 objection을 생성합니다. 수정 판정이고 남은 라운드가 있으면 실패 내용과 함께 다시 초안을 만듭니다. 모든 라운드를 소진하면 실패 목록을 표시한 채 결과를 반환합니다. 모델이 스스로 채우는 checks 블록도 두 번째 신호로 활용하지만, 최종 판단은 결정론적 Go 검사와 출처·법칙 해석 결과를 함께 사용합니다.
운영 조건과 제한
모델은 실제 호출에 gemini-3.5-flash, 파일 요약에 gemini-3.5-flash-lite를 사용합니다. 무료 키에서 Pro는 limit 0인 429를 반환하며, 유료 키에서는 환경 변수 하나로 활성화할 수 있지만 저자는 필요하지 않았다고 설명합니다. 분당 rate limit이 일일 한도보다 먼저 문제가 되므로 ingest는 대기 시간을 늘리며 재시도하고, UI에는 어떤 키로 전환했는지 표시합니다.
무료 등급에서는 업로드한 데이터가 Google의 모델 학습에 사용될 수 있습니다. 저자는 자신의 corpus가 공개 서적과 공개 블로그라서 문제 삼지 않았지만, 고객 계약서처럼 민감한 자료를 넣을 때는 약관을 먼저 확인해야 한다고 적습니다. 이 구현의 조건은 문서가 Markdown으로 잘 정리되어 있고, hosted store와 로컬 manifest를 비교하며, 질문에서 직접 검색하기 전에 선례의 패턴을 추출하는 데 있습니다.
원문: dev.to / 번역·요약: Trawling