WIKIRAG
플레이북

RAG

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

RAG임베딩벡터검색OpenSearchGraphRAGMCP

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천만원"이라고 답하면서 근거로는 도입부 슬라이드를 가리켰다. 그 조각에 그 숫자가 없다. 뒷받침하지 않는 출처는 출처가 없는 것보다 나쁘다. 검증된 것처럼 보이는데 아니기 때문이다.

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

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

그 방어가 반대쪽으로 틀렸다

몇 주 뒤에 이 대조가 맞는 답을 버리는 걸 봤다.

색인에 드는 게 늘 산문은 아니다. 구조화된 기록이면 조각이 {"사무명": "경비업 허가신청", "수수료내역": "10,000원"} 같은 모양으로 온다. 모델은 그걸 읽고 사람이 읽을 말로 옮겨 적는다. "수수료내역: 10,000원"이라고 쓰지 따옴표와 중괄호까지 옮기지 않는다. 그러면 글자 대조는 그 둘을 다른 것으로 보고, 답은 맞는데 근거를 못 찾았다며 통째로 버린다. 쓰다가 걸렸다.

고칠 길이 둘이었다. 대조를 무르게 하거나, 원문을 읽을 수 있게 만들거나. 앞쪽은 한 줄이면 되는데 그러면 지어낸 답도 같이 통과한다. 방어가 거짓 양성을 냈다고 방어를 깎는 건, 못 잡는 쪽으로 틀리기로 하는 것과 같다.

그래서 막는 쪽은 빡빡한 채로 두고 읽히는 쪽을 고쳤다. 대조할 때 떼는 건 따옴표와 괄호, 쉼표와 콜론뿐이고 낱말과 숫자는 그대로 남는다. "10,000원"과 "10000원"이 같아지는 정도인데, 그 둘이 다른 뜻이었던 적은 없다. 그걸로도 안 맞는 모양은 원문 쪽을 제목 줄과 "키: 값" 줄들로 펴서 모델이 한 줄을 그대로 옮겨 적을 수 있게 했다. 객체로 읽히는 것만 펴고, 아니면 손대지 않는다. 못 편 게 틀리게 편 것보다 낫다.

작은 것 둘이 같이 붙었다. 길이로 짧은 인용을 거르는 규칙이 있는데, 그 판정은 구분 기호를 떼기 전 값으로 한다. 떼고 재면 "10,000원"이 8자에서 7자로 줄어 걸린다. 짧은 인용을 막는 규칙이 따옴표가 있고 없고로 갈리면 안 된다. 그리고 실패 문구를 둘로 갈랐다. "인용을 아예 안 적었다"와 "적었는데 원문에 없다"는 다음 수가 다른데 한 문장으로 묶여 있어서, 어느 쪽인지 가리느라 한 번 헤맸다. 모델이 뭐라고 적었는지는 로그에만 남긴다. 화면에 올리기엔 그 글이 지어낸 것일 수 있고, 그런데 조사하는 쪽에는 그게 곧 단서다.

근거 없으면 답하지 않는다를 모든 질문에 걸면 안 된다

이 방어를 전부에 걸었더니 "안녕?"에도 "뒤졌지만 근거가 될 대목이 없습니다"라고 답했다. 규칙 자체가 틀린 게 아니라 거는 자리가 틀렸다. 그 규칙은 회사 사실을 물었을 때만 맞다. 그래서 검색을 도구로 쥐여 주고 부를지 말지를 모델이 정하게 했다. 안 부르면 그냥 답하고, 그때는 출처가 없는 게 맞다.

되묻는 질문도 그대로는 못 찾는다. "그럼 그 사람 자리는?"을 통째로 임베딩하면 찾을 낱말이 없다. 앞선 몇 턴을 보고 혼자 뜻이 통하는 문장으로 다시 쓴 뒤에 검색한다. 다시 쓴 문장이 실패하거나 원문보다 한참 길어지면 원문을 쓴다.

색인에 칸막이를 세우다

여기까지는 부르는 쪽이 하나였다. 공용이라고 이름은 붙였지만 실제로는 자원 방 하나가 쓰는 색인이었고, 남이 붙을 자리는 없었다. 그다음에 한 일은 한 색인을 여럿이 나눠 쓸 수 있게 칸막이를 세우는 것이었다.

먼저 사람이 아닌 것이 부를 문을 냈다. 사람은 로그인 토큰으로 부르지만, MCP나 배치는 로그인할 사람이 없다. 그래서 기계용 API 키를 따로 만들고 헤더를 아예 다른 것으로 나눴다. 같은 헤더에 접두사로 가르면 판정이 문자열 모양에 의존하는데, 그건 인증에서 하고 싶은 종류의 일이 아니다. 키에는 검색·답변·색인·삭제 네 가지 권한과 볼 수 있는 출처 제한이 붙는다.

그리고 누구 것을 볼 수 있는지를 요청 본문에서 받지 않기로 했다. 부르는 쪽이 "나는 이 태그를 볼 수 있다"고 적어 보내면 그 말을 믿는 셈이 된다. 지금은 신원에서 꺼낸다. 읽을 때 요청이 비어 있으면 가진 것 전부로 채우고, 쓸 때 비어 있으면 거절한다. 읽기와 쓰기가 반대인 이유는 청크 판정이 교집합이라 쓰기에서 "가진 것 전부"가 곧 전원 공개가 되기 때문이다. 같은 빈칸이 한쪽에서는 가장 안전한 기본값이고 다른 쪽에서는 사고다.

청크에는 주인 표식을 박는다. 여기에 함정이 하나 있다. 그 표식 문자열을 만드는 코드가 제품 쪽과 플랫폼 쪽에 하나씩 있어서, 둘이 어긋나도 컴파일러가 안 잡는다. 저장소가 다르면 타입이 국경을 못 넘는다.

마지막으로 MCP 엔드포인트를 붙였다. 도구는 둘, 찾기와 묻기다. 색인하는 도구는 주지 않았다. 대화하다 자료를 밀어 넣는 것과 자료를 찾아 읽는 것은 다른 일이고, 앞엣것은 화면이 있는 제품의 몫이다. 새 패키지를 들이지도 않았다. 이미 들어와 있던 SDK로 됐다.

여기까지가 사실이다. 칸막이는 세웠지만 여럿이 쓰고 있다는 근거는 아직 없다. 부르는 쪽은 자원 방과 MCP고, 사용자 수도 질의량도 재고 있지 않다.

채우고, 그다음 보이게

칸막이를 세웠는데 색인에 든 게 세 건이었다. 검색이 도는 건 확인했지만 쓸 만한지는 확인할 방법이 없는 상태였다. 물어볼 거리가 있어야 물어보고 판단한다.

넣을 것부터 만들었다. 원래 있던 색인 문은 파일을 받아 다운로드하고 파싱하고 AI에게 캡션을 쓰게 한다. 그림 때문에 그렇게 짰다. 그림은 본문이 없어서 "이게 무슨 파일인가"를 AI가 대신 써 주지 않으면 색인할 것이 아예 없다.

그런데 밀어 넣고 싶은 건 사람이 쓴 한국어 문서였다. 그건 본문 자체가 이미 설명이라, 그걸 다시 요약해 붙이는 값이 낮다. 그래서 텍스트만 받는 문을 따로 냈다. 캡션 모델을 안 부르니 남는 비용이 임베딩뿐이고, 문서 수십 건에 한 자릿수 센트다. 이 싸구려가 이 문을 두는 이유다. 부담 없이 넣을 수 있어야 넣어 보고 판단한다.

문서 이름은 별도 조각으로 두지 않고 본문 첫 조각 머리에 얹었다. 이름만 담은 조각은 검색에 걸려도 스니펫이 파일 이름 한 줄이라 왜 걸렸는지를 못 보여 준다. 스니펫은 걸린 대목을 보여주는 자리다.

넣어 보고 나서 뺐다

개발일지 62건을 넣었다. 그리고 뺐다. 스니펫이 커밋 해시 나열이었기 때문이다. 그날 무엇을 했나의 기록이라 읽을 문장이 아니라 목록이고, 검색으로 꺼내 봐야 답이 안 된다.

대신 위키를 넣었다. 프로젝트가 무엇이고 왜 그렇게 정했나가 문장으로 있어서 "이거 왜 이렇게 했더라"에 답이 된다. 49건이다. 뉴스는 뺐다. 남이 쓴 기사를 자동으로 모은 것이라, 섞이면 자기 것을 찾을 때 남의 기사가 같이 올라온다.

여기서 배운 게 있다. 넣는 길만 만들면 안 된다. 빼는 길이 없으면 잘못 넣은 것이 영구가 되는데, 잘못 넣었는지는 넣어 보기 전에 모른다.

빼려다 막힌 것도 있었다. 위키 문서 id가 notes/docker처럼 디렉터리를 품는데, 슬래시가 든 id는 삭제 주소에 아예 안 걸려서 없는 주소 취급을 받았다. %2F로 감싸도 같다. 넣을 때는 id를 본문에 실어 보내서 이 문제가 없었고, 지울 때만 막혀 있었다. 그대로 뒀으면 이미 넣은 49건이 영영 못 지우는 고아가 됐다.

어느 색인에 있는지는 묻는 사람이 모른다

색인이 둘이 되자 다른 게 걸렸다. 부르는 쪽이 "어느 색인을 뒤질까"를 골라야 했다. 그런데 답이 위키에 있는지 개발일지에 있는지는 묻는 사람이 알 수 없는 것이다. 알면 검색을 안 한다. 그래서 검색이 색인 목록을 받게 했다.

대가가 있다. 키워드 검색은 문서 전체의 낱말 분포로 희소성을 재는데, 색인마다 따로 만들면 그 기준이 달라져 점수를 나란히 못 놓는다. 그래서 여럿을 뒤질 땐 합쳐서 만들고, 합친 건 캐시해 둘 수 없으니 매번 만드는 값을 낸다. 단일 색인일 때와 여럿일 때 같은 질의라도 순위가 갈릴 수 있다는 뜻이기도 하다. 아직 안 맞췄다.

색인에 무엇이 들었는지 보이게

마지막으로 현황을 보는 문 둘을 냈다. 소스별 요약(자원 몇 건, 조각 몇 개, 마지막 색인이 언제)과 자원 목록이다.

두 가지를 신경 썼다. 첫째, 세는 자를 검색과 같은 것으로 썼다. 판정을 따로 쓰면 남의 조각이 한 건이라도 세어질 수 있고, 그러면 건수만으로 남의 색인이 있다는 사실이 샌다.

둘째, 전량 스캔에 상한이 있어서 그 수를 넘기면 숫자가 조용히 작아진다. 검색은 상위만 보면 되니 티가 안 나지만 현황은 숫자 자체가 답이다. 그래서 잘렸으면 잘렸다고 응답에 적는다. 색인이 본문을 조용히 버리면 안 된다고 정했던 규칙을, 이번엔 세는 쪽에 걸었다.

권한은 새로 만들지 않고 검색 권한을 그대로 썼다. 현황에 나가는 건 검색으로 이미 닿을 수 있는 것의 요약이라 따로 막아도 막히는 게 없고, 새 권한을 만들면 이미 발급된 키가 전부 막힌다.

다만 이 숫자가 재는 것은 색인에 무엇이 들었나이지, 누가 얼마나 쓰나가 아니다. 질의량도 사용자 수도 여전히 안 센다.

끝난 제품이 살아 있는 API를 얼렸다

이 백엔드는 종료작 제품의 몸에 얹혀 있다. 그 사실이 한 번 물었다.

콘솔에서 첫 호출이 97초 걸렸다. 콜드 스타트로 짐작했는데 아니었다. 제품 시절의 배치 작업 넷이 아직 돌고 있었고(고정거래, 브리핑 캐시, 알림 처리, 알림 정리), 그중 알림 처리가 매분 깨어나 Firestore를 부르고 있었다. 그런데 제품 쪽 인증 자격이 죽어 있어서 그 호출 하나가 300초 동안 재시도한다.

문제는 그게 블로킹 호출인데 async 함수 안에 그대로 들어 있었다는 것이다. 스케줄러가 이벤트 루프 위에서 돌리니, 그 300초 동안 서버가 받는 모든 요청이 같이 멈췄다. 주기가 60초라 계속 겹쳤다.

기본값을 꺼짐으로 바꿨다. 코드는 안 지웠다. 자격을 되살리면 그대로 쓸 수 있고, 다시 켤 때는 블로킹 호출부터 빼야 한다고 적어 뒀다. 배운 건 이거다. 끝난 제품의 코드는 안 쓰이는 게 아니라 계속 돈다. 아무도 안 볼 알림을 만들려고 매분 깨어나서, 살아 있는 API를 얼리고 있었다.

같은 자리가 한 번 더 물었다. 나중에 이 백엔드에 다른 일 몫으로 외부 도구 서버를 하나 붙였는데, 제품 시절의 채팅이 **"도구가 있으면 전부 붙인다"**로 짜여 있었다. 그래서 그 서버의 도구 열 개와 정의 만여 자가 채팅 요청마다 실리게 됐다. 막을 자리가 두 군데였고, 한 군데는 부르는 쪽이 이름을 적어 보내는 길이라 요청에 그 이름을 실으면 채팅에서 그 도구를 부를 수 있었다. 숨길 목록을 한 곳에 두고 둘 다 그걸 보게 했다. 여기서 값을 한 건 고친 방식이 아니라 그걸 지키는 방식이다. 규칙 함수만 재는 시험은 호출부가 옛 함수로 되돌아가도 초록이다. 그래서 부르는 자리를 소스 글자로 보는 시험을 넣었고, 실제로 그 시험이 두 번째 구멍을 찾아냈다. 끝낸 제품은 기능이 안 붙는다는 게 문서에 적힌 다짐이면 다음 사람이 어기고, 코드 한 줄이면 안 어긴다.

값을 고르게 하고, 고른 값을 굳힌다

여기까지 손본 것들은 전부 서버 안의 상수였다. 근거를 몇 조각 넣을지, 유사도 몇부터 근거로 칠지, 어떤 지침으로 답하게 할지. 실측으로 정한 값이라 틀린 건 아닌데, 내가 정한 값이 남의 자료에도 맞다는 보장이 없다.

그래서 그 넷을 요청에서 받게 열었다. 다만 전부 선택으로 뒀다. 안 보내면 서버 상수가 그대로 쓰이고, 그래서 원래 쓰던 쪽은 한 글자도 안 바뀐다. 손잡이를 다는 일에서 제일 조심할 건 안 만지는 사람을 안 건드리는 것이다.

지침을 여는 건 조금 다른 무게다. 그 값이 답변 엔진의 성격 자체라, 바꿀 수 있다는 건 "근거에 없는 건 말하지 않는다"를 지울 수 있다는 뜻이다. 그래도 열었다. 막는 게 두 겹인데 한 겹은 지침으로 못 지우기 때문이다. 인용구를 원문과 글자로 대조하는 건 코드가 하는 일이라, 지침을 어떻게 바꾸든 뒷받침 없는 출처는 여전히 안 나간다. 지금은 키의 주인이 곧 자기 색인의 주인이니 자기 자료에 대고 자기가 성격을 정하는 것뿐이고, 남에게 키를 주기 시작하면 여기를 권한으로 막아야 한다고 적어 뒀다.

왜 저건 근거가 안 됐는지를 보여준다

손잡이를 달면서 같이 나온 문제가 있다. 밀긴 미는데 어느 쪽으로 밀어야 하는지를 모른다. 답이 이상할 때 보이는 건 답뿐이고, 걸렸다가 탈락한 조각은 화면에서 그냥 없는 것이었다.

탈락 사유가 둘인데 미는 손잡이가 정반대다. 유사도 문턱을 못 넘은 것은 문턱을 내려야 하고, 문턱은 넘었는데 조각 수 상한에 밀린 것은 조각 수를 올려야 한다. 둘 다인 것도 있는데 그건 조각 수를 올려도 문턱에서 또 걸린다. 그래서 탈락한 조각을 사유와 함께 같이 실어 보낸다.

근거 카드에도 점수를 붙였다. 여기서 하나 안 보여주기로 한 게 있다. 키워드 점수는 숫자로 안 내보낸다. BM25는 상한이 없어서 "유사도 4.32" 같은 게 뜨는데, 옆에 0에서 1 사이 값이 나란히 있으면 읽는 사람에게 그냥 거짓말이다. 대신 그 값이 0일 때만 "뜻으로만"이라고 한 마디 세운다. 근거로 치는 규칙이 "키워드가 0보다 크거나 유사도가 문턱 이상"이라, 키워드가 0인 근거는 그 말이 문서에 아예 없고 뜻으로만 걸린 것이다. 그게 "왜 이게 근거지"의 답이다.

묶고, 굳히고, 주소를 낸다

값을 고를 수 있게 되니 다음 질문이 따라왔다. 고르고 나서 그걸 매번 다시 고르나.

두 단을 만들었다. 먼저 자료를 목적별로 묶는 축을 냈다. 권한 축에 얹지 않았다. "볼 수 있나"와 "어디에 드나"는 다른 질문이고, 섞으면 자료를 다른 묶음으로 옮기는 게 권한을 옮기는 일이 된다. 묶음이 저장하는 건 이름표뿐이고, 어느 자료가 그 묶음인지는 조각에 표식으로 박는다. 묶음 쪽에 목록으로 들면 검색이 좁힐 때마다 그 문서를 읽어 조각과 대조해야 하는데, 조각에 두면 이미 메모리에 있는 값 하나만 보면 된다. 그리고 묶음을 지우는 게 자료를 지우지는 않는다. 이름표를 지우고 표식만 걷는다.

저장소가 낳는 자료(위키, 개발일지)는 사람이 올리는 게 아니라 스크립트가 밀어 넣는다. 그건 읽기 전용 묶음 하나로 자동으로 들어간다. 여기서 "스크립트가 넣은 건가 사람이 올린 건가"를 어떻게 가르냐가 걸렸는데, 부르는 쪽으로는 못 가른다. 스크립트도 사람도 같은 키를 쓰고, 키에 "나는 배치다"라고 적혀 있지 않다. 적게 해도 그건 부르는 쪽이 적는 값이라 판정의 근거가 못 된다. 대신 색인 이름은 자료가 어디서 왔나라서 이미 답을 말하고 있다. 화면에서 위키 이름으로 올릴 길이 없고, 스크립트가 사람 자료 쪽으로 밀 이유도 없다.

그 위에 발행 단위를 얹었다. 묶음과 지침과 파라미터 넷을 하나로 저장하면 그게 앱이고, 앱마다 MCP 주소가 따로 난다. 도구 이름도 앱을 따라간다. 원두 자료만 보는 앱을 만들면 그 주소에 붙은 모델에게는 원두를 찾는 도구만 보인다.

여기서 앱 도구는 범위 인자를 안 받기로 했다. 범위를 정하는 건 앱이지 부르는 모델이 아니다. 열어 두면 모델이 넓혀 부르고, 그 순간 "원두만 보는 바리스타"가 깨진다.

런타임에 생기는 것에 기동 시점 자원을 붙이면 안 된다

앱마다 MCP 서버 인스턴스를 만들려다 막혔다. MCP 세션 매니저는 자기 수명 주기 안에서 태스크 그룹을 여는데, 그 수명 주기는 서버가 뜰 때 열린다. 그런데 앱은 사용자가 아무 때나 만드는 것이다. 인스턴스를 앱마다 만들면 서버가 뜬 뒤에 생긴 앱은 그걸 열어 줄 사람이 없어서, 첫 호출에서 "태스크 그룹이 초기화되지 않았다"로 죽는다.

그래서 인스턴스는 하나로 두고 도구 목록을 요청마다 갈아 끼운다. 주소가 다른 건 요청이 들어올 때 경로만 바꿔 준다. 재정의로 되는 방식을 골랐다. 라이브러리 내부를 파고들면 판올림에서 조용히 깨지는데, 메서드 재정의는 안 되면 임포트하는 순간 드러난다.

앱이 0개였던 게 아니라 만들 수가 없었다

앱을 다 만들어 놓고 한참을 앱이 0개인 채로 뒀다. 화면도 비어 있고 앱을 고르는 줄도 안 떴다.

지침을 안 적고 발행하면 늘 실패였다. 요청 모델에 "최소 1글자" 제약이 걸려 있었는데, 화면은 빈 문자열을 실어 보낸다. 그 제약은 답변 요청 쪽에서 그대로 베껴 오면서 같이 딸려 온 것이었다. 같은 값이 두 문맥에서 뜻이 다르다는 걸 놓쳤다. 답변 한 번에 빈 지침은 무의미하지만, 앱에서 빈 지침은 "서버 기본을 쓰겠다"는 뜻 있는 값이다. 고칠 때 지우면 "서버 기본으로 되돌리겠다"가 된다.

더 나쁜 건 저장소가 이미 그렇게 읽고 있었다는 것이다. 빈 문자열을 받으면 "안 정함"으로 되돌리는 코드가 있었다. 서버만 혼자 거절하고 있었다. 화면에 적어 둔 "비우면 서버 기본 지침으로 되돌아갑니다"도 같이 거짓말이었다.

그리고 화면에는 오류 번호 석 자만 떴다. 그 번호는 사용자가 고칠 수 있는 게 아니라 배선이 틀렸다는 뜻인데, "다시 해 보세요"로 넘기면 영영 안 고쳐진다. 사람 말로 적게 바꿨다.

읽기 한 건마다 돈이 나간다는 걸, 한도가 차고서야

앞에서 벡터DB를 안 얹는 대신 Firestore 읽기 과금을 직접 관리하기로 했다고 썼다. 그 계산서가 왔다.

색인 저장소의 일일 무료 읽기 5만이 소진돼 서비스가 멈췄다. 그날 기록이 읽기 5만에 쓰기 319, 삭제 10이다. 순전히 읽기다.

원인은 현황 화면이었다. "어떤 문서가 색인돼 있나"를 알아내려고 조각을 통째로 읽는다. 조각이 310개인데 요약과 목록이 각각 색인 셋을 훑고, 5분짜리 캐시는 배포할 때마다 0이 되고 색인할 때마다 버려지고 5분이면 만료된다. 두 시간 동안 배포하고 새로고침을 반복한 구간에서만 3만이 나갔다.

고친 방법이 조금 재미있다. Firestore에는 개수만 세는 집계 질의가 있고, 그건 문서 읽기로 안 세어진다. 콘솔이 사용량 옆에 그렇게 적어 뒀다. 그런데 현황 자체를 집계로 바꿀 수는 없었다. 자원 수는 중복 없는 문서 키를 세야 하는데 집계에 그게 없고, 마지막 색인 시각은 최댓값이라 색인을 따로 만들어야 하고, 권한 판정은 "배열이 비었거나 겹친다"라 한 질의로 안 된다.

그래서 집계를 답이 아니라 캐시가 아직 맞나 물어보는 자로 썼다. 캐시 수명이 지나면 버리기 전에 개수만 물어보고, 그대로면 들고 있던 걸 그대로 쓴다. 이 확인에 드는 읽기가 0이다. 덤으로 수명도 5분에서 30분으로 늘렸다. 그 수명이 지키는 건 "다른 인스턴스가 쓴 걸 내가 모른다" 하나뿐인데, 같은 프로세스의 쓰기는 즉시 버리고 있고 색인을 바꾸는 건 밤에 한 번이다. 30분 늦게 보이는 것의 최악이 "어젯밤 것이 30분 늦게 걸린다"였다.

실측으로 화면 100번 열기가 30,700 읽기에서 613으로 내려갔다. 답이 안 바뀌었다는 건 고치기 전과 후에 같은 하네스를 돌려 응답과 저장소 상태를 덤프해 비교했다.

실패가 되먹임이 되는 자리

같이 잡은 게 더 아팠다. 옛 자료를 읽기 전용 묶음으로 쓸어 담는 소급 작업을, 목록을 여는 요청에 달아 뒀다. "한 번만 하고 도장을 찍는다"는 설계였다.

도장 쓰기가 한도 초과로 실패하면, 매 요청이 전량 읽기를 다시 한다. 읽기 한도가 찼으니 도장도 못 찍고, 못 찍었으니 다음 요청이 또 소급한다. 한도가 찬 상태가 스스로를 유지한다. 실측으로 재현하고 그 문을 걷어냈다. 소급은 스크립트로 남기고, 함수에 "다시 조회 요청에 달지 마라"를 박아 뒀다.

하나 더 있었다. 저장소가 죽으면 본문 없는 500이 나갔다. 키를 확인하는 자리가 의존성이라, 어떤 라우터의 예외 처리로도 안 잡히는 위치였다. 앱 전체에 처리기를 하나 세워서 "색인 저장소의 일일 읽기 한도가 찼습니다"로 바꿨다. 죽는 건 어쩔 수 없어도 왜 죽었는지는 말해야 한다.

여기까지가 사실이다. 손잡이를 열고 앱을 발행할 수 있게 만들었지만, 몇 개가 발행돼 쓰이고 있는지는 여전히 안 센다. 그리고 저 5만 읽기도 남이 몰려와서 난 게 아니다. 밤 자동화가 2만, 내가 배포하며 새로고침한 게 3만이다.

도는 동안 무엇을 하는지 보이게

답을 기다리는 동안 화면에는 아무것도 없었다. 검색을 하긴 했는지, 몇 조각을 봤는지, 지금 어디쯤인지가 전부 응답이 끝난 뒤에 한꺼번에 온다. 그래서 답변과 색인에 걸음을 흘려보내는 문을 따로 냈다. 답변 쪽 걸음이 여덟이다.

여기서 새로 계산한 값은 없다. 여덟 걸음은 전부 파이프라인이 이미 알고 있으면서 버리던 것이다. 질의를 어떻게 다시 썼는지도, 몇 조각을 근거로 세웠는지도, 왜 저건 떨어졌는지도 안에서는 내내 알고 있었고 밖으로 안 나갔을 뿐이다. 보이게 하는 일이 대개 그렇다. 없는 것을 만드는 게 아니라 버리던 것을 내보낸다.

다만 이건 모델의 사고를 흘리는 게 아니다. 걸음 하나가 모델이 적은 생각을 싣긴 하는데, 그건 확장 사고를 켠 게 아니라 모델이 자진해 적었을 때만 뜬다. 확장 사고를 켜려면 최소 1,024 토큰을 떼어 줘야 하는데 부르는 세 자리의 응답 상한이 700, 1,024, 200이다. 답 자체도 토큰 단위로 흘리지 않는다. 답이 강제 도구 호출의 인자로 오기 때문이고, 그 강제가 날조를 막는 겹 중 하나라 흘려보자고 풀 수 있는 게 아니다.

지침을 열어 놓고 반만 보고 있었다

앞에서 답변 지침을 요청에서 받게 열었다. 그런데 그 지침이 절반의 질문에만 닿고 있었다.

검색할지 말지를 모델이 정하게 한 뒤로 질문은 두 길로 갈린다. 도구를 부르는 길과 안 부르는 길이다. 지침은 도구를 부른 다음, 찾아온 근거를 놓고 답을 쓰는 자리에만 실렸다. 그러니 인사나 잡담처럼 안 부르기로 한 질문은 전부 서버 기본 인격으로 답했다. 지침을 적어 둔 사람 눈에는 그게 통째로 무시된 것으로 보인다.

검색 범위도 같은 자리에서 샜다. 범위를 좁혀 물었는데 부를지 말지를 정하는 쪽이 그 범위를 모르면, 찾을 것이 없다고 보고 안 찾는다.

그래서 정하는 쪽 프롬프트에도 지침과 범위를 넣었다. 대가가 같이 왔다. "무엇이든 그냥 답해라"를 지침으로 유도할 자리가 생긴 것이다. 도구를 부르라거나 이모지를 쓰지 말라는 배관 규칙을 지침 뒤에 붙여 뒤엣말이 이기게 했지만, 프롬프트로 버티는 것이라 코드가 보장하는 건 아니다. 코드가 하는 건 여전히 인용구를 글자로 대조하는 한 겹뿐이고, 그 겹만은 지침으로 못 지운다.

세 번째가 며칠 뒤에 또 나왔다. 아래에 나오는 바깥 엔진 이야기인데, 엔진을 골라 놓고 물었더니 이번에도 고른 걸 못 보고 잡담으로 판정했다. 지침, 범위, 엔진. 사용자가 손으로 고른 것 셋이 전부 정하는 쪽에 안 가 있었던 셈이다. 셋째만은 알려 주는 걸로 안 고치고 아예 안 묻기로 했다. 고른 것 자체가 답인 질문을 모델에게 다시 물을 이유가 없고, 알려 주는 건 기울이는 것이지 강제가 아니라 경계에서 같은 판정이 또 나온다.

이 셋은 코드를 읽다 나온 게 아니라 쓰다 걸렸다. 앞의 둘은 고친 커밋 본문이 "사용자가"로 시작하고, 셋째는 "실서비스에서 그대로 났다"로 시작한다. 다만 그 사용자가 나인지 남인지는 코드로 못 가른다. 질의량도 사용자 수도 여전히 안 센다.

안 쓰는 값을 안 실어 보낸다

같이 나온 게 하나 더 있다. 자원 목록을 만들 때 조각을 통째로 읽어서 벡터까지 같이 끌어오고 있었다. 목록에는 쓰지 않는 값이다. 조각 하나가 18KB인데 그중 17KB가 벡터다. 필요한 일곱 필드만 받게 하니 전송량이 11.3MB에서 0.17MB로, 그걸 푸는 시간이 431ms에서 4ms로 떨어졌다.

다만 읽기 건수는 그대로다. 필요한 필드만 받는 건 돌려받을 때 씌우는 마스크라, 과금 단위인 "문서를 몇 개 읽었나"는 안 바뀐다. 싸진 건 회선과 CPU지 요금이 아니다.

터진 건 문서가 아니라 색인 항목이었다

PDF 하나가 색인에 실패했다. 읽기도 끝나고 임베딩도 끝나고 저장에서 터졌다. 돈이 나간 다음에 통째로 버려진 것이다.

처음 짚은 원인은 틀렸다. 문서가 커서라고 봤는데, 재 보니 한 번에 거는 조각 149개를 다 합쳐도 한도의 4분의 1이었다. 터진 건 본문이 아니라 색인 항목이다. 저장소는 넣는 값마다 정렬용 항목을 같이 만드는데, 원소가 N개인 배열은 N+2개를 만든다. 1,536차원 벡터가 든 조각 하나가 1,562개고 149개면 23만 개다. 본문으로는 넉넉히 통과하는 적재가 항목까지 세면 한도의 1.7배였다.

그래서 배치를 끊는 자를 개수에서 무게로 바꿨다. 무게는 본문 크기에 색인 항목 수를 얹은 값이고, 어림이라 틀릴 수 있으니 늘 실제보다 크게 나오도록 맞췄다. 여기서는 정확한 값보다 여유가 낫다. 한 번 더 쪼개지는 건 조금 느려지는 것이고, 한 번 덜 쪼개지는 건 터지는 것이다.

같이 붙인 게 되돌리기다. 적재가 여러 번으로 나뉘니 중간에 끊길 수 있는데, 그러면 화면은 실패라는데 검색에는 문서 앞부분만 걸린다. 사용자가 그 어긋남을 알 길이 없고, 그 뒤로 그 문서를 근거로 삼은 답은 조용히 반쪽이 된다. 쓰다 만 조각을 걷어내고 통째로 실패로 만들었다. 버려지는 임베딩 값이 아깝지 않냐는 물음에는 계산으로 답했다. 1원 몇십 전이다. 그 값을 살리자고 반쪽짜리 색인을 남기는 거래가 아니다.

진짜 고칠 자리는 코드가 아니라 저장소 설정이다. 그 벡터는 검색 질의에 쓰지도 않으면서 항목만 1,562개를 만든다. 콘솔에서 빼면 26개가 된다. 다만 콘솔에 안 걸고 코드에만 적으면 계산이 낙관해져서 다시 터지므로, 자리만 내고 기본값은 꺼 뒀다.

검색만 바깥 것으로 바꿔 끼울 자리

여기까지의 검색은 전부 내가 짠 것이다. 코사인과 파이썬 150줄짜리 BM25. 실측으로 여러 번 고치긴 했지만 그게 제대로 만든 검색 엔진보다 나은지 나쁜지는 모른다. 나란히 재 본 적이 없다.

그래서 검색 갈래만 갈아 끼울 자리를 냈다. 어느 엔진으로 찾을지를 요청에서 고르고, 기본값은 내 것이다. 안 보내면 코드 경로가 한 줄도 안 바뀐다. 손잡이를 열 때와 같은 규칙이다.

내고 나서 하나가 걸렸다. 갈아 끼운 게 근거를 어디서 뽑느냐까지였는데, 그 앞에 찾을지 말지를 정하는 쪽이 있었다. 정하는 쪽의 정체성은 내 색인이라, 바깥 지식을 묻는 질문을 자기와 상관없는 잡담으로 읽는다. 바깥 엔진을 골라 놓고 바깥 것을 물었더니 도구를 안 부르고 일반 상식으로 답했다. 그래서 바깥 엔진을 고른 턴은 정하는 쪽을 아예 건너뛴다. 잃는 건 인사와 잡담을 걸러 주는 것 하나인데, 헛검색은 "근거를 찾지 못했습니다"로 끝나고 그건 이미 잘 하는 답이다. 안 찾고 지어낸 답은 출처가 없고, 그런 답을 한 번 보면 그다음부터 모든 답을 의심하게 된다.

점수 자리에서도 갈렸다. 바깥 엔진이 주는 유사도 점수는 0.96에서 0.98 사이에 몰려 있어서, 정답과 잡음의 차이가 0.002다. 근거로 세울지 말지를 그 자로 재면 아무것도 못 거른다. 대신 같은 응답에 딸려 오는 재순위 점수가 정답 0.99, 잡음 0.03으로 벌어져서 그쪽을 썼다. 몰림 자체를 재 보려고 원래 점수도 같이 실어 두긴 했는데, 줄 세우는 데는 안 쓴다.

여기까지가 사실이다. 엔진의 이름도 주소도 키도 코드에 없다. 로컬 설정에만 두고 배포 쪽에는 안 넣었으니 지금 그 길로 가는 건 내 노트북뿐이고, 설정이 없으면 400을 돌려준다. 확인한 것도 어댑터 단위 왕복까지고 서버를 띄워 끝에서 끝까지는 아직이다. 그리고 두 엔진을 같은 질문 묶음으로 재 본 결과가 없다. 자리를 낸 목적이 그 비교인데, 비교를 아직 안 했다.

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모델이 적은 출처를 믿지 않고 글자로 대조한다

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

Decision #09검색할지 말지는 모델이 정한다

"근거 없으면 답하지 않는다"를 모든 질문에 걸었더니 "안녕?"에도 근거가 없다고 답했다. 그 규칙은 회사 사실을 물었을 때만 맞다. 검색을 도구로 주고 부를지를 모델이 정하게 했다. 안 부르면 출처 없이 그냥 답하는 것이 맞는 동작이다.

Decision #10볼 수 있는 범위는 요청이 아니라 신원에서 꺼낸다

부르는 쪽이 자기 권한을 적어 보내면 그 말을 믿는 셈이 된다. 신원에서 꺼내되, 읽기는 비면 가진 것 전부로 채우고 쓰기는 비면 거절한다. 청크 판정이 교집합이라 쓰기의 "전부"는 전원 공개가 되기 때문이다.

Decision #11넣는 길을 만들 때 빼는 길도 같이 만든다

개발일지를 넣어 보고서야 스니펫이 커밋 해시 나열이라 검색에 쓸모없다는 걸 알았다. 잘못 넣었는지는 넣어 보기 전에 모르는데, 뺄 길이 없으면 그 오염이 영구가 된다. 텍스트 색인 문을 낼 때 빼는 쪽도 같이 냈다.

Decision #12현황을 세는 자는 검색이 쓰는 것을 그대로 쓴다

색인에 무엇이 들었는지 보이는 문을 내면서 세는 판정을 따로 쓰지 않았다. 남의 조각이 한 건이라도 세어지면 건수만으로 남의 색인이 있다는 사실이 샌다. 권한도 새로 만들지 않고 검색 권한을 썼다. 새 권한을 만들면 이미 발급된 키가 전부 막힌다.

Decision #13손잡이는 열되, 안 보내면 옛 동작 그대로

서버 상수로 박아 둔 값 넷을 요청에서 받게 열었다. 전부 선택이라 안 보내면 서버 기본이 그대로 쓰이고, 원래 쓰던 호출자는 한 글자도 안 바뀐다. 손잡이를 다는 일에서 제일 조심할 건 안 만지는 쪽을 안 건드리는 것이다.

Decision #14탈락한 근거를 사유와 함께 돌려준다

유사도 문턱을 못 넘은 것과 조각 수 상한에 밀린 것은 미는 손잡이가 정반대다. 사유 없이 "없음"만 보이면 어느 쪽으로 밀어야 하는지 알 수가 없다. 상한 없는 키워드 점수는 숫자로 안 내보내고, 0일 때만 "뜻으로만"으로 바꿔 보여준다.

Decision #15묶는 축을 권한 축에 얹지 않는다

"볼 수 있나"와 "어디에 드나"는 다른 질문이다. 섞으면 자료를 다른 묶음으로 옮기는 게 권한을 옮기는 일이 된다. 멤버십도 묶음 쪽이 아니라 조각에 표식으로 박았다. 묶음 쪽에 목록으로 들면 검색이 좁힐 때마다 그 문서를 읽어 대조해야 한다.

Decision #16집계 질의를 답이 아니라 캐시 프로브로 쓴다

현황 자체는 집계로 못 만든다. 중복 없는 문서 키도, 최댓값도, 배열 겹침 판정도 한 질의에 안 들어간다. 대신 집계는 읽기로 안 세어지므로 "캐시가 아직 맞나"를 공짜로 물어볼 수 있다. 수명이 지나도 버리기 전에 개수부터 물어 그대로면 들고 있던 것을 그대로 쓴다.

Decision #17흘려보낼 것을 새로 만들지 않는다

파이프라인이 무엇을 했는지 보이게 하면서 값을 더 계산하지는 않았다. 여덟 걸음은 전부 안에서 이미 알고 있으면서 안 내보내던 것이다. 다만 이건 모델의 사고를 흘리는 게 아니다. 답이 강제 도구 호출의 인자로 오고 그 강제가 날조를 막는 겹이라, 토큰 단위로 흘리려면 방어를 풀어야 한다.

Decision #18지침은 검색하는 길과 안 하는 길 양쪽에 건다

검색할지를 모델이 정하게 한 뒤로 질문이 두 길로 갈렸는데, 지침은 한쪽에만 실려 있었다. 안 부르기로 한 질문은 전부 서버 기본 인격으로 답했고, 적어 둔 사람 눈에는 지침이 통째로 무시된 것으로 보인다. 정하는 쪽에도 지침을 주되 배관 규칙을 뒤에 붙여 뒤엣말이 이기게 했다. 프롬프트로 버티는 것이라 코드 보증은 아니고, 코드가 하는 건 인용구 대조 한 겹 그대로다.

Decision #19바꿔 끼울 자리만 내고 기본값은 그대로 둔다

여기 있는 검색은 전부 내가 짠 것인데 제대로 만든 엔진과 나란히 재 본 적이 없다. 그래서 검색 갈래만 갈아 끼울 수 있게 열되 기본값은 내 것으로 뒀다. 안 보내면 코드 경로가 한 줄도 안 바뀐다. 다만 자리를 낸 목적인 두 엔진 비교는 아직 안 했다.

Decision #20대조가 맞는 답을 버려도 대조를 무르게 하지 않는다

인용구 대조가 구조화된 기록 앞에서 맞는 답을 버렸다. 대조를 느슨하게 하면 한 줄로 끝나지만 그러면 지어낸 답도 같이 통과한다. 못 잡는 쪽으로 틀리기로 하는 셈이다. 막는 쪽은 빡빡한 채로 두고 읽히는 쪽을 고쳤다. 대조에서 떼는 건 뜻을 안 지우는 구분 기호뿐이고, 그걸로도 안 맞는 모양은 원문을 줄 단위로 펴서 모델이 한 줄을 그대로 옮겨 적을 수 있게 했다.

배운 것

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

규모 다음에 온 건 경계였다. 파이프라인을 두 번 짜기 싫어서 색인을 제품 밖으로 꺼내 놓고 나니, 설계가 "무엇을 얹느냐"에서 "어느 쪽이 무엇을 갖느냐"로 옮겨갔다. 색인은 플랫폼이, 원본과 권한은 제품이. 그 선을 긋고 나서야 파이프라인을 갈아엎어도 제품이 안 흔들린다. 그다음 선은 색인 안쪽이었다. 한 색인을 여럿이 나눠 쓰려면 누가 무엇을 볼 수 있는지를 부르는 쪽 말이 아니라 신원에서 꺼내야 한다. 칸막이 다음은 채우는 일이었는데, 거기서 하나가 더 나왔다. 넣는 길만 있고 빼는 길과 보는 길이 없으면 색인이 잘못됐다는 걸 알 방법이 없다. 개발일지를 넣었다 뺀 것도, 색인에 무엇이 들었는지 세는 문을 낸 것도 같은 자리에서 나왔다. 그다음은 고르는 일이었다. 서버 상수로 박아 둔 값이 남의 자료에도 맞다는 보장이 없어서 손잡이를 열었고, 열고 나니 "고른 걸 매번 다시 고르나"가 나와서 묶음과 발행 단위가 생겼다. 그리고 보이게 하는 일이 한 번 더 돌아왔다. 값을 밀 수 있게 해 놓으면 민 결과가 보여야 하는데, 파이프라인이 무엇을 했는지는 응답이 끝난 뒤에 한꺼번에 왔다. 흘려보낼 것을 새로 만들 일은 없었다. 이미 알고 있으면서 버리던 것이었다.

그리고 인프라를 안 얹기로 한 판단에 처음으로 값이 붙었다. 벡터DB를 안 두는 대신 문서 읽기 과금을 내가 관리하기로 한 건데, 그 관리를 한도가 차고 서비스가 멈추고 나서야 시작했다. 계산은 처음부터 적어 뒀다. 검색 열여섯 번이면 하루치가 끝난다고. 적어 두는 것과 관리하는 건 다른 일이었다.

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

보이게 하는 일에는 뒤따라온 게 하나 더 있었다. 파이프라인이 무엇을 했는지 한 줄 띄우고 나니, 정하는 쪽이 사용자가 고른 걸 세 가지 모양으로 무시하고 있었다는 게 하루 만에 드러났다. 지침, 범위, 엔진. 셋 다 몇 주째 그러고 있었는데 아무도 몰랐다. 고쳐야 할 것을 찾아 준 게 아니라, 고칠 것이 있다는 사실 자체를 보여 줬다.

여기까지가 내내 "지어낸 답을 어떻게 막나"였는데, 한 번은 반대로 틀렸다. 막는 장치가 맞는 답을 버렸다. 그때 고치는 길이 둘인데 하나는 방어를 깎는 것이고 하나는 아니다. 앞쪽이 훨씬 짧아서 자꾸 눈에 밟힌다. 방어를 세우는 일에는 그 방어가 거짓 양성을 냈을 때 무엇으로 갚을 것인가가 한 항목으로 붙는데, 그 답이 방어를 깎는 것이면 애초에 방어가 아니었던 셈이다.

이 구간에서 백엔드에 시험이 처음 생겼다. 그전까지 한 개도 없었다. 검색과 답변 쪽이 백네 개고(백엔드 전체로는 다른 일 몫이 더 붙어 백마흔 남짓이다) 전부 가짜 모델과 가짜 전송층을 태우는 단위 시험이라, 이걸로 "검증된 서비스"라고 말할 수는 없다. 재는 건 문자열이 제대로 조립되는가, 필드가 제대로 옮겨지는가, 갈래가 제대로 갈리는가까지다. 그래도 값을 한 자리가 하나 있다. 고치기 전 코드를 따로 띄워 같은 가짜를 태우고 응답을 글자 단위로 비교하는 것. "안 바꿨다"를 눈으로 훑어 말하는 대신 0줄 diff로 말하게 된다.

아직 안 한 것도 하나 분명해졌다. 여기 있는 검색은 전부 내가 짠 것이고 실측으로 여러 번 고쳤지만, 제대로 만든 엔진과 나란히 재 본 적은 없다. 바꿔 끼울 자리까지는 냈다. 재는 건 그다음이다.

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