문서 RAG에 재고 981건을 같이 색인한 방법: 구조화 데이터의 텍스트화
RAG를 "문서 검색"이라고만 생각하면 놓치는 게 많습니다. 제가 실제로 자연어로 묻고 싶은 질문의 절반은 문서가 아니라 데이터베이스에 들어있는 구조화된 데이터에 대한 것이었습니다. "이 철제장에 뭐 있지", "이 브랜드 물건 중 정리 대기 걸린 건 뭐지", "유효기간 지난 거 있어?". 이런 건 문서로 안 적어둡니다. Item 테이블에 행으로 들어있습니다.
이 글은 문서만 색인하던 RAG에 재고(Item) 981건을 같이 넣기까지의 설계 결정을 적은 겁니다.
문서만 색인하면 생기는 빈 칸
처음 RAG를 짤 때는 Doc 테이블만 색인했습니다. 사내 문서, 개발 노트, 회의록 같은 비구조화 텍스트만 doc_chunk 테이블에 넣었습니다. 그런데 막상 써보니, 제가 챗봇에 던지는 질문의 상당수가 "문서에 적혀있지 않은 사실"을 물었습니다. 물건의 보관 위치, 브랜드, 색상, 유효기간. 이런 건 문서로 쓰지 않고 Item 테이블의 컬럼으로 관리합니다.
문서 RAG만 두면 이런 질문은 전부 "모르겠다"가 나옵니다. 검색이 안 되니까요. 그렇다고 이런 질문을 또 다른 시스템으로 빼면, 사용자 입장에선 "이건 챗봇에 물어봐야지, 저건 어디서 봤지?"를 매번 가려야 합니다. 하나의 질문 창구에서 다 종류의 데이터를 다 찾게 만들고 싶었습니다.
구조화 데이터를 텍스트로 직렬화하기
해결책은 Item 레코드를 마크다운 문서로 바꿔서 같은 색인에 넣는 것이었습니다. indexer 코드를 보면 Item을 이렇게 직렬화합니다.
# {물건 이름}
{설명}
- 상태: 활성
- 종류: {type} > {kind} > {subkind}
- 브랜드: {brand}
- 색상: {색상명 / 톤 / 온도 / 계절}
- 소재: {material}
- 보관 위치: {zone > area > code > spot} (수량)
- 정리 대기 구역: {pendingZone}
- 구매일: {date}
- 유효기간: {date}
- 연관 물품: {related}DB의 여러 컬럼과 관계(location, color, relations)를 한 덩어리의 텍스트로 쓰는 겁니다. 이 텍스트는 임베딩 모델의 입력이 됩니다. 즉 구조화된 데이터를 비구조화된 자연어로 "역직렬화"해서 벡터 공간에 올리는 셈입니다.
왜 이렇게까지 하냐면, 임베딩 모델은 자연어에 맞춰 학습돼 있습니다. 컬럼값을 따로따로 임베딩하면 "활성"이라는 단어가 뭘 의미하는지 모델이 잡기 어렵습니다. 하지만 "상태: 활성"이라는 문장으로 넣으면, 다른 물건의 "상태: 폐기"와 의미 차이가 임베딩에 제대로 새겨집니다. 위치 경로도 마찬가지입니다. zone > area > code > spot을 "거실 > 이케아 철제장 > 윗칸 > 왼쪽" 같은 문장으로 쓰면, "거실 철제장에 뭐 있어?"라는 질문과 잘 매칭됩니다.
같은 색인, 다른 출처: source_type 설계
여기서 설계 결정이 하나 나옵니다. Doc과 Item을 같은 doc_chunk 테이블에 넣되, 어디서 온 청크인지 구분해야 합니다. 그래야 출처를 클릭했을 때 문서면 문서 편집기로, 물건이면 재고 상세로 보낼 수 있으니까요.
source_type 컬럼 하나로 doc과 item을 구분했습니다. 그리고 CHECK 제약으로 "둘 중 정확히 하나"를 강제했습니다.
CHECK (
(source_type = 'doc' AND source_doc_id IS NOT NULL AND source_item_id IS NULL) OR
(source_type = 'item' AND source_item_id IS NOT NULL AND source_doc_id IS NULL)
)외래키도 두 개를 둬서, Doc이 지워지면 그 Doc의 청크가, Item이 지워지면 그 Item의 청크가 각자 cascade로 따라 지워지게 했습니다. 같은 테이블에 섞어 넣되, 출처 추적은 분리된 채로 유지되는 구조입니다.
이 설계의 장점은 검색 코드가 하나라는 겁니다. searchChunks 함수는 source_type을 신경 쓰지 않고 그냥 벡터 + 키워드로 검색합니다. 결과에 문서 청크와 아이템 청크가 섞여 나오고, 각각의 출처 링크만 프론트엔드에서 적절히 갈라 보여주면 됩니다.
답을 만드는 두 길
여기서 재미있는 부분이 생깁니다. Item은 색인에 넣어두되, 정작 목록 질문에 답할 때는 색인을 안 씁니다. 이건 앞선 글에서 다룬 것과 이어집니다.
"이케아 철제장에 뭐 있어? 표로 정리해줘" 같은 질문은 RAG의 top-k 검색과 맞지 않습니다. 청크 열 개만 가져오면 열한 번째 물건이 빠지고, LLM은 빠진 걸 모른 채 답을 씁니다. 그래서 이런 질문은 LLM을 거치지 않고 Prisma로 직접 조회해 마크다운 표를 만듭니다.
반면 "이 브랜드 거 중에 정리 대기 걸린 건 뭐 있었지?" 같은 건 색인을 씁니다. 이건 전체 목록이 아니라 조건에 맞는 걸 찾는 질문이라 top-k 검색이 잘 작동합니다. 같은 Item 데이터라도 질문의 결에 따라 색인을 탈 때가 있고 DB를 탈 때가 있습니다.
즉 "어디에 색인해두냐"와 "그 색인을 언제 쓰냐"는 별개의 결정입니다. 저는 Item을 일단 색인에 넣어두고, 정확한 목록이 필요한 질문만 별도로 직조회로 돌립니다. 색인은 남겨두되, 쓰는 길만 가려서 엽니다.
무엇을 넣고 무엇을 넣지 않을까
그럼 모든 DB 테이블을 RAG에 넣어야 할까요? 저는 그러지 않았습니다. 114개 테이블 중 지금 색인에 들어가는 건 Doc과 Item 두 종류뿐입니다.
기준은 단순합니다. "자연어로 물어볼 일이 있는가"와 "텍스트로 펼쳤을 때 의미가 성립하는가"입니다. 재고는 "어디 있어", "뭐 있어", "유효기간 지났어" 같은 질문이 잦고, 컬럼들을 텍스트로 펼치면 하나의 물건 설명이 됩니다. 반면 가계부(Expense)나 수면 기록(SleepRecord)은 자연어로 물어볼 일이 거의 없고, 숫자 행을 텍스트로 펼쳐봤자 임베딩에 의미가 새겨지지 않습니다. 그런 건 차트로 보는 게 낫지, 챗봇으로 물어볼 게 아닙니다.
RAG의 도메인을 넓히는 기준은 "문서처럼 생긴 데이터"가 아니라 "자연어 질문이 닿을 수 있는 데이터"입니다. 구조화 데이터라도 자연어로 펼치면 RAG의 한 축이 됩니다. 다만 그 펼친 텍스트로 답을 만들지, 원본 DB로 답을 만들지는 질문의 종류에 따라 가릅니다. 이 두 결정을 나눠서 보는 게, RAG를 문서 검색 이상으로 쓰는 첫걸음이었습니다.
함께 읽기
- RAG 운영: 전체 재색인을 피하고 캐시는 본 요청을 막지 않는 법처음 RAG를 짤 때는 문서를 고칠 때마다 전체를 다시 색인했습니다. 문서가 백 개쯤 될 때는 그래도 됐습니다. 잠깐 기다리면 끝났거든요. 그런데 문서가 천 개를 넘고, 거기에 재고 아이템까지 색인에 들어가니 얘기가 달라졌습니다. 전체를 다시 임베딩하면 수천 번 모델을 부르는 셈이고, 그 사이에 질문이 들어오면 답이 늦…
- 한국어 RAG에서 벡터만 쓰면 안 되는 이유: 하이브리드 검색 설계처음 RAG를 짤 때는 벡터 검색만 썼습니다. 코사인 유사도 하나로 충분할 줄 알았죠. 그런데 한국어 질문에서 이상한 일이 잦았습니다. 분명 문서에 있는 단어를 물었는데 검색이 안 됩니다. 비슷한 의미의 다른 문서만 계속 위에 뜹니다. 원인을 찾다 보니, 벡터 검색이 "의미가 비슷한가"는 잘 재는데 "그 단어가 실제로 …
- 혼자 쓰는 RAG: 인증·권한을 빼고 남은 설계 결정들ChatGPT에 문서를 전부 올리면 끝나는 일 아닌가,라고 한동안 생각했습니다. 그런데 제가 쓰는 데이터를 전부 올리는 건 사정이 달랐습니다. 재고 목록, 영업 리드, 기도 기록, 가계부까지 한 사람의 전 삶이 들어있는 DB였거든요. 그래서 로컬 LLM 기반 RAG를 직접 짰습니다. 이 글은 그 과정에서 내린, 그리고 …
- RAG 답변 품질을 메트릭 없이 지키는 법: 출처·임계값·방어 코드RAG 답변 품질을 재는 정량 프레임워크가 있습니다. RAGAS, TruLens 같은 도구가 답변의 충실도(faithfulness), 관련성, 맥락 적합성을 점수로 냅니다. 처음엔 이런 걸 달아야 하나 고민했습니다. 그런데 막상 혼자 쓰는 RAG를 짜고 보니, 정량 점수보다 답이 거짓말을 안 하게 만드는 "코드"가 먼저…
- 멀티모달 RAG의 현실: 모달을 섞지 않고 텍스트로 환원하는 이유멀티모달 RAG라는 말을 처음 들었을 때, 이미지를 임베딩해서 문서 텍스트와 같은 벡터 공간에 올리는 걸 상상했습니다. CLIP 같은 모델로 그림과 글을 하나의 공간에 두면, "이런 느낌의 다이어그램 찾아줘" 같은 질문도 답이 나올 것 같았죠.