WIKIRAG
플레이북

RAG

LLM을 내 데이터에서 찾은 근거 위에 세우는 법.

RAG임베딩벡터검색OpenSearchGraphRAG

RAG가 푸는 문제

LLM은 학습한 것만 안다. 그리고 모르는 것도 아는 척 그럴듯하게 지어낸다. "내 지난주 일정 요약해줘"에 LLM이 혼자 답하면 그건 창작이다. 내 데이터를 본 적이 없으니까.

RAG(Retrieval-Augmented Generation)는 그 사이에 검색 한 단계를 끼운다. 질문이 오면 먼저 내 데이터에서 관련 조각을 찾아오고(Retrieval), 그걸 프롬프트에 실어 LLM이 그 근거 위에서 답하게(Generation) 한다. 모델의 기억이 아니라 찾아온 근거가 답의 재료가 되는 것이다.

나는 이걸 먼저 두 번, 완전히 다른 규모로 만들어봤다. 하나는 개인 데이터를 다루는 새김AI, 하나는 사내에서 운영급 RAG 자동화 파이프라인을 단독으로 설계·구현한 RagOps다(K8s 같은 공통 인프라는 팀이 맡았다). 같은 개념인데 규모가 다르니 설계가 갈렸고, 그 차이가 RAG를 제일 잘 설명한다.

파이프라인

문서 (게시글·일정·문서첨부)
내 데이터
청킹
긴 문서를 검색 단위로 자름
임베딩
텍스트 → 벡터
벡터 저장
Firestore inline · OpenSearch
질의 → 검색
질문도 임베딩해 유사도 top-k
프롬프트 주입 + LLM
찾은 근거 위에서 생성
출처 그라운딩
답에 근거 뱃지

RAG는 결국 이 한 줄이다. 저장할 땐 잘게 쪼개 벡터로 심고, 물어볼 땐 질문도 벡터로 만들어 가까운 조각을 꺼낸다. 나머지는 규모에 맞춘 선택의 문제다.

두 개의 RAG, 두 개의 규모

1,536
임베딩 차원 (text-embedding-3-small)
0
새김AI 초기: 청킹·벡터DB 없이 시작
brute-force
개인 규모 검색 (전량 코사인)
벡터+BM25
공용 색인 검색 (RRF 융합)
OpenSearch
RagOps: 운영급 벡터/검색 저장소

새김AI, 개인 규모의 미니멀 RAG

새김AI는 "내 모든 데이터를 아는 AI"가 목표였다. 게시글·일정·일기를 text-embedding-3-small(1536차원)로 임베딩했다.

여기서 내린 판단이 이 프로젝트의 RAG를 규정한다. 처음엔 청킹도 벡터DB도 쓰지 않았다. 문서 하나를 벡터 하나로 만들어 Firestore에 인라인으로 저장하고, 검색은 전량 브루트포스 코사인으로 돌렸다. 개인 데이터 규모엔 전용 벡터DB나 ANN(근사 최근접) 같은 게 필요 없다는 계산이었다. 몇 만 건 남짓이면 전부 훑어도 순식간이고, 인프라를 하나 덜 얹는 게 이득이었다.

규모가 커지자 설계가 따라 진화했다. 문서 첨부 기능이 붙으면서 2000자를 넘는 장문이 들어오기 시작했고, 벡터 하나로는 뒷부분이 검색에서 묻혔다. 그래서 오버랩 청커를 얹었다. 문서를 겹치는 구간으로 잘라 각 조각을 따로 임베딩하니, 긴 문서의 어느 대목이든 검색에 걸렸다. 짧은 개인 기록엔 청킹이 필요 없었지만, 규모가 청킹을 불러온 셈이다.

자세한 구현은 새김AI 위키에 있다.

RagOps, 운영급 RAG 파이프라인

반대쪽 규모는 사내에서 단독으로 설계·구현한 운영급 RAG 자동화 파이프라인, RagOps다. 여기선 개인 규모의 미니멀리즘이 안 통한다. 문서가 많고, 계속 들어오고, 한국어라 전처리가 무겁다.

그래서 파이프라인을 제대로 세웠다. 워커가 원문을 추출하고 → 형태소로 분석하고 → 청킹하고 → 임베딩해서 → OpenSearch에 적재하는 단계를, 오케스트레이터가 순서대로 돌린다. 브루트포스로 훑던 개인 규모와 달리, 여기선 검색 저장소(OpenSearch)가 색인과 유사도 검색을 맡는다.

같은 RAG인데, 새김AI가 "필요할 때까지 안 만든다"였다면 RagOps는 "처음부터 흐름으로 만든다"였다.

세 번째 규모, 색인을 제품 밖으로 꺼내기

개인 규모와 운영급 사이에 하나가 더 생겼다. 여러 제품이 나눠 쓰는 색인이다.

새김AI 제품 자체는 종료작이다. 그런데 다른 걸 만들다 보니 "여기도 검색이 필요한데 파이프라인을 또 짜야 하나"가 됐다. 그래서 새김AI 백엔드에 있던 파싱·청킹·임베딩·적재·검색을 API 세 개로 떼냈다. 색인하고, 검색하고, 지운다. 첫 사용처는 사내 그룹웨어의 자원 방이다.

경계를 하나로 정했다. 색인은 플랫폼이 갖고, 원본과 권한과 화면은 제품이 갖는다. 그래서 응답에 벡터가 나가지 않는다. 색인은 요약만, 검색은 히트만 돌려준다. 부르는 쪽은 자기 코드에 "벡터"라는 말을 쓸 일이 없다.

색인을 플랫폼에 둔 이유는 단순하다. 임베딩 모델이나 청크 크기를 바꾸면 색인을 전량 다시 만들어야 하는데, 제품마다 색인을 들고 있으면 그때 제품을 하나씩 찾아다녀야 한다. 자기 데이터라야 자기 판단으로 갈아엎는다. 잠금이 생기지도 않는다. 원본은 제품에 있으니 파이프라인을 갈아끼워도 재색인하면 그만이다.

천장은 전제에서 나온다

떼내면서 오래된 천장 하나가 사라졌다. 개인 규모에선 문서 하나의 청크를 전부 그 문서 안에 넣었다. Firestore 문서 한 건이 1MiB라 청크 수에 상한(20개)을 걸어 뒀고, 실측하면 본문 약 2만 자에서 잘렸다.

공용 색인은 청크 하나를 문서 하나로 쓴다. 그러니 그 전제가 없고, 천장도 없다. 상한은 다른 이유로 다시 걸었다. 지금은 청크 300개다. Firestore 배치 쓰기가 한 번에 500 연산이라 삭제와 쓰기가 각각 한 배치에서 끝나고, 실측으로 약 89,000자(A4 45쪽쯤)를 덮는다. 넘치면 조용히 자르지 않고 "잘랐다"고 응답에 적는다.

이 숫자는 처음에 120이었다. 나중에 청크 크기를 1200자에서 400자로 줄이자 같은 상한이 덮는 본문이 118,000자에서 34,416자로 떨어졌다. 청크 크기와 상한은 따로 만질 수 없는 한 쌍이라는 걸 실측하고서야 알았다.

여기서 제일 아팠던 건 조용한 손실이었다. 처음엔 남은 텍스트를 매번 청커에 넣고 마지막 청크를 되짚어 얼마나 먹었는지를 쟀는데, 반복 문단이 많은 장표에서 그 되짚기가 뒤쪽의 같은 문장을 잡았다. 42,000자 문서가 청크 20개로 "전량 색인 완료"라고 보고됐다. 잘렸다는 말도 없이. 지금은 청커가 확실히 다 삼킬 크기로 창을 미리 잘라 넣고, 창마다 끝까지 먹었는지 확인한다.

여전히 벡터DB는 없다

공용으로 떼냈다고 인프라를 얹지는 않았다. 검색은 그대로 Firestore에서 청크를 끌어와 파이썬에서 줄 세운다. 뒤에 나올 키워드 검색을 붙일 때도 검색엔진을 들이지 않고 파이썬 150줄로 짰다. 대신 다른 문제가 생겼다. Firestore는 문서 읽기 한 건마다 과금한다. 청크가 문서 하나씩이니, 문서 100건에 청크 30개면 검색 한 번이 3,000 읽기다. 하루 무료 한도가 5만이면 검색 열여섯 번에 끝난다.

그래서 자원 출처 단위로 청크를 들고 있는다. 무효화는 시간이 아니라 쓰기로 한다. 색인하거나 지우면 그 출처를 버린다. 시간 만료만 두면 방금 올린 파일이 한동안 검색에 안 잡히고, 그러면 사용자는 색인이 실패한 줄 안다. 시간 만료도 같이 두긴 했다. 인스턴스가 여럿이면 다른 인스턴스의 쓰기를 모르기 때문이다.

실제 자료로 재 보고 나서야 알게 된 것

배관이 다 도는 것과 검색이 쓸 만한 것은 다른 얘기였다. 11슬라이드짜리 사업 제안 장표 하나를 실제로 색인하고 질문 다섯 개를 던져 봤다. 세 가지가 무너져 있었다.

첫째, 임계값이 정답을 자르고 있었다. 유사도 0.3 미만은 걸러 버렸는데, "이사는 언제 하기로 했나"의 정답 문서가 0.276이라 잘렸다. 같은 판정에서 "점심 메뉴 추천"이 관계없는 그림을 0.361로 통과시켰다. 코사인 값의 절대 크기는 질의 길이와 청크 길이에 좌우된다. 짧은 한국어 질의는 통째로 낮게 나오고, 짧고 빽빽한 이미지 캡션은 아무 질문에나 중간값이 나온다. 어느 숫자를 박아도 같은 사고가 난다. 그래서 기본값을 0으로 내렸다. 거르지 않고 점수를 그대로 실어 보낸다.

둘째, 청크가 너무 컸다. 1200자면 슬라이드 56장이 한 덩어리다. "회의실 부족 문제"로 물으면 그 문장이 있는 청크가 0.17로, 같은 문서의 캡션 청크보다도 낮았다. 나머지 다섯 슬라이드가 신호를 희석한 것이다. 400자로 줄이니 슬라이드 12장에 대응했고, 같은 장표가 2청크에서 6청크로 갈렸다.

셋째, 뜻으로 찾는 것을 잘해도 글자로 물으면 졌다. "인테리어 시공비"의 정답은 예산 표 안의 "인테리어 1억 8천만원"인데, 표가 항목 | 금액 | 비고로 납작해서 그 낱말 둘레에 문맥이 거의 없다. 반면 후보지 슬라이드는 "임대료", "제곱미터" 같은 어휘가 빽빽해서 이긴다. 벡터는 을 본다. 드물고 정확한 낱말 하나는 벡터가 약하고, 그건 키워드의 일이다.

하이브리드, 점수가 아니라 등수를 합친다

그래서 BM25를 붙였다. 한국어라 형태소 분석기가 필요한데("인테리어를"과 "인테리어"는 다른 문자열이다) 사전을 얹고 빌드에 붙이는 값이 지금 규모에 안 맞아서, 한글은 글자 2-gram으로 자른다. CJK에서 분석기 없이 쓰는 표준 우회다. "인테리어를"과 "인테리어"가 세 조각 겹친다. 라틴과 숫자는 낱말 그대로 둔다. "5700"을 2-gram으로 쪼개면 오히려 흐려진다.

문제는 둘을 어떻게 합치느냐다. 코사인은 0~1 언저리이고 BM25는 상한이 없다. 정규화해서 더할 수도 있지만, 정규화 기준을 무엇으로 잡든 질의마다 분포가 달라 흔들린다. 그리고 그날 겪은 사고가 정확히 그것이었다. 절대값을 믿었다가 정답을 자른 것.

그래서 RRF(Reciprocal Rank Fusion)를 썼다. 점수를 안 보고 등수만 본다. 두 방식이 서로 다른 눈금을 써도 되고, 한쪽에만 잡힌 문서도 자기 등수만큼의 몫을 받는다. 절대값은 못 믿고 순위는 믿을 수 있다. 응답에는 벡터 점수와 키워드 점수를 같이 싣는다. 어느 갈래가 잡았는지 모르면 "왜 이게 1등인가"를 물을 수 없다.

출처 그라운딩, 답을 못 믿게 두지 않는다

RAG의 진짜 값은 근거를 되짚을 수 있다는 것이다. 검색으로 찾아온 조각이 있으니, 답에 "이건 여기서 나왔다"를 붙일 수 있다.

새김AI는 LLM이 응답 문장 끝에 [1][2] 같은 인용 마커를 심게 하고, 프론트가 그 마커를 파싱해 출처 뱃지로 렌더한다. 답을 그냥 믿으라는 게 아니라, 어느 기록에서 나왔는지 보여준다. (이 마커가 다단계 추론 엔진에선 종합 과정에 흘러버리는 문제도 겪었는데, 그 얘기는 새김AI 위키에 있다.)

모델이 적은 출처는 믿지 않는다

검색이 근거를 고르면, 그걸 문장으로 만드는 단계가 남는다. 공용 색인에 답변 엔드포인트를 붙였다. 여기서 제일 신경 쓴 것은 날조하는 순간 못 쓰게 되는 기능이라는 점이다. 한 번 지어낸 답을 보면 그다음부터 모든 답을 의심하게 되고, 의심하면서 쓸 바에는 파일을 직접 여는 게 빠르다.

두 겹으로 막았다. 부르기 전에 근거를 재고(키워드 점수가 0보다 크거나 유사도가 0.35 이상), 그러고도 모델에게 "근거에 없으면 없다고 말하라"고 시킨다. 여기서도 두 값을 같이 봐야 갈렸다. "점심 메뉴 추천"은 유사도가 0.331로 정답들과 비슷했는데 키워드 점수가 0이었다. 그 말이 문서에 아예 없다는 뜻이다.

그런데 실제로 던져 보니 모델이 답은 맞게 하고 출처는 틀리게 적었다. "인테리어 1억 8천만원"이라고 답하면서 근거로는 도입부 슬라이드를 가리켰다. 그 조각에 그 숫자가 없다. 뒷받침하지 않는 출처는 출처가 없는 것보다 나쁘다. 검증된 것처럼 보이는데 아니기 때문이다.

그래서 번호를 안 받는다. 모델에게 근거마다 원문 그대로의 인용구를 적게 하고, 그 글자가 실제로 어느 조각에 들어 있는지 코드가 찾는다. 번호는 검증할 수 없지만 글자는 검증할 수 있다. 판단이 개입하지 않는다. 덤으로 어느 조각이 근거인지도 이걸로 정해진다. 모델이 몇 번이라고 했든, 그 글자가 든 조각이 곧 근거다. 하나도 못 찾으면 답을 내보내지 않고 읽을 대목만 넘긴다.

그리고 답을 못 냈다고 빈손으로 돌려보내지 않는다. "근거 자체가 없음"과 "근거는 걸렸는데 답을 못 냄"은 다른 결과다. 뒤엣것은 실패가 아니라 직접 읽을 대목을 찾아 준 것이라, 그때 근거를 안 주면 사용자가 할 수 있는 일이 없어진다.

GraphRAG, 납작한 검색을 관계로

벡터 검색은 "비슷한 조각"은 잘 찾지만, "이 사람과 저 프로젝트의 관계"처럼 연결은 못 본다. 그래서 새김AI에 GraphRAG를 얹었다.

납작하게 쌓인 메모리에서 사람·장소·프로젝트·관심사 같은 엔티티와 그 관계를 뽑아 "AI가 아는 나" 그래프를 만든다. 질의가 들어오면 이름을 그래프 노드와 매칭(LLM 호출 없이)해 한 홉 이웃까지 훑어 근거 기억을 주입한다.

여기서 배운 게 있다. 처음엔 그래프를 그리기만 하고 질의에 참조하지 않아서, "시각화지 GraphRAG가 아니었다." 엔티티 링킹과 서브그래프 주입을 붙이고 나서야 이름값을 했다. 검색에 실제로 쓰이지 않는 그래프는 그냥 그림이다.

Decision Log

Decision #01개인 규모엔 벡터DB·ANN을 안 쓴다

새김AI의 데이터는 전량 브루트포스 코사인으로 훑어도 순식간이다. 전용 벡터DB나 근사 검색을 얹는 건 규모에 안 맞는 과설계였다. 인프라 하나를 덜 얹는 쪽을 택했다.

Decision #02청킹은 필요해질 때 도입한다

짧은 개인 기록엔 문서=벡터 하나로 충분했다. 청킹은 문서 첨부(장문)가 들어오면서 검색이 뒷부분을 놓치기 시작했을 때 오버랩 청커로 도입했다. 규모가 설계를 끌고 왔다.

Decision #03운영급은 파이프라인으로, 저장소에 검색을 위임

RagOps는 문서가 많고 한국어 전처리가 무거워, 추출→형태소→청킹→임베딩→적재를 오케스트레이터가 도는 흐름으로 세우고 검색은 OpenSearch에 맡겼다. 규모가 다르면 답도 다르다.

Decision #04찾은 근거는 반드시 출처로 되짚게

RAG를 쓰는 이유의 절반은 "답을 검증할 수 있다"는 것. 인용 마커 → 출처 뱃지로 답이 어느 기록에서 나왔는지 남긴다.

Decision #05색인은 플랫폼이, 원본과 권한은 제품이 갖는다

여러 제품이 검색을 쓰게 되면서 파이프라인을 공용 API로 떼냈다. 임베딩 모델이나 청크 크기를 바꾸면 색인을 전량 다시 만들어야 하는데, 제품마다 색인을 들고 있으면 그때 제품을 하나씩 찾아다녀야 한다. 대신 원본은 제품에 남겨 잠금이 생기지 않게 했다. 응답에 벡터를 담지 않는 것도 같은 이유다.

Decision #06잘렸으면 잘렸다고 말한다

색인이 조용히 본문을 버리면 검색이 왜 안 걸리는지 아무도 모른다. 42,000자 문서를 청크 20개로 자르고 "전량 색인 완료"라고 보고한 적이 있다. 지금은 상한에 걸리면 응답에 잘림 여부와 실제 색인한 글자 수를 같이 싣는다.

Decision #07유사도 임계값을 없애고, 점수 대신 등수를 합친다

코사인 값의 절대 크기는 질의와 청크 길이에 좌우된다. 0.3을 기준으로 삼았다가 정답(0.276)을 자르고 오답(0.361)을 통과시켰다. 어느 숫자를 박아도 같은 사고가 난다. 그래서 거르지 않고 점수를 실어 보내고, 벡터와 키워드는 RRF로 등수만 합친다.

Decision #08모델이 적은 출처를 믿지 않고 글자로 대조한다

모델이 답은 맞게 하고 출처는 틀리게 적는 것을 봤다. 뒷받침하지 않는 출처는 출처가 없는 것보다 나쁘다. 번호 대신 원문 인용구를 받아 실제 조각과 글자를 대조하고, 못 찾으면 답을 내보내지 않는다.

배운 것

RAG는 하나의 정답이 아니라 규모에 맞춘 선택의 묶음이다. 데이터가 작으면 브루트포스와 인라인 저장으로 충분하고, 그때 벡터DB를 얹는 건 과설계다. 데이터가 크고 계속 들어오면 파이프라인과 검색 저장소가 필요하다. 같은 개념을 두 규모로 만들어보고 나서야, "RAG를 쓴다"가 아니라 "이 규모엔 이 RAG"라고 말할 수 있게 됐다.

규모 다음에 온 건 경계였다. 파이프라인을 두 번 짜기 싫어서 색인을 제품 밖으로 꺼내 놓고 나니, 설계가 "무엇을 얹느냐"에서 "어느 쪽이 무엇을 갖느냐"로 옮겨갔다. 색인은 플랫폼이, 원본과 권한은 제품이. 그 선을 긋고 나서야 파이프라인을 갈아엎어도 제품이 안 흔들린다.

그리고 벡터 검색은 만능이 아니다. 유사도는 조각을 찾지만 관계는 못 본다. 거기서 GraphRAG가 갈라져 나온다. 그건 따로 다룬다.

마지막으로, RAG는 배관이 도는 것을 확인한 지점에서 시작한다. 색인이 되고 검색이 돌아온다고 다 된 게 아니었다. 실제 자료 하나를 넣고 실제로 물어봐야 임계값이 정답을 자르고 있다는 것도, 청크가 신호를 희석한다는 것도, 모델이 출처를 틀리게 적는다는 것도 보였다. 그 세 개는 코드를 읽어서는 안 나왔다.