Confluence 문서를 자동으로 수집해 Supabase에 저장하고, Slack에서 들어온 질문에 대해 저장된 문서를 근거로 답변하는 Q&A 봇입니다.
팀 문서와 회의록이 Confluence에 쌓여 있어도, 필요한 내용을 매번 직접 검색하고 여러 페이지를 읽어야 합니다. 이 프로젝트는 Confluence 문서를 주기적으로 동기화하고, Slack에서 자연어로 질문하면 관련 문서를 찾아 요약 답변과 참고 링크를 돌려주는 흐름을 제공합니다.
- Runtime: Deno (Supabase Edge Functions), TypeScript
- Database: Supabase Postgres
- Serverless: Supabase Edge Functions
- Document source: Atlassian Confluence API
- AI: OpenAI Responses API
- Chat interface: Slack Bot, Slack Events API
- Local tooling: Supabase CLI (
npx supabase)
Confluence
-> sync-confluence Edge Function
-> 문서 정규화 및 chunk 생성
-> chunk별 OpenAI 임베딩 생성
-> Supabase Postgres 저장
Slack mention
-> slack-events Edge Function
-> Slack signature 검증
-> 질문 텍스트 검색(search_pages) + 질문 임베딩 의미 검색(search_pages_by_embedding) 병행
-> OpenAI로 근거 기반 답변 생성
-> Slack thread에 답변 전송
sync-confluence 함수는 접근 가능한 Confluence space를 조회하고 각 page의 본문을 가져옵니다. CONFLUENCE_SPACE_KEYS가 비어 있으면 전체 space를 수집하고, 값이 있으면 지정한 space만 수집합니다.
수집된 page는 텍스트로 정규화한 뒤 hash를 계산해 confluence_pages에 upsert합니다. 이후 답변 검색에 적합하도록 본문을 chunk로 나누고 document_chunks에 저장합니다. Slack 질문과 답변 결과는 qa_logs에 남겨 운영 중 문제를 추적할 수 있게 했습니다.
page 본문과 별개로 Confluence의 "폴더" 콘텐츠 타입도 함께 수집합니다. 폴더는 본문이 없어 chunk를 만들지 않지만, parent_id 체인이 폴더를 거쳐 끊기지 않도록 confluence_pages에 subtype='folder'로 같이 저장합니다. 제목/본문에 env, .env, 환경변수가 포함된 페이지는 마스킹 여부와 무관하게 수집 자체를 하지 않습니다.
chunk마다 OpenAI 임베딩도 만들어서 저장합니다(페이지당 배치 호출 1회). 내용이 안 바뀐 페이지(content_hash 일치)면서 이미 청크가 있고 전부 임베딩까지 돼 있으면 재처리를 건너뜁니다 — 매번 전체를 다시 임베딩하면 Edge Function 실행 시간 한도(WORKER_RESOURCE_LIMIT)에 걸리기 때문입니다. 단, 청크가 0개인 채로 건너뛰면 영원히 재처리가 안 되므로 "청크가 실제로 존재하는지"도 같이 확인합니다.
주요 테이블:
confluence_pages: Confluence page(및 폴더) 원문, 제목, URL, space, version, sync metadata, 전문검색용raw_text_tsvdocument_chunks: 본문을 1200자 단위로 나눈 chunk와 chunk별 임베딩(embedding, pgvector). 의미 기반 검색(search_pages_by_embedding)이 이 테이블을 조회한다qa_logs: Slack 질문, 답변, 참고 문서, 처리 상태, 에러 메시지bot_channel_guides: 채널별 봇 사용 가이드 전송 기록
qa_logs는 후속 질문 처리를 위해 Slack thread metadata도 저장합니다.
slack_thread_ts: 같은 thread의 질문/답변을 묶기 위한 timestampslack_message_ts: 개별 Slack 메시지 timestamp
bot_channel_guides는 봇이 채널에 초대됐을 때 사용 가이드를 한 번만 보내기 위한 중복 방지 테이블입니다.
Slack에서 봇을 멘션하면 slack-events 함수가 이벤트를 받습니다. 함수는 Slack signature를 검증한 뒤 질문 텍스트에서 봇 멘션을 제거하고, 두 가지 검색을 병행해서 관련 페이지를 찾습니다.
1. search_pages (텍스트 기반, 페이지 단위 2단계 검색)
- 제목 매칭: 질문에서 뽑은 핵심 키워드가 제목에 있는 페이지를 찾고, 그 키워드가 제목에 드물게 등장할수록(=더 구체적일수록) 점수를 높게 준다. 예를 들어 "대시보드"가 제목에 1개 문서에만 있으면 "모니터링"(수십 개 문서에 있음)보다 훨씬 결정적인 신호가 된다. (한계: "계정"/"로그인"처럼 흔한 키워드 여러 개가 약하게라도 제목에 걸리면 이 단계가 검색 예산을 다 차지할 수 있다 — 아래 임베딩 검색이 이 사각지대를 보완한다.)
- 본문 전문검색 보강:
raw_text_tsv(Postgres 전문검색, 제목 가중치 A + 본문 가중치 B) 기반ts_rank로 나머지를 채운다.
2. search_pages_by_embedding (의미 기반 검색)
질문을 OpenAI 임베딩으로 변환해 document_chunks.embedding(pgvector, HNSW 인덱스)과 코사인 거리로 비교한다. 제목이 질문과 아예 다른 언어/단어로 되어 있어도(예: 제목이 Auth인 문서가 "카카오 로그인" 질문에 걸리는 경우) 본문 의미가 관련 있으면 찾을 수 있다. 페이지 동기화 시 청크마다 임베딩을 미리 계산해 저장해둔다.
두 검색 결과를 합쳐서(중복 제거) LLM에 넘깁니다. 정밀한 순위를 하나로 통합하려 하지 않고, 후보를 넉넉히 넘겨서 실제로 관련 있는 내용만 답변에 쓰도록 LLM 프롬프트에서 걸러내게 합니다.
"모니터링 하위 페이지별 담당자 정리해줘"처럼 특정 상위 페이지의 하위 페이지 전체를 나열해야 하는 질문(hierarchy_listing intent)은 이 검색들과 별도로 get_page_descendants DB 함수로 parent_id 체인을 재귀적으로 따라가며 실제 하위 페이지 트리를 가져옵니다.
찾은 페이지 원문만 OpenAI 프롬프트에 포함합니다. 답변은 문서 내용 안에서만 생성하도록 제한하고, Slack에서 읽기 좋게 섹션 제목과 blockquote bullet 형식으로 정리합니다. 질문은 룰 기반 intent로 분류해 답변 기준을 다르게 적용합니다.
Slack 전송 시에는 생성된 mrkdwn 답변을 섹션 제목 기준으로 파싱해 Block Kit context, divider, section block으로 재구성합니다. text는 fallback으로 함께 보내므로 알림, 검색, 구형 클라이언트에서도 내용을 확인할 수 있습니다.
스레드 안에서 다시 봇을 멘션하면 같은 thread의 최근 Q&A 로그를 함께 참고합니다. 이전 답변은 후속 질문의 맥락으로만 사용하고, 사실 판단은 항상 검색된 Confluence 문서를 우선합니다.
봇이 채널에 처음 초대되면 member_joined_channel 이벤트를 받아 사용 가이드 메시지를 자동으로 전송합니다. 가이드는 채널별로 한 번만 전송하며, 실제 Confluence 문서 기반 질문 예시, thread 후속 질문, 민감값 마스킹 정책을 안내합니다.
원하는 답변을 받으려면 날짜, 문서명, 팀명, 담당자명 같은 정확한 키워드를 함께 입력하는 것이 좋습니다. 예를 들어 미팅 요약해줘보다 07월 6일 미팅에서 나온 주요 논의 정리해줘가 더 안정적으로 관련 문서를 찾습니다.
초대 가이드 질문 예시:
@qna 07월 6일 미팅에서 나온 주요 논의 정리해줘@qna 배포 설정 파일에서 보안상 주의할 점 알려줘@qna 컴포넌트 가이드 기준으로 UI 작업할 때 주의사항 알려줘@qna AWS Savings Plans 내용 요약해줘
질문 유형별 답변 섹션:
hierarchy_listing: 확인된 하위 페이지, 페이지별 담당자, 문서에 없는 하위 페이지, 참고 문서meeting_summary: 요약, 주요 논의, 담당자별 작업, 이후 작업 공유, 참고 문서security: 결론, 위험 요소, 권장 조치, 참고 문서how_to: 목적, 절차, 주의사항, 참고 문서owner_or_schedule: 확인된 담당자, 확인된 일정, 문서에 없는 정보, 참고 문서general: 답변, 근거, 참고 문서
검색 키워드는 질문에서 조사를 뗀 실제 단어(예: "카카오로그인"에서 "카카오"/"로그인"도 따로 추출)를 핵심 키워드로 쓰고, 질문 유형에 따라 보조 키워드를 추가합니다. 예를 들어 보안 질문은 .env, 환경변수, secret, token, password를 보조 키워드로 추가하고, 회의록 질문은 미팅, 회의록, 요약, 논의, 작업을 추가합니다. 다만 "프로젝트"처럼 문서 전반에 반복돼 변별력이 없는 단어는 키워드에서 제외합니다.
Confluence 문서에 .env, API key, token, password, secret 같은 민감값이 포함될 수 있으므로 아래 단계에서 처리합니다.
- 수집 대상 제외: 제목이
env,.env,환경변수에 매칭되는 페이지는 마스킹 여부와 무관하게sync-confluence가 애초에 수집하지 않음 - 수집 단계: 그 외 페이지는 원문을 저장하기 전에 민감값을
[REDACTED]로 치환 - 답변 단계: OpenAI 응답을 Slack에 보내기 전에 다시 한 번
[REDACTED]로 치환 - prompt 단계: 민감값 원문을 출력하지 말고 변수명과 조치 사항만 안내하도록 제한
마스킹은 값만 숨기고 변수명은 남깁니다. 예를 들어 OPENAI_API_KEY=...는 OPENAI_API_KEY=[REDACTED] 형태로 저장/응답됩니다.
Supabase pg_cron과 pg_net으로 sync-confluence Edge Function을 매일 실행합니다.
Job: sync-confluence-daily
Schedule: 0 15 * * *
Timezone: UTC 기준, KST 매일 00:00
Timeout: timeout_milliseconds:=120000
Supabase Dashboard의 Cron UI는 timeout을 짧게 덮어쓸 수 있으므로, cron job을 수정할 때는 SQL Editor에서 cron.job의 command 값을 확인합니다.
sync-confluence: Confluence 문서 수집, 정규화, chunk 저장, chunk별 임베딩 생성slack-events: Slack 이벤트 수신, 질문 검색, AI 답변 생성, Slack 응답 전송
현재 배포 URL:
https://vqtdrlqqlrakzrkiqpoy.supabase.co/functions/v1/slack-events
Supabase Edge Functions는 Supabase 기본 env 외에 아래 값을 사용합니다.
CONFLUENCE_BASE_URL=
CONFLUENCE_EMAIL=
CONFLUENCE_API_TOKEN=
CONFLUENCE_SPACE_KEYS=
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
SLACK_BOT_TOKEN=
SLACK_BOT_USER_ID=
SLACK_SIGNING_SECRET=
SYNC_BATCH_SIZE=25
MAX_CONTEXT_CHARS=12000CONFLUENCE_SPACE_KEYS, SYNC_BATCH_SIZE, MAX_CONTEXT_CHARS, OPENAI_EMBEDDING_MODEL은 선택 값입니다(OPENAI_EMBEDDING_MODEL 기본값 text-embedding-3-small). SLACK_BOT_USER_ID는 Slack auth.test 응답의 user_id 값이며, 봇 초대 이벤트에서 봇 자신이 채널에 들어온 경우만 가이드 메시지를 보내는 데 사용합니다.
이 프로젝트에는 별도 Node 런타임/빌드가 없습니다. 배포와 마이그레이션은 Supabase CLI(npx supabase)로 합니다.
# 최초 1회: 프로젝트 연결
npx supabase link --project-ref vqtdrlqqlrakzrkiqpoy
# 마이그레이션 적용
npx supabase db push
# (원격 마이그레이션 이력이 로컬 파일명과 안 맞아 db push가 막히면, 개별 파일을 직접 실행)
npx supabase db query --linked -f supabase/migrations/00xx_xxx.sql
# Edge Function 배포
npx supabase functions deploy slack-events --project-ref vqtdrlqqlrakzrkiqpoy
npx supabase functions deploy sync-confluence --project-ref vqtdrlqqlrakzrkiqpoy
# 수동 동기화 실행 (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY는 .env 참고)
curl -X POST -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" \
"$SUPABASE_URL/functions/v1/sync-confluence"
# DB에서 검색 결과 직접 확인
npx supabase db query --linked "select title from search_pages(array['키워드1','키워드2'], array['키워드1','키워드2'], 10);"