RSS

문서 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 컬럼 하나로 docitem을 구분했습니다. 그리고 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를 문서 검색 이상으로 쓰는 첫걸음이었습니다.