사내 지식을 답변으로 바꾸는 RAG 시스템 구축기

문서는 많지만 필요한 순간에 찾기 어렵고, 검색 결과를 열어 일일이 내용을 비교해야 한다면 지식은 충분히 활용되지 못합니다. 이번 글에서는 문서와 업무 데이터를 자연어로 검색하고, 근거와 함께 답을 생성하는 RAG 시스템을 작은 범위에서 시작해 운영 가능한 구조로 확장한 과정을 정리합니다.

왜 RAG가 필요했나

일반 키워드 검색은 문서에 정확한 표현이 있어야 잘 동작합니다. 반면 실무 질문은 같은 의미를 여러 표현으로 묻습니다. “보관된 장비가 어디 있지?”, “최근 자료의 핵심은 무엇이지?”처럼 질문의 형태도 다양합니다.

RAG(Retrieval-Augmented Generation)는 먼저 관련 자료를 검색한 뒤, 검색된 근거만 언어 모델에 전달해 답을 만듭니다. 모델이 모든 사내 정보를 외우게 하는 대신 최신 원본을 검색하므로 갱신과 출처 확인이 쉽습니다.

전체 구조

구성은 네 부분으로 나눴습니다.

원본 계층: 문서 데이터베이스와 내부 파일 저장소

검색 계층: PostgreSQL과 pgvector

생성 계층: 로컬에서 운영하는 Ollama 임베딩·대화 모델

애플리케이션 계층: Next.js 기반 Chat UI와 API

원본 파일 저장소는 데이터 창고 역할만 맡고, 검색용 텍스트와 벡터는 애플리케이션 전용 데이터베이스에 둡니다. 이 구조는 원본과 검색 인덱스의 책임을 분리하고 애플리케이션 요청 경로를 짧게 유지합니다.

pgvector와 하이브리드 검색

문서를 적절한 길이의 청크로 나누고 임베딩 모델로 벡터를 생성해 pgvector에 저장했습니다. 검색은 벡터 유사도만 쓰지 않고 PostgreSQL 전문 검색을 함께 사용했습니다.

벡터 검색: 표현이 달라도 의미가 비슷한 자료 탐색

키워드 검색: 제품명, 문서명, 고유명사처럼 정확한 문자열 탐색

Reciprocal Rank Fusion: 두 검색 순위를 하나로 결합

벡터 검색은 언제나 “가장 가까운 결과”를 반환하기 때문에 관련성이 낮은 자료도 결과에 들어올 수 있습니다. 이를 막기 위해 최소 유사도 기준, 동일 문서의 청크 수 제한, 답변에 전달할 최대 근거 수를 함께 적용했습니다.

문서뿐 아니라 구조화 데이터도 검색하기

RAG의 입력이 꼭 파일일 필요는 없습니다. 물품처럼 구조화된 데이터도 사람이 읽는 문장으로 직렬화하면 같은 검색 인덱스에서 찾을 수 있습니다.

예를 들어 이름, 분류, 색상, 소재, 보관 위치, 수량, 구매일을 검색용 텍스트로 만들되 원본 레코드를 문서 테이블에 복사하지 않았습니다. 인덱스에 출처 유형과 원본 ID를 저장해 원본 수정 시 해당 항목만 다시 처리하도록 구성했습니다.

이 방식은 다음과 같은 질문을 가능하게 합니다.

특정 장비의 보관 위치와 수량 찾기

계절과 색상 조건에 맞는 물품 탐색

교체나 보충이 필요한 항목 확인

서로 연관된 물품 조회

PDF 수집과 증분 인덱싱

첫 검증에서는 Markdown, TXT, CSV, JSON, YAML을 대상으로 시작했고 PDF 지원을 추가했습니다. PDF는 바이너리를 그대로 읽지 않고 텍스트 추출 도구로 변환한 결과만 가져옵니다. 텍스트 레이어가 없는 이미지 스캔 PDF는 OCR 단계가 필요하므로 자동으로 제외합니다.

매번 모든 파일을 다시 읽으면 파일 저장소와 임베딩 서버에 불필요한 부하가 생깁니다. 그래서 다음 순서로 변경분만 확인합니다.

파일 크기와 수정 시각 비교

변경 후보만 체크섬 계산

신규·변경·삭제 상태 반영

내용 해시가 달라진 출처만 임베딩

검색 캐시 무효화

자동 동기화 작업은 짧은 주기로 실행하되 허용된 검증 폴더와 개발 데이터베이스만 접근하도록 제한했습니다.

답변 품질은 검색 이후에 결정된다

초기에는 관련 청크 여러 개를 그대로 모델에 전달했습니다. 그러자 모델은 질문에 답하기보다 근거를 번호순으로 요약했습니다. 검색 성공이 곧 좋은 답변을 뜻하지는 않았습니다.

개선한 원칙은 다음과 같습니다.

관련성이 낮은 근거 제거

한 출처가 답변 전체를 차지하지 않도록 중복 제한

질문에 대한 결론을 먼저 작성

실제로 사용한 문장에만 각주 표시

근거가 부족하면 관련 없는 내용을 나열하지 않고 범위를 되묻기

출처 목록은 답변 본문이 아니라 별도 UI로 제공

작은 언어 모델일수록 프롬프트의 역할과 출력 형식을 명확하게 나누는 것이 중요했습니다.

Chat UI와 스트리밍

Chat 화면은 좌측 대화 영역, 중앙 답변 스레드, 우측 참고 문헌 레일로 나눴습니다. 답변은 Markdown으로 렌더링하되 HTML 정화 과정을 거쳐 안전하게 표시합니다. 본문의 각주를 누르면 해당 근거가 강조됩니다.

Ollama에 설치된 대화 모델을 선택할 수 있는 옵션도 추가했습니다. 임베딩 모델은 인덱스 호환성을 위해 고정하고, 답변 모델만 요청별로 바꿉니다. 서버는 실제 설치된 모델인지 검증하고 임베딩 전용 모델은 선택 목록에서 제외합니다.

스트리밍 응답은 애플리케이션만 구현한다고 끝나지 않았습니다. 리버스 프록시가 SSE 응답을 버퍼링하면 토큰이 실시간으로 표시되지 않고 덩어리로 도착합니다. RAG 답변 경로에 한해 프록시 버퍼링·캐시·압축을 끄고 읽기 시간을 늘려 해결했습니다.

운영하면서 발견한 함정

한 요청에서 전부 인덱싱하지 않기

임베딩 대상을 작은 배치로 나누면 프록시 타임아웃을 피하고 실패한 지점부터 재개할 수 있습니다. UI는 여러 배치를 자동 반복하고 진행률을 보여주는 편이 좋습니다.

동시 인덱싱 충돌 막기

수동 인덱싱과 예약 작업이 겹치면 동일한 청크를 동시에 만들 수 있습니다. 출처 단위 트랜잭션 잠금으로 같은 원본의 중복 처리를 직렬화했습니다.

비공개 패키지 인증은 모든 워크플로에 필요하다

배포 작업에서 정상 설치되던 의존성이 별도 동기화 작업에서는 인증 오류로 실패할 수 있습니다. 각 워크플로가 독립된 실행 환경이라는 점을 고려해 패키지 레지스트리 인증을 명시해야 합니다.

캐시는 가속 계층이어야 한다

Redis 장애가 검색 자체를 막지 않도록 캐시 읽기·쓰기 실패는 원본 검색으로 우회합니다. 인덱스 갱신 뒤에는 관련 캐시만 지우고 짧은 TTL도 함께 사용했습니다.

안전한 확장 방향

현재 RAG는 읽기 전용입니다. 생성과 수정도 가능하지만, 언어 모델이 데이터베이스나 파일 시스템을 직접 변경하게 해서는 안 됩니다. 다음 단계에서는 모델이 구조화된 변경안을 만들고 사용자가 변경 전후를 확인한 뒤 기존 애플리케이션 API가 실행하는 Action 계층을 고려할 수 있습니다.

조회는 즉시 실행하되 생성·수정은 승인 후 실행하고, 삭제는 추가 확인과 감사 로그를 요구하는 방식이 적절합니다.

마무리

실용적인 RAG는 벡터 데이터베이스 하나로 완성되지 않습니다. 원본 범위 제한, 변경 감지, 하이브리드 검색, 근거 선별, 프롬프트, 스트리밍 프록시, 캐시, 자동화 작업이 함께 맞물려야 합니다.

작은 검증 폴더와 개발 데이터베이스에서 시작해 실패 지점을 확인하고, 출처 유형을 하나씩 확장하는 접근이 안전하고 효과적이었습니다. 특히 “무엇을 검색할 것인가”만큼 “무엇을 검색하지 않을 것인가”를 설계하는 일이 중요했습니다.