Slack 봇을 조직 Wiki 입구로 만들기
B-BOT에 RAG 파이프라인을 붙여 흩어진 온보딩·인수인계 문서를 Slack 질문으로 찾을 수 있게 만든 과정을 정리한다.
BCSD에서 활동하다 보면 질문은 대부분 Slack에서 시작된다.
로컬 환경은 어떻게 띄우는지, 환경 변수는 어디에 있는지, 배포는 어떤 순서로 하는지, 예전에 정리해 둔 에러 해결 문서는 어디에 있는지 같은 질문이다.
질문 자체는 문제가 아니지만 각 답을 확인할 수 있는 문서들이 GitHub README/Wiki, Notion 문서, Google Drive 문서에 흩어져 있고 트랙별로 문서 위치도 달라서 매번 찾아다녀야 한다는 것이 문제였다. 문서를 안 쓴 것이 아니라 문서가 흩어져 있었고, 그래서 “어디 보면 된다”는 말도 점점 어려워졌다.
처음에는 문서를 한곳으로 옮기는 방법을 생각했지만 트랙마다 익숙한 툴이 달랐고, 한 번에 문서 저장소를 통일하면 기존 문서의 위치와 작성 방식을 모두 바꿔야 했다.
그래서 문서 위치를 통일하는 대신, 문서에 접근하는 입구를 Slack 봇 하나로 통일하기로 했다.
1. Wiki를 새로 만들지 않기
새 Wiki를 만들기보다 기존 문서를 검색할 수 있는 입구를 만드는 쪽으로 방향을 잡았다. 이미 GitHub, Notion, Google Drive에 많은 문서가 쌓여 있었고, 현 상황에서 문제는 문서의 부재가 아니라 질문자가 어떤 저장소에서 어떤 키워드로 찾아야 하는지 모른다는 데 있었다.
그래서 B-BOT을 문서 저장소가 아닌 검색 입구가 되도록 설계했으며 사용자는 다른 사람에게 질문하듯이 자연어로 질문하고, 삐봇은 관련 문서 조각을 찾아 답변하도록 했다.
구조를 단순하게 보면 아래와 같다.
아키텍처 이미지는 GPT로 초안을 만들고, 글의 맥락에 맞게 수정했습니다.구조를 잡을 때는 Slack 처리, 모델 실행, 검색 인덱스를 서로 분리하는 데 집중했다. B-BOT 서버는 Slack 이벤트와 명령을 처리하고, Ollama는 임베딩과 답변 생성을 맡으며, PostgreSQL+pgvector는 정규화된 문서 청크와 질의 로그를 저장한다.
pgvector를 선택한 이유는 벡터 검색만을 위해 별도 저장소를 늘리고 싶지 않았기 때문이었다. 문서 청크에는 출처, 문서 타입, 수정 시각, 질의 로그와 같은 메타데이터가 함께 붙었고, 기존 운영 데이터도 PostgreSQL에 모을 계획이었으므로 같은 DB 안에서 벡터와 메타데이터를 함께 조회하는 편이 단순하다고 판단했다.
물론 Qdrant 같은 전용 벡터 DB를 쓰면 벡터 검색 성능과 확장성, 인덱스 운영 기능은 더 가져갈 수 있었을 것이다.
다만 이 프로젝트에서 당장 더 중요했던 건 검색 성능을 독립적으로 확장하는 일이 아니었다. 검색 결과를 재랭킹하고, 답변에 출처를 붙이고, 못 찾은 질문을 다시 문서 보강으로 연결하려면 출처·문서 타입·수정 시각·질의 로그가 벡터와 함께 움직여야 했다. 그래서 문서 청크와 메타데이터를 같은 흐름에서 조회할 수 있는 PostgreSQL+pgvector 쪽이 더 단순하다고 판단했다.
뿐만 아니라 저장소를 늘리면 백업과 장애 지점도 하나 더 생기는 것 역시 pgvector를 선택한 이유 중 하나로 했기에. 초기 구조에서는 PostgreSQL+pgvector로 구현을 하기로 결정했다.
이렇게 아키텍처를 나누면 봇 코드는 Slack 인터페이스에 집중할 수 있고, 모델이나 동기화 방식이 바뀌어도 RAG 질의 처리 경계만 조정하면 된다. 문서 수집도 같은 이유로 B-BOT의 scheduledTasks에 얹되, GitHub·Notion·Google Drive마다 변경 주기와 API 특성이 달라 소스별 동기화 전략은 따로 두었다.
2. 지식 소스 연결
연결한 소스는 GitHub, Notion, Google Drive 세 가지이다. 각 소스는 문서 형태와 노이즈가 달라서 같은 방식으로 수집할 수 없었다. 처음에는 커넥터를 공통 인터페이스로 맞추면 충분할 것 같았는데, 실제로는 소스마다 “어디까지 믿을 수 있는가”가 달랐다. 그래서 수집 코드는 공통 파이프라인으로 모으되, 문서를 발견하고 제외하는 기준은 소스별로 다르게 잡았다.
GitHub는 프로젝트 README와 Wiki를 중심으로 색인했다. 레포별 README는 프로젝트 설명, 로컬 실행 방법, 기술 스택처럼 온보딩 질문과 직접 연결되는 정보가 많았다. 다만 일부 문서는 아키텍처와 같이 이미지로만 표현하고 있는 내용이 있어서, 텍스트 본문만 읽으면 중요한 정보가 빠지는 문제가 있어 추후 개선을 해야할 부분이다.. Github는 문서 수가 적고 변경 빈도도 높지 않아서, 복잡한 증분 처리보다 주기적으로 다시 읽는 방식이 더 단순하게 처리할 수 있었고 작은 코퍼스에서는 정교한 동기화보다 실패 지점이 적은 방식이 운영하기 쉽다고 판단했다.
Notion은 공식 SDK인 @notionhq/client를 사용해 지정한 루트 페이지부터 블록 트리를 재귀적으로 읽었고, child_database나 연결된 데이터 소스를 만나면 SDK의 query API로 하위 페이지를 가져오는 방식을 이용했다.
Notion 문서는 일반 페이지, 토글, 컬럼, 데이터베이스가 섞여 있어서 단순한 텍스트 export처럼 다룰 수 없었기에
SDK가 돌려주는 블록 타입을 기준으로 본문이 있는 페이지는 색인하고, 출석부(회의 참여)처럼 제목만 있고 본문이 없는 데이터베이스 행은 자연스럽게 건너뛰도록 만들었다.
속도가 느리더라도 Notion의 실제 문서 구조를 따라가는 쪽이, 인수인계 문서처럼 데이터베이스 안에 들어간 문서를 놓치지 않는 방법이라고 판단했다.
Google Drive는 범위 설정이 먼저였다. 현재 Google Drive에는 동아리 회계, 보안에 연관된 계정 정보 등 공개적으로 사용하기 어려운 문서들이 다수 존재했기 때문에 교육, 트랙, 운영처럼 조직 지식에 해당하는 폴더만 명시적으로 지정하고, 그외 스프레드시트와 자격증명 관련 문서는 RAG 대상에서 제외했다.
3. 그대로 넣었을 때의 검색 실패
커넥터를 붙이고 나서 바로 괜찮은 Wiki가 되지는 않았다. 문서를 모으는 일과 질문에 맞게 검색되는 일은 다른 문제였다.
첫 번째 문제는 문맥이 잘리는 것이었다.
README나 Notion 문서는 헤딩 아래에 표와 목록이 붙어 있는 경우가 많다.
그런데 본문을 단순히 글자 수로만 자르다보니 Team Members 같은 헤딩과 실제 멤버 목록이 서로 다른 청크로 갈라지게 되고
이 상태에서 “참여한 사람들”을 물으면 이름만 나열된 청크는 의미적으로 잘 걸리지 않는 문제가 발생했다.
두 번째 문제는 회의록 노이즈였다. Notion에는 회의록과 작업 기록이 많았고, 이 문서들은 단어가 풍부해서 검색 결과에 자주 올라왔다. 하지만 온보딩 질문, 과거 에러 처리 기록을 찾을 때 필요한 답은 보통 회의록보다 가이드, README, 인수인계 문서에 있었기에 노이즈라고 판단됐다.
세 번째 문제는 프로젝트 혼합이었다. 여러 레포 문서를 한 번에 검색하면, 모델은 컨텍스트 안에 들어온 서로 다른 프로젝트 정보를 섞어 답하는 경향이 있었다. “코인 백엔드 v2 기술스택”을 물었는데 프론트엔드 기술이 섞이거나, 참여자 정보가 다른 레포에서 끌려오는 식이다.
처음에는 프롬프트에 “서로 다른 프로젝트를 섞지 말라”고 적으면 해결될 거라고 생각했다. 하지만 다시 확인해보니 이 문제는 생성 단계의 지시보다 검색 단계의 입력 문제에 가까웠다. 모델이 볼 수 있는 컨텍스트에 이미 여러 프로젝트가 섞여 있으면, 작은 지시문 하나로 안정적으로 분리하기 어려웠다.
4. 검색 품질을 위한 데이터 설계
검색 품질을 올리기 위해 먼저 청킹 방식을 바꿨다. 각 청크 앞에 현재 헤딩 경로를 붙여서, 본문 조각이 어느 문맥에서 나온 내용인지 함께 임베딩되도록 했다.
예를 들어 이름 목록만 남기지 않고 아래처럼 만든다.
Team Members > Active Members
철수 영희 ...
이렇게 하면 목록 자체에는 “멤버”, “참여자” 같은 단어가 없어도, 헤딩의 의미가 검색 벡터에 같이 들어가게 된다.
그 다음으로 해결의 실마리가 된 것은 OKF(Open Knowledge Format) 글이었다.
OKF 자체를 그대로 도입한 것은 아니지만, 문서를 단순 텍스트로만 보지 않고 type, tags, timestamp 같은 메타데이터를 함께 다루는 방향이 지금 문제와 잘 맞았다.
그래서 B-BOT에서는 별도 OKF 번들을 만들기보다, 이미 수집한 문서 청크에 doc_type과 updated_at을 붙이는 방식으로 아이디어만 가져왔다.
회의록과 가이드를 같은 텍스트 덩어리로 취급하지 않고, 검색 이후 재랭킹에서 문서의 성격과 최신성을 함께 보게 만드는 방식이었다.
다음으로 문서 타입을 붙였다.
수집 시점에 제목과 소스를 보고 readme, guide, handover, meeting, finance, personal_work, doc 같은 타입을 기록했다.
LLM으로 분류하지 않고 휴리스틱으로 처리했는데, 이 정도만으로도 회의록 노이즈를 줄이는 데 충분한 성과를 보였다.
검색 문제는 먼저 후보를 넓게 가져온 뒤, 의미 유사도에 타입 가중치와 최신성 가중치를 더해 다시 정렬하는 방식으로 개선했다.
const score = similarity + typeWeight[docType] + recencyBonus(updatedAt);
회의록과 개인 작업 기록은 조금 가중치를 낮추고, README와 가이드, 인수인계 문서는 조금 올렸다. 다만 최근 회의록이 오래된 가이드를 무조건 이기면 안 되기 때문에, 의미 유사도와 문서 타입을 뒤집지 않는 범위에서만 보조 지표로서 활용했다.
프로젝트 혼합은 라우팅으로 해결했다. 질문을 레포 식별 정보와 비교해 특정 프로젝트가 뚜렷하면 해당 프로젝트 문서만 검색했다. 모호한 질문은 전체 검색을 하되, 상위 결과가 한 프로젝트에 몰리면 그 프로젝트로 다시 좁혔다.
이 방식은 프롬프트를 더 길게 쓰는 것보다 효과가 분명했다. 모델에게 “섞지 말라”고 부탁하는 대신, 섞일 수 있는 문서를 애초에 컨텍스트에 넣지 않았다. RAG에서 생성 모델의 역할은 컨텍스트를 읽고 정리하는 것이고, 컨텍스트의 경계는 검색 파이프라인이 책임져야 했다.
5. Slack에서 Wiki처럼 답하기
사용자 입장에서는 내부 파이프라인보다 Slack에서 어떻게 보이는지가 더 중요하다.
B-BOT에는 !질문 명령을 붙였고, 질문이 들어오면 먼저 검색 중이라는 메시지를 보낸 뒤 답변을 갱신하도록 했다.
답변에는 두 가지 원칙을 뒀다.
- 찾은 문서가 없으면 모르는 데 아는 척하지 않고 못 찾았다고 말한다.
- 찾은 문서가 있으면 답변 아래에 출처를 붙인다.
출처는 단순히 보여주기 위한 것이 아니라, 사용자가 원문을 접근해 세부 내용을 확인, 수정할 수 있어야 봇이 Wiki의 입구 역할을 할 수 있기에 붙였다.
응답 방식도 한 번에 완성된 메시지를 보내는 대신 스트리밍에 가깝게 바꿨다. 흐름은 아래처럼 나눴다.
Slack B-BOT LLM
| !질문 | |
|------------>| |
| 200 ACK | |
|<------------| |
| 검색 중... | |
|<------------| |
| | generate |
| |-------------->|
| chat.update | stream chunk |
|<-------------|<--------------|
| chat.update | stream chunk |
|<-------------|<--------------|
Slack 이벤트는 빠르게 ACK하고, 별도 메시지를 만든 뒤 생성 결과가 들어오는 동안 chat.update로 내용을 갱신했다.
실제 생성 시간이 조금 걸리더라도 사용자는 봇이 멈춘 것이 아니라 답을 작성 중이라는 것을 볼 수 있다.
정답을 찾을 수 있어도 대화 경험이 답답하면 사람들은 다시 사람에게 물어보게 될 것이며 조직 Wiki가 잘 사용되기 위해서는, 검색 정확도뿐 아니라 기다리는 경험을 좋게 만드는 것이 중요했다..
6. 운영하면서 고치는 구조
RAG 기능은 한 번 만들고 끝나는 기능이 아니었다. 처음부터 모든 문서를 완벽하게 넣을 수 없고, 사용자가 실제로 어떤 질문을 할지도 알 수 없다. 그래서 답변 품질을 감으로 판단하지 않도록 로그를 남겼다.
질문마다 질문 내용, 답변, 출처, 문서를 찾았는지 여부, 응답 지연을 저장했다. 사용자 식별자는 원본 Slack ID를 저장하지 않고 해시 값 일부만 남겨서, 순사용자 집계는 가능하지만 원본 ID는 보관하지 않도록 했다.
문서를 찾았다고 해서 항상 좋은 답변은 아닐 수 있다. 문서는 찾았지만 모델이 잘못 요약했을 수도 있고, 사용자가 기대한 문서가 아닐 수도 있기에 답변에는 👍👎 반응도 붙여 실 사용자의 피드백을 받을 수 있도록 했다.
반대로 문서를 못 찾은 로그 목록은 그대로 다음 작업 목록이 된다. 어떤 질문을 못 찾는지 보면 어떤 문서를 보강해야 하는지 확인할 지표가 될 것이다.
측정 항목은 많이 만들지 않았다.
먼저 봐야 할 것은 이 기능이 실제로 쓰이는지였다.
그래서 질의수와 순사용자를 기록했고, 그다음에는 검색이 문서를 찾았는지 여부를 남겼다.
found=false가 쌓이면 답변 품질 이전에 검색 대상 문서가 비어 있다는 뜻이기 때문이다.
응답 지연은 Slack에서 기다릴 만한 속도인지 보기 위한 값이고, 👍👎는 자동 지표가 놓치는 답변 품질을 보기 위한 신호로 봤다. 문서를 찾았더라도 답변이 기대와 다를 수 있으니, 검색 커버리지와 사용자 피드백은 따로 봐야 했다.
이렇게 해두면 RAG 품질 개선이 “느낌상 별로다”에서 끝나지 않는다. 못 찾은 질문을 보고 문서를 추가하거나, 특정 타입이 계속 노이즈가 되면 재랭킹을 조정하거나, 출처는 맞는데 답변이 틀리면 프롬프트와 생성 모델 쪽을 다시 볼 수 있다.
7. 정리
B-BOT에 붙인 RAG Wiki는 새로운 문서 저장소가 아닌 GitHub, Notion, Google Drive에 흩어진 문서를 그대로 두고, Slack에서 질문으로 접근할 수 있게 만든 입구에 가깝다.
이 작업을 하면서 가장 많이 바뀐 생각은 LLM보다 검색 데이터의 경계가 먼저라는 점이었다. 문서를 많이 넣는 것만으로는 좋은 답변이 나오지 않았고, 헤딩을 함께 임베딩하고, 문서 타입을 구분하고, 프로젝트 범위를 좁히고, 출처를 유지해야 비로소 Wiki처럼 쓸 수 있었다.
물론 아직 완벽한 Wiki라고 부르기는 어려울 것 같다. 하지만 계속해서 봇이 못 찾은 질문을 기준으로 문서를 보강하고, 실제 사용 로그를 보면서 검색 가중치를 계속 조정하는 과정을 거치며 더 나은 동아리 환경을 만들 수 있을 것이다.
