feat: 실시간 용어설명 조회 기능 추가

This commit is contained in:
djeon 2025-10-29 21:50:16 +09:00
parent a84449e88d
commit 8bb91f646f
21 changed files with 1038 additions and 2759 deletions

View File

@ -1,441 +0,0 @@
# Vector DB 통합 시스템 구현 완료 보고서
## 프로젝트 개요
**목표**: 용어집(Term Glossary)과 관련자료(Related Documents) 검색을 위한 Vector DB 기반 통합 시스템 개발
**구현 기간**: 2025년 (프로젝트 완료)
**기술 스택**:
- **Backend**: Python 3.9+, FastAPI
- **Vector DB (용어집)**: PostgreSQL 14+ with pgvector
- **Vector DB (관련자료)**: Azure AI Search
- **AI Services**: Azure OpenAI (임베딩), Claude 3.5 Sonnet (설명 생성)
- **Cache**: Redis (설정 완료, 구현 대기)
---
## 구현 완료 항목
### ✅ 1. 프로젝트 구조 및 의존성 설정
- **디렉토리 구조**:
```
vector/
├── src/
│ ├── models/ # 데이터 모델
│ ├── db/ # 데이터베이스 레이어
│ ├── services/ # 비즈니스 로직
│ ├── api/ # REST API
│ └── utils/ # 유틸리티
├── scripts/ # 데이터 로딩 스크립트
├── tests/ # 테스트 코드
├── config.yaml # 설정 파일
├── requirements.txt # 의존성
└── README.md # 문서
```
- **주요 파일**:
- `requirements.txt`: 15개 핵심 패키지 정의
- `config.yaml`: 환경별 설정 관리
- `.env.example`: 환경 변수 템플릿
### ✅ 2. 데이터 모델 및 스키마 정의
**용어집 모델** (`src/models/term.py`):
- `Term`: 용어 기본 정보 + 벡터 임베딩
- `TermSearchRequest`: 검색 요청 (keyword/vector/hybrid)
- `TermSearchResult`: 검색 결과 + 관련도 점수
- `TermExplanation`: Claude AI 생성 설명
**관련자료 모델** (`src/models/document.py`):
- `Document`: 문서 메타데이터 및 전체 내용
- `DocumentChunk`: 문서 청크 (2000 토큰 단위)
- `DocumentSearchRequest`: 하이브리드 검색 요청
- `DocumentSearchResult`: 검색 결과 + 시맨틱 점수
### ✅ 3. 용어집 Vector DB 구현 (PostgreSQL + pgvector)
**구현 파일**: `src/db/postgres_vector.py`
**핵심 기능**:
- ✅ 데이터베이스 초기화 (테이블, 인덱스 자동 생성)
- ✅ 용어 삽입/업데이트 (UPSERT)
- ✅ 키워드 검색 (ILIKE, 유사도 점수)
- ✅ 벡터 검색 (코사인 유사도)
- ✅ 카테고리별 통계
- ✅ 평균 신뢰도 계산
**테이블 스키마**:
```sql
CREATE TABLE terms (
term_id VARCHAR(255) PRIMARY KEY,
term_name VARCHAR(255) NOT NULL,
normalized_name VARCHAR(255),
category VARCHAR(100),
definition TEXT,
context TEXT,
synonyms TEXT[],
related_terms TEXT[],
document_source JSONB,
confidence_score FLOAT,
usage_count INT,
last_updated TIMESTAMP,
embedding vector(1536),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**인덱스**:
- B-tree: term_name, normalized_name, category
- GIN: synonyms
- IVFFlat: embedding (벡터 유사도 검색용)
### ✅ 4. 관련자료 Vector DB 구현 (Azure AI Search)
**구현 파일**: `src/db/azure_search.py`
**핵심 기능**:
- ✅ 인덱스 생성 (벡터 필드 + 시맨틱 설정)
- ✅ 문서 청크 업로드 (배치 처리)
- ✅ 하이브리드 검색 (키워드 + 벡터 + 시맨틱 랭킹)
- ✅ 필터링 (폴더, 문서타입, 날짜)
- ✅ 통계 조회 (문서 수, 타입별 분포)
**인덱스 스키마**:
- **필드**: id, document_id, document_type, title, folder, created_date, participants, keywords, agenda_id, agenda_title, chunk_index, content, content_vector, token_count
- **벡터 설정**: 1536차원, 코사인 유사도
- **시맨틱 설정**: title + content 우선순위
### ✅ 5. 데이터 로딩 및 임베딩 생성
**용어집 로딩** (`scripts/load_terms.py`):
- ✅ JSON 파일 파싱 (terms-01.json ~ terms-04.json)
- ✅ 임베딩 생성 (용어명 + 정의 + 맥락)
- ✅ PostgreSQL 삽입
- ✅ 통계 출력
**관련자료 로딩** (`scripts/load_documents.py`):
- ✅ JSON 파일 파싱 (meet-ref.json)
- ✅ 문서 청킹 (2000 토큰 단위, 문단 기준)
- ✅ 임베딩 생성 (청크별)
- ✅ Azure AI Search 업로드
- ✅ 통계 출력
**임베딩 생성기** (`src/utils/embedding.py`):
- ✅ Azure OpenAI API 연동
- ✅ 단일/배치 임베딩 생성
- ✅ 재시도 로직 (Exponential Backoff)
- ✅ 토큰 카운팅
- ✅ 오류 처리
### ✅ 6. 검색 API 및 서비스 구현
**FastAPI 애플리케이션** (`src/api/main.py`):
**용어집 엔드포인트**:
- `POST /api/terms/search`: 하이브리드 검색 (keyword/vector/hybrid)
- `GET /api/terms/{term_id}`: 용어 상세 조회
- `POST /api/terms/{term_id}/explain`: Claude AI 설명 생성
- `GET /api/terms/stats`: 통계 조회
**관련자료 엔드포인트**:
- `POST /api/documents/search`: 하이브리드 검색 + 시맨틱 랭킹
- `GET /api/documents/stats`: 통계 조회
**주요 기능**:
- ✅ 의존성 주입 (Database, Embedding, Claude Service)
- ✅ CORS 설정
- ✅ 에러 핸들링
- ✅ 로깅
- ✅ OpenAPI 문서 자동 생성
### ✅ 7. Claude AI 연동 구현
**Claude 서비스** (`src/services/claude_service.py`):
**구현 기능**:
- ✅ 용어 설명 생성 (2-3문장, 회의 맥락 반영)
- ✅ 유사 회의록 요약 (3문장, 환각 방지)
- ✅ 재시도 로직 (최대 3회)
- ✅ Fallback 메커니즘
- ✅ 토큰 사용량 추적
**프롬프트 엔지니어링**:
- 시스템 프롬프트: 역할 정의, 출력 형식 제약
- 사용자 프롬프트: 구조화된 정보 제공
- 환각 방지: "실제로 다뤄진 내용만 포함" 명시
### ✅ 8. 테스트 및 샘플 데이터 검증
**테스트 코드**:
- `tests/test_api.py`: API 엔드포인트 통합 테스트 (10개 테스트 케이스)
- `tests/test_data_loading.py`: 데이터 로딩 및 임베딩 생성 검증
**검증 스크립트**:
- `scripts/validate_setup.py`: 설정 검증 자동화 스크립트
- Python 버전 확인
- 프로젝트 구조 확인
- 의존성 패키지 확인
- 환경 변수 확인
- 샘플 데이터 파일 확인
**테스트 가이드**:
- `TESTING.md`: 상세한 테스트 절차 및 문제 해결 가이드
---
## 기술적 의사결정
### 1. 하이브리드 아키텍처 선택
**결정**: PostgreSQL + pgvector (용어집) + Azure AI Search (관련자료)
**이유**:
- **용어집**: 소규모 데이터, 키워드 검색 중요 → PostgreSQL 적합
- **관련자료**: 대규모 문서, 시맨틱 검색 필요 → Azure AI Search 적합
- 각 용도에 최적화된 기술 선택으로 성능 극대화
### 2. 하이브리드 검색 전략
**용어집**:
- 키워드 검색: ILIKE 기반 유사도 계산
- 벡터 검색: 코사인 유사도
- 하이브리드: 가중 평균 (keyword_weight: 0.4, vector_weight: 0.6)
**관련자료**:
- Azure AI Search의 Hybrid Search + Semantic Ranking 활용
- 키워드 + 벡터 + L2 시맨틱 리랭킹
### 3. 청킹 전략
**기준**: 2000 토큰 단위, 문단 경계 존중
**장점**:
- 의미 단위 분할로 컨텍스트 보존
- 임베딩 품질 향상
- 검색 정확도 개선
### 4. 에러 처리 및 Fallback
**임베딩 생성**:
- Exponential Backoff (최대 3회 재시도)
- Rate Limit 대응
**Claude AI**:
- API 실패 시 기본 정의 + 맥락 반환
- 사용자 경험 저하 방지
---
## 주요 파일 구조
```
vector/
├── src/
│ ├── models/
│ │ ├── term.py # 용어집 데이터 모델
│ │ └── document.py # 관련자료 데이터 모델
│ ├── db/
│ │ ├── postgres_vector.py # PostgreSQL + pgvector 구현
│ │ └── azure_search.py # Azure AI Search 구현
│ ├── services/
│ │ └── claude_service.py # Claude AI 서비스
│ ├── api/
│ │ └── main.py # FastAPI 애플리케이션
│ └── utils/
│ ├── config.py # 설정 관리
│ └── embedding.py # 임베딩 생성
├── scripts/
│ ├── load_terms.py # 용어집 데이터 로딩
│ ├── load_documents.py # 관련자료 데이터 로딩
│ └── validate_setup.py # 설정 검증
├── tests/
│ ├── test_api.py # API 테스트
│ └── test_data_loading.py # 데이터 로딩 테스트
├── config.yaml # 설정 파일
├── requirements.txt # 의존성
├── .env.example # 환경 변수 템플릿
├── README.md # 프로젝트 문서
├── TESTING.md # 테스트 가이드
└── IMPLEMENTATION_SUMMARY.md # 본 문서
```
---
## API 엔드포인트 요약
### 용어집 API
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST | `/api/terms/search` | 용어 하이브리드 검색 |
| GET | `/api/terms/{term_id}` | 용어 상세 조회 |
| POST | `/api/terms/{term_id}/explain` | Claude AI 설명 생성 |
| GET | `/api/terms/stats` | 용어 통계 |
### 관련자료 API
| Method | Endpoint | 설명 |
|--------|----------|------|
| POST | `/api/documents/search` | 문서 하이브리드 검색 |
| GET | `/api/documents/stats` | 문서 통계 |
---
## 성능 특성
### 용어집 검색
- **키워드 검색**: ~10ms (100개 용어 기준)
- **벡터 검색**: ~50ms (IVFFlat 인덱스)
- **하이브리드 검색**: ~60ms
### 관련자료 검색
- **하이브리드 검색**: ~100-200ms
- **시맨틱 랭킹**: +50ms
### 임베딩 생성
- **단일 텍스트**: ~200ms
- **배치 (50개)**: ~1-2초
### Claude AI 설명
- **평균 응답 시간**: 2-5초
- **토큰 사용량**: 500-1000 토큰
---
## 다음 단계 (권장사항)
### 즉시 실행 가능
1. **환경 설정**:
```bash
python scripts/validate_setup.py
```
2. **데이터 로딩**:
```bash
python scripts/load_terms.py
python scripts/load_documents.py
```
3. **API 서버 실행**:
```bash
python -m src.api.main
# 또는
uvicorn src.api.main:app --reload
```
4. **테스트 실행**:
```bash
pytest tests/ -v
```
### 단기 개선 (1-2주)
- [ ] Redis 캐싱 활성화 (설정 완료, 구현 필요)
- [ ] API 인증/인가 추가
- [ ] 로깅 시스템 고도화 (구조화된 로그)
- [ ] 성능 모니터링 (Prometheus/Grafana)
### 중기 개선 (1-2개월)
- [ ] 용어 버전 관리
- [ ] 문서 업데이트 자동화 (웹훅 또는 스케줄러)
- [ ] 사용자 피드백 기반 관련도 학습
- [ ] A/B 테스트 프레임워크
### 장기 개선 (3개월+)
- [ ] 다국어 지원 (한국어/영어)
- [ ] 그래프 DB 통합 (용어 관계 시각화)
- [ ] 실시간 회의록 생성 (STT 연동)
- [ ] 지식 그래프 자동 구축
---
## 품질 메트릭
### 코드 커버리지
- 데이터 모델: 100%
- DB 레이어: 90%
- API 레이어: 85%
- 서비스 레이어: 80%
### 검색 품질
- 용어집 정확도: 평가 필요 (사용자 피드백)
- 문서 검색 정확도: 평가 필요 (사용자 피드백)
- Claude 설명 품질: 평가 필요 (전문가 리뷰)
---
## 의존성 요약
### 핵심 라이브러리
- **Web Framework**: fastapi, uvicorn
- **Database**: psycopg2-binary, pgvector
- **AI Services**: openai (Azure OpenAI), anthropic (Claude)
- **Azure**: azure-search-documents, azure-core, azure-identity
- **Cache**: redis
- **Data**: pydantic, pyyaml
- **Utilities**: tenacity (retry), tiktoken (tokenizer)
### 개발/테스트
- pytest
- httpx (API 테스트)
---
## 보안 고려사항
### 현재 구현
- ✅ 환경 변수로 API 키 관리
- ✅ .env 파일 gitignore 처리
- ✅ SQL Injection 방지 (파라미터화된 쿼리)
### 개선 필요
- [ ] API 키 로테이션 자동화
- [ ] Rate Limiting
- [ ] API 인증/인가 (JWT, OAuth2)
- [ ] 입력 검증 강화
- [ ] HTTPS 강제
- [ ] 감사 로그
---
## 비용 예측 (월별)
### Azure OpenAI (임베딩)
- 모델: text-embedding-ada-002
- 비용: $0.0001 / 1K 토큰
- 예상: 100만 토큰/월 → **$0.10**
### Azure AI Search
- 티어: Basic
- 비용: ~$75/월
- 예상: **$75**
### Claude API
- 모델: claude-3-5-sonnet
- 비용: $3 / 1M 입력 토큰, $15 / 1M 출력 토큰
- 예상: 10만 토큰/월 → **$1-2**
### 총 예상 비용: **~$80-85/월**
---
## 결론
Vector DB 통합 시스템이 성공적으로 구현되었습니다. 용어집과 관련자료 검색을 위한 하이브리드 아키텍처를 채택하여 각 용도에 최적화된 성능을 제공합니다.
**주요 성과**:
- ✅ 8개 주요 컴포넌트 완전 구현
- ✅ 10개 REST API 엔드포인트
- ✅ 포괄적인 테스트 스위트
- ✅ 상세한 문서화
- ✅ 프로덕션 준비 코드
**다음 단계**:
1. 환경 설정 및 검증
2. 데이터 로딩
3. API 서버 실행
4. 통합 테스트
5. 프로덕션 배포
모든 소스 코드와 문서는 `/Users/daewoong/home/workspace/HGZero/vector/` 디렉토리에 있습니다.

View File

@ -1,132 +0,0 @@
# Vector DB 통합 시스템
## 개요
회의록 작성 시스템을 위한 Vector DB 기반 용어집 및 관련자료 검색 시스템
## 주요 기능
1. **용어집 (Term Glossary)**
- PostgreSQL + pgvector 기반
- 맥락 기반 용어 설명 제공
- Claude AI 연동
2. **관련자료 (Related Documents)**
- Azure AI Search 기반 (별도 인덱스)
- Hybrid Search + Semantic Ranking
- 회의록 유사도 검색
## 기술 스택
- Python 3.11+
- FastAPI (REST API)
- PostgreSQL + pgvector (용어집)
- Azure AI Search (관련자료)
- Azure OpenAI (Embedding)
- Claude 3.5 Sonnet (LLM)
- Redis (캐싱)
## 프로젝트 구조
```
vector/
├── src/
│ ├── models/ # 데이터 모델
│ ├── db/ # DB 연동 (PostgreSQL, Azure Search)
│ ├── services/ # 비즈니스 로직
│ ├── api/ # REST API
│ └── utils/ # 유틸리티 (임베딩, 설정 등)
├── scripts/ # 초기화 및 데이터 로딩 스크립트
└── tests/ # 테스트
```
## 설치 및 실행
### 1. 환경 설정
```bash
# .env 파일 생성
cp .env.example .env
# .env 파일을 열어 실제 API 키 및 데이터베이스 정보 입력
# - POSTGRES_* (PostgreSQL 접속 정보)
# - AZURE_OPENAI_* (Azure OpenAI API 키 및 엔드포인트)
# - AZURE_SEARCH_* (Azure AI Search API 키 및 엔드포인트)
# - CLAUDE_API_KEY (Claude API 키)
```
### 2. 의존성 설치
```bash
# 가상환경 생성 (권장)
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# 패키지 설치
pip install -r requirements.txt
```
### 3. 설정 검증
```bash
# 모든 설정이 올바른지 확인
python scripts/validate_setup.py
```
### 4. 데이터 로딩
```bash
# 용어집 데이터 로딩 (PostgreSQL 테이블 자동 생성 및 데이터 삽입)
python scripts/load_terms.py
# 관련자료 데이터 로딩 (Azure AI Search 인덱스 생성 및 데이터 업로드)
python scripts/load_documents.py
```
### 5. API 서버 실행
```bash
# 방법 1: 직접 실행
python -m src.api.main
# 방법 2: uvicorn 사용 (개발 모드)
uvicorn src.api.main:app --reload --host 0.0.0.0 --port 8000
```
### 6. API 문서 확인
브라우저에서 다음 주소로 접속:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API 엔드포인트
### 용어집 API
- `POST /api/terms/search` - 용어 검색
- `GET /api/terms/{term_id}` - 용어 상세 조회
- `POST /api/terms/{term_id}/explain` - 맥락 기반 용어 설명 (Claude AI)
### 관련자료 API
- `POST /api/documents/search` - 관련 문서 검색 (Hybrid Search)
- `GET /api/documents/related/{meeting_id}` - 관련 회의록 추천
- `POST /api/documents/{doc_id}/summarize` - 유사 내용 요약 (Claude AI)
## 테스트
### 설정 검증 테스트
```bash
# 환경 설정 및 의존성 확인
python scripts/validate_setup.py
```
### 데이터 로딩 테스트
```bash
# 데이터 파일 로드 및 임베딩 생성 검증
python tests/test_data_loading.py
```
### API 테스트
```bash
# API 서버가 실행 중인 상태에서:
pytest tests/test_api.py -v
```
자세한 테스트 가이드는 [TESTING.md](TESTING.md) 참조
## 문서
- [TESTING.md](TESTING.md) - 상세한 테스트 가이드 및 문제 해결
- [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) - 구현 완료 보고서
- [용어집 구현방안](../design/구현방안-용어집.md)
- [관련자료 구현방안](../design/구현방안-관련자료.md)
- [아키텍처 최적안 결정](../design/아키텍처_최적안_결정.md)

View File

@ -1,375 +0,0 @@
# RAG 회의록 서비스
회의록 RAG(Retrieval-Augmented Generation) 서비스는 확정된 회의록을 embedding 벡터와 함께 저장하고, 유사한 회의록을 검색할 수 있는 기능을 제공합니다.
## 아키텍처
```
Meeting Service RAG Service
| |
| 1. 회의록 확정 |
| |
v |
Event Hub --------------------------> Event Hub Consumer
(MINUTES_FINALIZED) |
| 2. 메시지 Consume
|
v
Embedding 생성
(OpenAI text-embedding-ada-002)
|
v
PostgreSQL + pgvector
(rag_minutes 테이블)
|
| 3. 연관 회의록 조회
|
v
Vector Similarity Search
(Cosine Distance)
```
## 주요 기능
### 1. 회의록 RAG 저장
- **트리거**: Meeting 서비스에서 회의록 확정 시 Event Hub로 이벤트 발행
- **처리 흐름**:
1. Event Hub Consumer가 `MINUTES_FINALIZED` 이벤트 수신
2. 회의록 전체 내용을 텍스트로 생성 (제목 + 목적 + 섹션 내용)
3. OpenAI Embedding API를 사용하여 1536차원 벡터 생성
4. `rag_minutes` 테이블에 회의록 정보와 embedding 벡터 저장
### 2. 연관 회의록 조회
- **API**: `POST /api/minutes/search`
- **검색 방식**: Vector Similarity Search (Cosine Distance)
- **입력**: 최종 회의록 내용 (full_content)
- **출력**: 유사도 높은 회의록 목록 (상위 K개, 기본값 5개)
### 3. 회의록 상세 조회
- **API**: `GET /api/minutes/{minutes_id}`
- **출력**: 회의록 전체 정보 (Meeting 정보, Minutes 정보, Sections)
## 데이터베이스 스키마
### rag_minutes 테이블
```sql
CREATE TABLE rag_minutes (
-- Meeting 정보
meeting_id VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
purpose VARCHAR(500),
description TEXT,
scheduled_at TIMESTAMP,
location VARCHAR(200),
organizer_id VARCHAR(50) NOT NULL,
-- Minutes 정보
minutes_id VARCHAR(50) PRIMARY KEY,
minutes_status VARCHAR(20) NOT NULL DEFAULT 'FINALIZED',
minutes_version INTEGER NOT NULL DEFAULT 1,
created_by VARCHAR(50) NOT NULL,
finalized_by VARCHAR(50),
finalized_at TIMESTAMP,
-- 회의록 섹션 (JSON)
sections JSONB,
-- 전체 회의록 내용 (검색용)
full_content TEXT NOT NULL,
-- Embedding 벡터
embedding vector(1536),
-- 메타데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 인덱스
- `idx_rag_minutes_meeting_id`: Meeting ID로 검색
- `idx_rag_minutes_title`: 제목으로 검색
- `idx_rag_minutes_finalized_at`: 확정 일시로 정렬
- `idx_rag_minutes_created_by`: 작성자로 검색
- `idx_rag_minutes_embedding`: 벡터 유사도 검색 (IVFFlat 인덱스)
- `idx_rag_minutes_full_content_gin`: Full-text 검색 (GIN 인덱스)
## 설치 및 실행
### 1. 의존성 설치
```bash
cd rag
pip install -r requirements.txt
```
### 2. 환경 변수 설정
`.env` 파일에 다음 환경 변수 추가:
```bash
# PostgreSQL
POSTGRES_HOST=4.217.133.186
POSTGRES_PORT=5432
POSTGRES_DATABASE=ragdb
POSTGRES_USER=hgzerouser
POSTGRES_PASSWORD=Hi5Jessica!
# Azure OpenAI (Embedding)
AZURE_OPENAI_API_KEY=your-api-key
AZURE_OPENAI_ENDPOINT=https://api.openai.com/v1/embeddings
# Azure Event Hub
EVENTHUB_CONNECTION_STRING=Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;...
EVENTHUB_NAME=hgzero-eventhub-name
AZURE_EVENTHUB_CONSUMER_GROUP=$Default
AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=hgzerostorage;...
AZURE_STORAGE_CONTAINER_NAME=hgzero-checkpoints
```
### 3. 데이터베이스 초기화
```bash
cd rag
python scripts/init_rag_minutes.py
```
이 스크립트는 다음 작업을 수행합니다:
- `rag_minutes` 테이블 생성
- 필요한 인덱스 생성
- pgvector 확장 설치 확인
### 4. Event Hub Consumer 시작
```bash
cd rag
python start_consumer.py
```
Consumer는 백그라운드에서 실행되며 Event Hub로부터 회의록 확정 이벤트를 수신합니다.
### 5. API 서버 시작
```bash
cd rag/src
python -m api.main
```
또는:
```bash
cd rag
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --reload
```
## API 사용 예시
### 1. 연관 회의록 검색
**요청**:
```bash
curl -X POST "http://localhost:8000/api/minutes/search" \
-H "Content-Type: application/json" \
-d '{
"query": "2025년 1분기 마케팅 전략 수립 및 실행 계획",
"top_k": 5,
"similarity_threshold": 0.7
}'
```
**응답**:
```json
[
{
"minutes": {
"meeting_id": "MTG-2025-001",
"title": "2025 Q1 마케팅 전략 회의",
"minutes_id": "MIN-2025-001",
"full_content": "...",
"sections": [...]
},
"similarity_score": 0.92
},
{
"minutes": {
"meeting_id": "MTG-2024-098",
"title": "2024 Q4 마케팅 결산",
"minutes_id": "MIN-2024-098",
"full_content": "...",
"sections": [...]
},
"similarity_score": 0.85
}
]
```
### 2. 회의록 상세 조회
**요청**:
```bash
curl "http://localhost:8000/api/minutes/MIN-2025-001"
```
**응답**:
```json
{
"meeting_id": "MTG-2025-001",
"title": "2025 Q1 마케팅 전략 회의",
"purpose": "2025년 1분기 마케팅 전략 수립",
"minutes_id": "MIN-2025-001",
"minutes_status": "FINALIZED",
"sections": [
{
"section_id": "SEC-001",
"type": "DISCUSSION",
"title": "시장 분석",
"content": "2025년 시장 동향 분석...",
"order": 1,
"verified": true
}
],
"full_content": "...",
"created_at": "2025-01-15T10:30:00",
"finalized_at": "2025-01-15T12:00:00"
}
```
### 3. 통계 조회
**요청**:
```bash
curl "http://localhost:8000/api/minutes/stats"
```
**응답**:
```json
{
"total_minutes": 150,
"total_meetings": 145,
"total_authors": 25,
"latest_finalized_at": "2025-01-20T15:30:00"
}
```
## Event Hub 메시지 형식
Meeting 서비스에서 발행하는 회의록 확정 이벤트 형식:
```json
{
"event_type": "MINUTES_FINALIZED",
"timestamp": "2025-01-15T12:00:00Z",
"data": {
"meeting_id": "MTG-2025-001",
"title": "2025 Q1 마케팅 전략 회의",
"purpose": "2025년 1분기 마케팅 전략 수립",
"description": "...",
"scheduled_at": "2025-01-15T10:00:00",
"location": "본사 3층 회의실",
"organizer_id": "organizer@example.com",
"minutes_id": "MIN-2025-001",
"status": "FINALIZED",
"version": 1,
"created_by": "user@example.com",
"finalized_by": "user@example.com",
"finalized_at": "2025-01-15T12:00:00",
"sections": [
{
"section_id": "SEC-001",
"type": "DISCUSSION",
"title": "시장 분석",
"content": "2025년 시장 동향 분석...",
"order": 1,
"verified": true
}
]
}
}
```
## 성능 최적화
### 1. Vector Search 최적화
- **IVFFlat 인덱스**: 대량의 벡터 데이터에 대한 근사 검색
- **lists 파라미터**: 데이터 크기에 따라 조정 (기본값: 100)
- **Cosine Distance**: 유사도 측정에 최적화된 거리 메트릭
### 2. Full-text Search
- **GIN 인덱스**: 텍스트 검색 성능 향상
- **to_tsvector**: PostgreSQL의 Full-text Search 기능 활용
### 3. Embedding 생성
- **배치 처리**: 여러 회의록을 동시에 처리할 때 배치 API 활용
- **캐싱**: 동일한 내용에 대한 중복 embedding 생성 방지
## 모니터링
### 1. 로그
- **Consumer 로그**: `logs/rag-consumer.log`
- **API 로그**: `logs/rag-api.log`
### 2. 메트릭
- 초당 처리 이벤트 수
- 평균 embedding 생성 시간
- 평균 검색 응답 시간
- 데이터베이스 연결 상태
## 문제 해결
### 1. Event Hub 연결 실패
```bash
# 연결 문자열 확인
echo $EVENTHUB_CONNECTION_STRING
# Event Hub 상태 확인 (Azure Portal)
```
### 2. Embedding 생성 실패
```bash
# OpenAI API 키 확인
echo $AZURE_OPENAI_API_KEY
# API 할당량 확인 (OpenAI Dashboard)
```
### 3. 데이터베이스 연결 실패
```bash
# PostgreSQL 연결 확인
psql -h $POSTGRES_HOST -U $POSTGRES_USER -d $POSTGRES_DATABASE
# pgvector 확장 확인
SELECT * FROM pg_extension WHERE extname = 'vector';
```
## 향후 개선 사항
1. **하이브리드 검색**: Keyword + Vector 검색 결합
2. **재랭킹**: 검색 결과 재정렬 알고리즘 추가
3. **메타데이터 필터링**: 날짜, 작성자, 카테고리 등으로 필터링
4. **설명 생성**: Claude AI를 활용한 유사 회의록 관계 설명
5. **배치 처리**: 대량의 과거 회의록 일괄 처리
## 참고 자료
- [pgvector](https://github.com/pgvector/pgvector): PostgreSQL의 Vector 확장
- [Azure Event Hubs](https://docs.microsoft.com/azure/event-hubs/): Azure Event Hubs 문서
- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings): OpenAI Embedding API 가이드

View File

@ -1,508 +0,0 @@
# Vector DB 통합 시스템 테스트 가이드
## 목차
1. [사전 준비](#사전-준비)
2. [환경 설정](#환경-설정)
3. [데이터베이스 설정](#데이터베이스-설정)
4. [데이터 로딩 테스트](#데이터-로딩-테스트)
5. [API 서버 실행](#api-서버-실행)
6. [API 엔드포인트 테스트](#api-엔드포인트-테스트)
7. [자동화 테스트](#자동화-테스트)
8. [문제 해결](#문제-해결)
---
## 사전 준비
### 필수 소프트웨어
- Python 3.9 이상
- PostgreSQL 14 이상 (pgvector 확장 지원)
- Redis (선택사항, 캐싱용)
### Azure 서비스
- Azure OpenAI Service (임베딩 생성용)
- Azure AI Search (관련 문서 검색용)
---
## 환경 설정
### 1. 가상환경 생성 및 활성화
```bash
cd vector
python -m venv venv
# Linux/Mac
source venv/bin/activate
# Windows
venv\Scripts\activate
```
### 2. 의존성 설치
```bash
pip install -r requirements.txt
```
### 3. 환경 변수 설정
`.env.example` 파일을 `.env`로 복사하고 실제 값으로 수정:
```bash
cp .env.example .env
```
`.env` 파일 수정 예시:
```bash
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DATABASE=meeting_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_actual_password
# Azure OpenAI
AZURE_OPENAI_API_KEY=your_actual_api_key
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
# Azure AI Search
AZURE_SEARCH_ENDPOINT=https://your-search-service.search.windows.net
AZURE_SEARCH_API_KEY=your_actual_api_key
# Claude AI
CLAUDE_API_KEY=your_actual_claude_api_key
# Redis
REDIS_PASSWORD=your_redis_password
```
---
## 데이터베이스 설정
### 1. PostgreSQL 데이터베이스 생성
```sql
CREATE DATABASE meeting_db;
```
### 2. pgvector 확장 설치
PostgreSQL에 연결 후:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### 3. 데이터베이스 초기화
용어 데이터 로딩 스크립트를 실행하면 자동으로 테이블이 생성됩니다:
```bash
python scripts/load_terms.py
```
---
## 데이터 로딩 테스트
### 1. 데이터 로딩 검증 테스트
환경 설정 없이도 데이터 파일 로드를 검증할 수 있습니다:
```bash
python tests/test_data_loading.py
```
**예상 출력:**
```
============================================================
Vector DB 데이터 로딩 테스트
============================================================
============================================================
설정 로드 테스트
============================================================
✓ 설정 로드 성공
- PostgreSQL 호스트: localhost
- Azure OpenAI 모델: text-embedding-ada-002
...
============================================================
용어 데이터 로드 테스트
============================================================
✓ terms-01.json 로드 완료: XX개 용어
✓ terms-02.json 로드 완료: XX개 용어
...
총 XXX개 용어 로드 완료
```
### 2. 용어집 데이터 로딩
```bash
python scripts/load_terms.py
```
**예상 출력:**
```
============================================================
용어집 데이터 로딩 시작
============================================================
✓ 설정 로드 완료
✓ PostgreSQL 연결 완료
✓ 데이터베이스 초기화 완료
✓ 임베딩 생성기 초기화 완료
✓ 총 XXX개 용어 로드 완료
✓ 임베딩 생성 완료
✓ 삽입 완료: 성공 XXX, 실패 0
============================================================
용어집 통계
============================================================
전체 용어: XXX개
평균 신뢰도: X.XX
카테고리별 통계:
- 기술용어: XX개
- 비즈니스용어: XX개
...
```
### 3. 관련자료 데이터 로딩
```bash
python scripts/load_documents.py
```
**예상 출력:**
```
============================================================
관련자료 데이터 로딩 시작
============================================================
✓ 설정 로드 완료
✓ Azure AI Search 연결 완료
✓ 인덱스 생성 완료
✓ 임베딩 생성기 초기화 완료
✓ 총 XX개 문서 로드 완료
✓ 총 XXX개 청크 생성 완료
✓ XXX개 청크 업로드 완료
============================================================
관련자료 통계
============================================================
전체 문서: XX개
전체 청크: XXX개
문서 타입별 통계:
- 회의록: XX개
- 참고자료: XX개
...
```
---
## API 서버 실행
### 1. 개발 모드로 실행
```bash
python -m src.api.main
```
또는:
```bash
uvicorn src.api.main:app --reload --host 0.0.0.0 --port 8000
```
### 2. 서버 확인
브라우저에서 접속:
- API 문서: http://localhost:8000/docs
- 대체 API 문서: http://localhost:8000/redoc
- 루트 엔드포인트: http://localhost:8000/
---
## API 엔드포인트 테스트
### 1. 루트 엔드포인트 테스트
```bash
curl http://localhost:8000/
```
**예상 응답:**
```json
{
"service": "Vector DB 통합 시스템",
"version": "1.0.0",
"endpoints": {
"용어집": "/api/terms/*",
"관련자료": "/api/documents/*"
}
}
```
### 2. 용어 검색 테스트
#### 키워드 검색
```bash
curl -X POST http://localhost:8000/api/terms/search \
-H "Content-Type: application/json" \
-d '{
"query": "API",
"search_type": "keyword",
"top_k": 5,
"confidence_threshold": 0.7
}'
```
#### 벡터 검색
```bash
curl -X POST http://localhost:8000/api/terms/search \
-H "Content-Type: application/json" \
-d '{
"query": "회의 일정 관리",
"search_type": "vector",
"top_k": 3,
"confidence_threshold": 0.6
}'
```
#### 하이브리드 검색
```bash
curl -X POST http://localhost:8000/api/terms/search \
-H "Content-Type: application/json" \
-d '{
"query": "마이크로서비스",
"search_type": "hybrid",
"top_k": 5,
"confidence_threshold": 0.5
}'
```
### 3. 용어 상세 조회
먼저 검색으로 용어 ID를 찾은 후:
```bash
curl http://localhost:8000/api/terms/{term_id}
```
### 4. 용어 설명 생성 (Claude AI)
```bash
curl -X POST http://localhost:8000/api/terms/{term_id}/explain \
-H "Content-Type: application/json" \
-d '{
"meeting_context": "백엔드 개발 회의에서 REST API 설계 논의"
}'
```
### 5. 용어 통계 조회
```bash
curl http://localhost:8000/api/terms/stats
```
### 6. 관련 문서 검색
```bash
curl -X POST http://localhost:8000/api/documents/search \
-H "Content-Type: application/json" \
-d '{
"query": "프로젝트 계획",
"top_k": 3,
"relevance_threshold": 0.3,
"semantic_ranking": true
}'
```
#### 필터링된 검색
```bash
curl -X POST http://localhost:8000/api/documents/search \
-H "Content-Type: application/json" \
-d '{
"query": "회의록",
"top_k": 5,
"relevance_threshold": 0.3,
"document_type": "회의록",
"folder": "프로젝트A",
"semantic_ranking": true
}'
```
### 7. 문서 통계 조회
```bash
curl http://localhost:8000/api/documents/stats
```
---
## 자동화 테스트
### 1. pytest 설치 확인
pytest가 requirements.txt에 포함되어 있어야 합니다.
### 2. API 테스트 실행
서버가 실행 중인 상태에서:
```bash
pytest tests/test_api.py -v
```
**예상 출력:**
```
tests/test_api.py::test_root PASSED
tests/test_api.py::test_search_terms_keyword PASSED
tests/test_api.py::test_search_terms_vector PASSED
tests/test_api.py::test_search_terms_hybrid PASSED
tests/test_api.py::test_get_term_stats PASSED
tests/test_api.py::test_search_documents PASSED
tests/test_api.py::test_search_documents_with_filters PASSED
tests/test_api.py::test_get_document_stats PASSED
tests/test_api.py::test_get_nonexistent_term PASSED
tests/test_api.py::test_explain_term PASSED
```
### 3. 개별 테스트 실행
```bash
# 특정 테스트만 실행
pytest tests/test_api.py::test_search_terms_keyword -v
# 테스트 상세 출력
pytest tests/test_api.py -v -s
```
---
## 문제 해결
### 1. PostgreSQL 연결 실패
**증상:**
```
psycopg2.OperationalError: could not connect to server
```
**해결:**
- PostgreSQL이 실행 중인지 확인
- .env 파일의 데이터베이스 접속 정보 확인
- 방화벽 설정 확인
### 2. pgvector 확장 오류
**증상:**
```
psycopg2.errors.UndefinedObject: type "vector" does not exist
```
**해결:**
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### 3. Azure OpenAI API 오류
**증상:**
```
openai.error.AuthenticationError: Incorrect API key provided
```
**해결:**
- .env 파일의 AZURE_OPENAI_API_KEY 확인
- Azure Portal에서 API 키 재확인
- API 엔드포인트 URL 확인
### 4. Azure AI Search 인덱스 생성 실패
**증상:**
```
azure.core.exceptions.HttpResponseError: (Unauthorized) Access denied
```
**해결:**
- .env 파일의 AZURE_SEARCH_API_KEY 확인
- Azure Portal에서 API 키 및 권한 확인
- 인덱스 이름 중복 여부 확인
### 5. 임베딩 생성 실패
**증상:**
```
RateLimitError: Rate limit exceeded
```
**해결:**
- Azure OpenAI의 Rate Limit 확인
- 배치 크기를 줄여서 재시도
- 재시도 로직이 자동으로 작동하므로 대기
### 6. Claude API 오류
**증상:**
```
anthropic.APIError: Invalid API Key
```
**해결:**
- .env 파일의 CLAUDE_API_KEY 확인
- API 키 유효성 확인
- 호출 빈도 제한 확인
---
## 성능 테스트
### 1. 검색 응답 시간 측정
```bash
time curl -X POST http://localhost:8000/api/terms/search \
-H "Content-Type: application/json" \
-d '{
"query": "API",
"search_type": "hybrid",
"top_k": 10
}'
```
### 2. 동시 요청 테스트
Apache Bench를 사용한 부하 테스트:
```bash
ab -n 100 -c 10 http://localhost:8000/
```
---
## 다음 단계
1. **프로덕션 배포 준비**
- 환경별 설정 분리 (dev/staging/prod)
- 로깅 및 모니터링 설정
- 보안 강화 (API 키 관리, HTTPS)
2. **성능 최적화**
- Redis 캐싱 활성화
- 인덱스 튜닝
- 쿼리 최적화
3. **기능 확장**
- 사용자 인증/인가
- 용어 버전 관리
- 문서 업데이트 자동화
4. **통합 테스트**
- E2E 테스트 작성
- CI/CD 파이프라인 구축
- 자동화된 성능 테스트

View File

@ -1,378 +0,0 @@
# Event Hub Consumer Guide
## 핵심 개념: Partition Ownership (파티션 소유권)
Event Hub에서는 **같은 Consumer Group 내에서 하나의 파티션은 동시에 오직 하나의 Consumer만 읽을 수 있습니다**. 이를 "Exclusive Consumer" 패턴이라고 합니다.
## 왜 이런 제약이 있나요?
### 1. 순서 보장 (Ordering)
```
파티션 0: [이벤트1] → [이벤트2] → [이벤트3]
❌ 잘못된 경우 (여러 Consumer가 동시 읽기):
Consumer A: 이벤트1 처리 중... (느림)
Consumer B: 이벤트2 처리 완료 ✓
Consumer C: 이벤트3 처리 완료 ✓
→ 처리 순서: 2 → 3 → 1 (순서 뒤바뀜!)
✅ 올바른 경우 (하나의 Consumer만):
Consumer A: 이벤트1 ✓ → 이벤트2 ✓ → 이벤트3 ✓
→ 처리 순서: 1 → 2 → 3 (순서 보장!)
```
### 2. Checkpoint 일관성
```
❌ 여러 Consumer가 각자 checkpoint:
Consumer A: offset 100까지 읽음 → checkpoint 저장
Consumer B: offset 150까지 읽음 → checkpoint 덮어씀
Consumer A 재시작 → offset 150부터 읽음 → offset 100~149 누락!
✅ 하나의 Consumer만:
Consumer A: offset 100 → 110 → 120 → ... (순차적)
재시작 시 → 마지막 checkpoint부터 정확히 재개
```
### 3. 중복 처리 방지
```
❌ 여러 Consumer가 동일 이벤트 읽기:
Consumer A: 주문 이벤트 처리 → 결제 완료
Consumer B: 동일 주문 이벤트 처리 → 중복 결제!
✅ 하나의 Consumer만:
Consumer A: 주문 이벤트 처리 → 1번만 결제 ✓
```
## Ownership Claim 메커니즘
### 동작 과정
```
Consumer A (PID 51257) - 먼저 시작
Blob Storage에 파티션 0 소유권 요청
✅ 승인 (Owner: A, Lease: 30초)
30초마다 Lease 갱신
계속 소유권 유지
Consumer B (테스트) - 나중에 시작
Blob Storage에 파티션 0 소유권 요청
❌ 거부 (이미 A가 소유 중)
"hasn't claimed an ownership" 로그
계속 재시도 (대기 상태)
```
### Blob Storage에 저장되는 정보
```json
{
"partitionId": "0",
"ownerIdentifier": "73fda457-b555-4af5-873a-54a2baa5fd95",
"lastModifiedTime": "2025-10-29T02:17:49Z",
"eTag": "\"0x8DCF7E8F9B3C1A0\"",
"offset": "120259090624",
"sequenceNumber": 232
}
```
## 현재 상황 분석
```
Event Hub: hgzero-eventhub-name
├─ 파티션 수: 1개 (파티션 0)
└─ Consumer Group: $Default
실행 중:
├─ Consumer A (PID 51257): 파티션 0 소유 ✅
│ ├─ 이벤트 정상 수신 중
│ ├─ Lease 주기적 갱신 중
│ └─ Checkpoint 저장 중
└─ 테스트 Consumer들: 소유권 없음 ❌
├─ 파티션 0 claim 시도
├─ 계속 거부당함
├─ "hasn't claimed an ownership" 로그
└─ 이벤트 수신 불가
```
## 해결 방법
### Option 1: 기존 Consumer 종료 후 재시작
```bash
# 기존 Consumer 종료
kill 51257
# 새로 시작
cd /Users/daewoong/home/workspace/HGZero/rag
python start_consumer.py
```
**장점**: 간단
**단점**: 다운타임 발생 (Lease 만료까지 최대 30초)
### Option 2: 다른 Consumer Group 사용 (권장)
```yaml
# config.yaml
eventhub:
consumer_group: "test-group" # $Default 대신 사용
```
**장점**:
- 기존 Consumer에 영향 없음
- 독립적으로 모든 이벤트 읽기 가능
- 개발/테스트에 이상적
**단점**: 리소스 추가 사용
### Option 3: 파티션 수평 확장
```
Event Hub 파티션 증가: 1개 → 3개
Consumer 실행: 3개
분산:
├─ Consumer A: 파티션 0
├─ Consumer B: 파티션 1
└─ Consumer C: 파티션 2
→ 병렬 처리로 3배 성능 향상!
```
**장점**: 높은 처리량
**단점**: 비용 증가, 전체 순서는 보장 안 됨 (파티션 내 순서만 보장)
## Consumer Group 비교
```
┌─────────────────────────────────────────┐
│ Event Hub: hgzero-eventhub │
│ 파티션 0: [이벤트들...] │
└─────────────────────────────────────────┘
├─────────────────┬─────────────────┐
│ │ │
Consumer Group Consumer Group Consumer Group
"$Default" "analytics" "backup"
│ │ │
Consumer A Consumer B Consumer C
(RAG 처리) (분석 처리) (백업 처리)
│ │ │
각자 독립적으로 동일한 파티션 0의 모든 이벤트 읽음
각자 독립적인 Checkpoint 유지
```
## 파티션과 Consumer 수 관계
### Case 1: Consumer 1개
```
Event Hub: 파티션 3개 (P0, P1, P2)
Consumer Group: $Default
├─ Consumer A: P0, P1, P2 모두 소유
└─ 모든 파티션 처리 (순차적)
```
### Case 2: Consumer 3개 (이상적)
```
Event Hub: 파티션 3개 (P0, P1, P2)
Consumer Group: $Default
├─ Consumer A: P0 소유
├─ Consumer B: P1 소유
└─ Consumer C: P2 소유
→ 병렬 처리로 최대 성능!
```
### Case 3: Consumer 5개 (과잉)
```
Event Hub: 파티션 3개 (P0, P1, P2)
Consumer Group: $Default
├─ Consumer A: P0 소유
├─ Consumer B: P1 소유
├─ Consumer C: P2 소유
├─ Consumer D: 소유한 파티션 없음 (대기)
└─ Consumer E: 소유한 파티션 없음 (대기)
→ D, E는 이벤트를 읽지 못하고 대기만 함
```
## 베스트 프랙티스
| 환경 | Consumer 수 | Consumer Group | 파티션 수 |
|------|-------------|----------------|-----------|
| **프로덕션** | = 파티션 수 | production | 처리량에 맞게 |
| **개발** | 1개 | development | 1~2개 |
| **테스트** | 1개 | test | 1개 |
| **분석** | 1개 | analytics | (공유) |
### 권장 사항
1. **프로덕션 환경**
- Consumer 수 = 파티션 수 (1:1 매핑)
- 고가용성을 위해 각 Consumer를 다른 서버에 배치
- Consumer 수 > 파티션 수로 설정하면 일부는 대기 상태 (Standby)
2. **개발/테스트 환경**
- 별도 Consumer Group 사용
- 파티션 1개로 충분
- 필요시 checkpoint를 초기화하여 처음부터 재처리
3. **모니터링**
- Ownership claim 실패 로그 모니터링
- Lease 갱신 실패 알림 설정
- Checkpoint lag 모니터링
4. **장애 복구**
- Lease timeout 고려 (기본 30초)
- Consumer 장애 시 자동 재분배 (30초 이내)
- Checkpoint로부터 정확한 위치에서 재개
## Consumer 프로세스 관리 명령어
### 프로세스 확인
```bash
# Consumer 프로세스 확인
ps aux | grep "start_consumer.py" | grep -v grep
# 상세 정보 (실행시간, CPU, 메모리)
ps -p <PID> -o pid,etime,%cpu,%mem,cmd
# 네트워크 연결 확인
lsof -i -n | grep <PID>
# 모든 Python 프로세스 확인
ps aux | grep python | grep -v grep
```
### 프로세스 종료
```bash
# 정상 종료 (SIGTERM)
kill <PID>
# 강제 종료 (SIGKILL)
kill -9 <PID>
# 이름으로 종료
pkill -f start_consumer.py
```
## 테스트 이벤트 전송
```python
from azure.eventhub import EventHubProducerClient, EventData
import json
import os
from dotenv import load_dotenv
load_dotenv('rag/.env')
conn_str = os.getenv('EVENTHUB_CONNECTION_STRING')
eventhub_name = os.getenv('EVENTHUB_NAME')
test_event = {
'eventType': 'MINUTES_FINALIZED',
'data': {
'meetingId': 'test-meeting-001',
'title': '테스트 회의',
'minutesId': 'test-minutes-001',
'sections': [
{
'sectionId': 'section-001',
'type': 'DISCUSSION',
'title': '논의 사항',
'content': '테스트 논의 내용입니다.',
'order': 1,
'verified': True
}
]
}
}
producer = EventHubProducerClient.from_connection_string(
conn_str=conn_str,
eventhub_name=eventhub_name
)
event_data_batch = producer.create_batch()
event_data_batch.add(EventData(json.dumps(test_event)))
producer.send_batch(event_data_batch)
print('✅ 테스트 이벤트 전송 완료')
producer.close()
```
## Event Hub 파티션 정보 조회
```python
import asyncio
from azure.eventhub.aio import EventHubConsumerClient
async def check_partitions():
client = EventHubConsumerClient.from_connection_string(
conn_str=EVENTHUB_CONNECTION_STRING,
consumer_group="$Default",
eventhub_name=EVENTHUB_NAME
)
async with client:
partition_ids = await client.get_partition_ids()
print(f"파티션 개수: {len(partition_ids)}")
print(f"파티션 IDs: {partition_ids}")
for partition_id in partition_ids:
props = await client.get_partition_properties(partition_id)
print(f"\n파티션 {partition_id}:")
print(f" 시퀀스 번호: {props['last_enqueued_sequence_number']}")
print(f" 오프셋: {props['last_enqueued_offset']}")
print(f" 마지막 이벤트 시간: {props['last_enqueued_time_utc']}")
asyncio.run(check_partitions())
```
## 정리
### "에러"가 아니라 "설계된 동작"입니다
1. ✅ **정상**: Consumer A가 파티션 소유 → 이벤트 처리
2. ✅ **정상**: Consumer B가 claim 실패 → 대기
3. ✅ **정상**: Consumer A 종료 시 → Consumer B가 자동 인수
### 이 메커니즘의 장점
- 📌 **순서 보장**: 파티션 내 이벤트 순서 유지
- 📌 **정확히 한 번 처리**: 중복 처리 방지
- 📌 **자동 장애 복구**: Consumer 장애 시 자동 재분배
- 📌 **수평 확장**: 파티션 추가로 처리량 증가
### 현재 상황 해결
**권장**: 다른 Consumer Group을 사용하여 테스트하시는 것이 가장 안전하고 효율적입니다!
```yaml
# 개발/테스트용 Consumer Group 설정
eventhub:
consumer_group: "development" # 또는 "test"
```
이렇게 하면:
- 기존 프로덕션 Consumer에 영향 없음
- 독립적으로 모든 이벤트를 처음부터 읽을 수 있음
- 여러 번 테스트 가능

View File

@ -1,595 +0,0 @@
# pgvector Extension PostgreSQL 설치 가이드
## 개요
벡터 유사도 검색을 위한 pgvector extension이 포함된 PostgreSQL 데이터베이스 설치 가이드입니다.
---
## 1. 사전 요구사항
### 1.1 필수 확인 사항
- [ ] Kubernetes 클러스터 접속 가능 여부 확인
- [ ] Helm 3.x 이상 설치 확인
- [ ] kubectl 명령어 사용 가능 여부 확인
- [ ] 기본 StorageClass 존재 여부 확인
### 1.2 버전 정보
| 구성요소 | 버전 | 비고 |
|---------|------|------|
| PostgreSQL | 16.x | pgvector 0.5.0 이상 지원 |
| pgvector Extension | 0.5.1+ | 최신 안정 버전 권장 |
| Helm Chart | bitnami/postgresql | pgvector 포함 커스텀 이미지 |
---
## 2. 설치 방법
### 2.1 Kubernetes 환경 (Helm Chart)
#### 2.1.1 개발 환경 (dev)
**Step 1: Namespace 생성**
```bash
kubectl create namespace vector-dev
```
**Step 2: Helm Repository 추가**
```bash
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
```
**Step 3: values.yaml 작성**
```yaml
# values-pgvector-dev.yaml
global:
postgresql:
auth:
postgresPassword: "dev_password"
username: "vector_user"
password: "dev_vector_password"
database: "vector_db"
image:
registry: docker.io
repository: pgvector/pgvector
tag: "pg16"
pullPolicy: IfNotPresent
primary:
initdb:
scripts:
init-pgvector.sql: |
-- pgvector extension 활성화
CREATE EXTENSION IF NOT EXISTS vector;
-- 설치 확인
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
resources:
limits:
memory: 2Gi
cpu: 1000m
requests:
memory: 1Gi
cpu: 500m
persistence:
enabled: true
size: 10Gi
storageClass: "" # 기본 StorageClass 사용
service:
type: ClusterIP
ports:
postgresql: 5432
metrics:
enabled: true
serviceMonitor:
enabled: false
volumePermissions:
enabled: true
```
**Step 4: Helm 설치 실행**
```bash
helm install pgvector-dev bitnami/postgresql \
--namespace vector-dev \
--values values-pgvector-dev.yaml \
--wait
```
**Step 5: 설치 확인**
```bash
# Pod 상태 확인
kubectl get pods -n vector-dev
# 서비스 확인
kubectl get svc -n vector-dev
# pgvector 설치 확인
kubectl exec -it pgvector-dev-postgresql-0 -n vector-dev -- \
psql -U vector_user -d vector_db -c "SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';"
```
**예상 출력:**
```
extname | extversion
---------+------------
vector | 0.5.1
(1 row)
```
#### 2.1.2 운영 환경 (prod)
**Step 1: Namespace 생성**
```bash
kubectl create namespace vector-prod
```
**Step 2: values.yaml 작성 (고가용성 구성)**
```yaml
# values-pgvector-prod.yaml
global:
postgresql:
auth:
postgresPassword: "CHANGE_ME_PROD_PASSWORD"
username: "vector_user"
password: "CHANGE_ME_VECTOR_PASSWORD"
database: "vector_db"
image:
registry: docker.io
repository: pgvector/pgvector
tag: "pg16"
pullPolicy: IfNotPresent
architecture: replication # 고가용성 구성
primary:
initdb:
scripts:
init-pgvector.sql: |
-- pgvector extension 활성화
CREATE EXTENSION IF NOT EXISTS vector;
-- 성능 최적화 설정
ALTER SYSTEM SET shared_buffers = '2GB';
ALTER SYSTEM SET effective_cache_size = '6GB';
ALTER SYSTEM SET maintenance_work_mem = '512MB';
ALTER SYSTEM SET max_wal_size = '2GB';
-- pgvector 최적화
ALTER SYSTEM SET max_parallel_workers_per_gather = 4;
resources:
limits:
memory: 8Gi
cpu: 4000m
requests:
memory: 4Gi
cpu: 2000m
persistence:
enabled: true
size: 100Gi
storageClass: "" # 기본 StorageClass 사용
podAntiAffinity:
preset: hard # Primary와 Replica 분리 배치
readReplicas:
replicaCount: 2
resources:
limits:
memory: 8Gi
cpu: 4000m
requests:
memory: 4Gi
cpu: 2000m
persistence:
enabled: true
size: 100Gi
backup:
enabled: true
cronjob:
schedule: "0 2 * * *" # 매일 새벽 2시 백업
storage:
size: 50Gi
metrics:
enabled: true
serviceMonitor:
enabled: true
networkPolicy:
enabled: true
allowExternal: false
```
**Step 3: Helm 설치 실행**
```bash
helm install pgvector-prod bitnami/postgresql \
--namespace vector-prod \
--values values-pgvector-prod.yaml \
--wait
```
**Step 4: 설치 확인**
```bash
# 모든 Pod 상태 확인 (Primary + Replicas)
kubectl get pods -n vector-prod
# Replication 상태 확인
kubectl exec -it pgvector-prod-postgresql-0 -n vector-prod -- \
psql -U postgres -c "SELECT * FROM pg_stat_replication;"
```
---
### 2.2 Docker Compose 환경 (로컬 개발)
**docker-compose.yml**
```yaml
version: '3.8'
services:
pgvector:
image: pgvector/pgvector:pg16
container_name: pgvector-local
environment:
POSTGRES_DB: vector_db
POSTGRES_USER: vector_user
POSTGRES_PASSWORD: local_password
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
ports:
- "5432:5432"
volumes:
- pgvector_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
command:
- "postgres"
- "-c"
- "shared_buffers=256MB"
- "-c"
- "max_connections=200"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vector_user -d vector_db"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgvector_data:
driver: local
```
**init-scripts/01-init-pgvector.sql**
```sql
-- pgvector extension 활성화
CREATE EXTENSION IF NOT EXISTS vector;
-- 테스트 테이블 생성 (선택사항)
CREATE TABLE IF NOT EXISTS vector_test (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(384) -- 384차원 벡터 (예시)
);
-- 인덱스 생성 (HNSW - 고성능)
CREATE INDEX ON vector_test
USING hnsw (embedding vector_cosine_ops);
-- 확인 쿼리
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
```
**실행 명령**
```bash
# 시작
docker-compose up -d
# 로그 확인
docker-compose logs -f pgvector
# 접속 테스트
docker exec -it pgvector-local psql -U vector_user -d vector_db
# 종료
docker-compose down
```
---
## 3. 설치 검증
### 3.1 Extension 설치 확인
```sql
-- Extension 버전 확인
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
-- 지원 연산자 확인
SELECT oprname, oprleft::regtype, oprright::regtype
FROM pg_operator
WHERE oprname IN ('<=>', '<->', '<#>');
```
**예상 결과:**
```
oprname | oprleft | oprright
---------+---------+----------
<=> | vector | vector
<-> | vector | vector
<#> | vector | vector
```
### 3.2 벡터 연산 테스트
```sql
-- 테스트 데이터 삽입
CREATE TABLE test_vectors (
id SERIAL PRIMARY KEY,
embedding vector(3)
);
INSERT INTO test_vectors (embedding) VALUES
('[1,2,3]'),
('[4,5,6]'),
('[1,1,1]');
-- 코사인 거리 계산 테스트
SELECT id, embedding, embedding <=> '[1,2,3]' AS cosine_distance
FROM test_vectors
ORDER BY cosine_distance
LIMIT 3;
```
### 3.3 인덱스 성능 테스트
```sql
-- HNSW 인덱스 생성
CREATE INDEX ON test_vectors USING hnsw (embedding vector_cosine_ops);
-- 인덱스 사용 여부 확인
EXPLAIN ANALYZE
SELECT id FROM test_vectors
ORDER BY embedding <=> '[1,2,3]'
LIMIT 10;
```
---
## 4. 연결 정보
### 4.1 Kubernetes 환경
**개발 환경 (cluster 내부)**
```
Host: pgvector-dev-postgresql.vector-dev.svc.cluster.local
Port: 5432
Database: vector_db
Username: vector_user
Password: dev_vector_password
```
**운영 환경 (cluster 내부)**
```
Host: pgvector-prod-postgresql.vector-prod.svc.cluster.local
Port: 5432
Database: vector_db
Username: vector_user
Password: CHANGE_ME_VECTOR_PASSWORD
```
**외부 접속 (Port-Forward)**
```bash
# 개발 환경
kubectl port-forward -n vector-dev svc/pgvector-dev-postgresql 5432:5432
# 운영 환경
kubectl port-forward -n vector-prod svc/pgvector-prod-postgresql 5433:5432
```
### 4.2 Docker Compose 환경
```
Host: localhost
Port: 5432
Database: vector_db
Username: vector_user
Password: local_password
```
---
## 5. Python 연결 예제
### 5.1 필수 라이브러리
```bash
pip install psycopg2-binary pgvector
```
### 5.2 연결 코드
```python
import psycopg2
from pgvector.psycopg2 import register_vector
# 연결
conn = psycopg2.connect(
host="localhost",
port=5432,
database="vector_db",
user="vector_user",
password="local_password"
)
# pgvector 타입 등록
register_vector(conn)
# 벡터 검색 예제
cur = conn.cursor()
cur.execute("""
SELECT id, embedding <=> %s::vector AS distance
FROM test_vectors
ORDER BY distance
LIMIT 5
""", ([1, 2, 3],))
results = cur.fetchall()
for row in results:
print(f"ID: {row[0]}, Distance: {row[1]}")
cur.close()
conn.close()
```
---
## 6. 트러블슈팅
### 6.1 Extension 설치 실패
```sql
-- 에러: extension "vector" is not available
-- 해결: pgvector 이미지 사용 확인
```
**확인 명령:**
```bash
# Pod의 이미지 확인
kubectl describe pod pgvector-dev-postgresql-0 -n vector-dev | grep Image
```
### 6.2 인덱스 생성 실패
```sql
-- 에러: operator class "vector_cosine_ops" does not exist
-- 해결: Extension 재생성
DROP EXTENSION vector CASCADE;
CREATE EXTENSION vector;
```
### 6.3 성능 이슈
```sql
-- 인덱스 통계 업데이트
ANALYZE test_vectors;
-- HNSW 파라미터 조정 (m=16, ef_construction=64)
CREATE INDEX ON test_vectors
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
```
---
## 7. 보안 권장사항
### 7.1 비밀번호 관리
```bash
# Kubernetes Secret 생성
kubectl create secret generic pgvector-credentials \
--from-literal=postgres-password='STRONG_PASSWORD' \
--from-literal=password='STRONG_VECTOR_PASSWORD' \
-n vector-prod
# values.yaml에서 참조
global:
postgresql:
auth:
existingSecret: "pgvector-credentials"
```
### 7.2 네트워크 정책
```yaml
# network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: pgvector-policy
namespace: vector-prod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: postgresql
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: vector-prod
- podSelector:
matchLabels:
app: vector-service
ports:
- protocol: TCP
port: 5432
```
---
## 8. 모니터링
### 8.1 Prometheus Metrics (운영 환경)
```yaml
# ServiceMonitor가 활성화된 경우 자동 수집
metrics:
enabled: true
serviceMonitor:
enabled: true
namespace: monitoring
interval: 30s
```
### 8.2 주요 메트릭
- `pg_up`: PostgreSQL 가용성
- `pg_database_size_bytes`: 데이터베이스 크기
- `pg_stat_database_tup_fetched`: 조회된 행 수
- `pg_stat_database_conflicts`: 복제 충돌 수
---
## 9. 백업 및 복구
### 9.1 수동 백업
```bash
# Kubernetes 환경
kubectl exec -n vector-prod pgvector-prod-postgresql-0 -- \
pg_dump -U vector_user vector_db > backup_$(date +%Y%m%d).sql
# Docker Compose 환경
docker exec pgvector-local pg_dump -U vector_user vector_db > backup.sql
```
### 9.2 복구
```bash
# Kubernetes 환경
cat backup.sql | kubectl exec -i pgvector-prod-postgresql-0 -n vector-prod -- \
psql -U vector_user -d vector_db
# Docker Compose 환경
cat backup.sql | docker exec -i pgvector-local psql -U vector_user -d vector_db
```
---
## 10. 참고 자료
- [pgvector GitHub](https://github.com/pgvector/pgvector)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/16/)
- [Bitnami PostgreSQL Helm Chart](https://github.com/bitnami/charts/tree/main/bitnami/postgresql)
- [pgvector Performance Tips](https://github.com/pgvector/pgvector#performance)
---
## 부록: 차원별 인덱스 권장사항
| 벡터 차원 | 인덱스 타입 | 파라미터 | 비고 |
|----------|-----------|---------|------|
| < 768 | HNSW | m=16, ef_construction=64 | 일반적인 임베딩 |
| 768-1536 | HNSW | m=24, ef_construction=100 | OpenAI ada-002 |
| > 1536 | IVFFlat | lists=100 | 매우 높은 차원 |
**인덱스 선택 가이드:**
- **HNSW**: 검색 속도 우선 (메모리 사용량 높음)
- **IVFFlat**: 메모리 절약 우선 (검색 속도 느림)

View File

@ -3,6 +3,7 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
sse-starlette==1.8.2
# Database
psycopg2-binary==2.9.9

Binary file not shown.

View File

@ -5,6 +5,7 @@ from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Dict, Any
import logging
import asyncio
from pathlib import Path
from ..models.term import (
@ -30,10 +31,12 @@ from ..db.postgres_vector import PostgresVectorDB
from ..db.azure_search import AzureAISearchDB
from ..db.rag_minutes_db import RagMinutesDB
from ..services.claude_service import ClaudeService
from ..services.sse_manager import sse_manager
from ..utils.config import load_config, get_database_url
from ..utils.embedding import EmbeddingGenerator
from ..utils.text_processor import extract_nouns_as_query
from ..utils.redis_cache import RedisCache
from . import term_routes
# 로깅 설정
logging.basicConfig(
@ -58,6 +61,64 @@ app.add_middleware(
allow_headers=["*"],
)
# SSE 라우터 등록
app.include_router(term_routes.router)
# 앱 시작/종료 이벤트 핸들러
@app.on_event("startup")
async def startup_event():
"""앱 시작 시 SSE Manager 및 EventHub Consumer 시작"""
global _eventhub_consumer_task
# SSE Manager 시작
await sse_manager.start()
logger.info("SSE Manager 시작 완료")
# EventHub Consumer를 백그라운드 태스크로 시작
_eventhub_consumer_task = asyncio.create_task(start_eventhub_consumer())
logger.info("EventHub Consumer 백그라운드 태스크 시작 완료")
logger.info("FastAPI 앱 시작 완료")
@app.on_event("shutdown")
async def shutdown_event():
"""앱 종료 시 SSE Manager 및 EventHub Consumer 정리"""
global _eventhub_consumer_task
# EventHub Consumer 태스크 취소
if _eventhub_consumer_task:
_eventhub_consumer_task.cancel()
try:
await _eventhub_consumer_task
except asyncio.CancelledError:
logger.info("EventHub Consumer 태스크 취소됨")
# SSE Manager 종료
await sse_manager.stop()
logger.info("FastAPI 앱 종료 완료")
async def start_eventhub_consumer():
"""EventHub Consumer 시작 함수"""
try:
from ..services.eventhub_consumer import start_consumer
config = get_config()
rag_minutes_db = get_rag_minutes_db()
embedding_gen = get_embedding_gen()
term_db = get_term_db()
logger.info("EventHub Consumer 시작 중...")
await start_consumer(config, rag_minutes_db, embedding_gen, term_db)
except asyncio.CancelledError:
logger.info("EventHub Consumer 종료 요청 수신")
raise
except Exception as e:
logger.error(f"EventHub Consumer 실행 중 에러: {str(e)}", exc_info=True)
# 전역 변수 (의존성 주입용)
_config = None
_term_db = None
@ -66,6 +127,7 @@ _rag_minutes_db = None
_embedding_gen = None
_claude_service = None
_redis_cache = None
_eventhub_consumer_task = None
def get_config():
@ -176,6 +238,36 @@ async def root():
}
@app.get("/health")
async def health_check():
"""헬스 체크 엔드포인트"""
global _eventhub_consumer_task
eventhub_status = "unknown"
if _eventhub_consumer_task:
if _eventhub_consumer_task.done():
try:
_eventhub_consumer_task.result()
eventhub_status = "stopped"
except Exception as e:
eventhub_status = f"error: {str(e)}"
else:
eventhub_status = "running"
else:
eventhub_status = "not_started"
return {
"status": "healthy",
"sse_manager": {
"active_sessions": len(sse_manager.get_active_sessions()),
"sessions": sse_manager.get_active_sessions()
},
"eventhub_consumer": {
"status": eventhub_status
}
}
@app.post("/api/rag/terms/search", response_model=List[TermSearchResult])
async def search_terms(
request: TermSearchRequest,

View File

@ -0,0 +1,93 @@
"""
용어 관련 API 엔드포인트
"""
from fastapi import APIRouter, HTTPException
from sse_starlette.sse import EventSourceResponse
import asyncio
import json
import logging
from ..services.sse_manager import sse_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/rag/terms", tags=["terms"])
@router.get("/stream/{session_id}")
async def stream_terms(session_id: str):
"""
용어 검색 결과 SSE 스트림
Args:
session_id: 회의 세션 ID
Returns:
SSE 스트림
"""
try:
# SSE 연결 등록
queue = sse_manager.register(session_id)
logger.info(f"용어 스트림 시작: {session_id}")
async def event_generator():
"""SSE 이벤트 생성기"""
try:
# 연결 확인 메시지
yield {
"event": "connected",
"data": json.dumps({"session_id": session_id, "status": "connected"})
}
# 메시지 수신 및 전송
while True:
try:
# Timeout을 두어 주기적으로 heartbeat 전송
message = await asyncio.wait_for(queue.get(), timeout=30.0)
yield {
"event": message["event"],
"data": json.dumps(message["data"])
}
except asyncio.TimeoutError:
# Heartbeat 전송
yield {
"event": "heartbeat",
"data": json.dumps({"type": "heartbeat"})
}
except asyncio.CancelledError:
logger.info(f"용어 스트림 취소됨: {session_id}")
except Exception as e:
logger.error(f"이벤트 생성 중 에러: {str(e)}")
finally:
# 연결 정리
sse_manager.unregister(session_id)
logger.info(f"용어 스트림 종료: {session_id}")
return EventSourceResponse(event_generator())
except ValueError as e:
raise HTTPException(status_code=429, detail=str(e))
except Exception as e:
logger.error(f"스트림 시작 실패: {str(e)}")
raise HTTPException(status_code=500, detail="스트림 시작 실패")
@router.get("/stream/{session_id}/status")
async def get_stream_status(session_id: str):
"""
스트림 연결 상태 확인
Args:
session_id: 회의 세션 ID
Returns:
연결 상태
"""
is_connected = sse_manager.is_connected(session_id)
return {
"session_id": session_id,
"connected": is_connected
}

View File

@ -5,6 +5,7 @@ Azure Event Hub Consumer 서비스
import asyncio
import json
import logging
import re
from typing import Dict, Any, Optional, Union, List
from datetime import datetime
@ -112,7 +113,13 @@ class EventHubConsumer:
try:
# 이벤트 데이터 파싱
event_body = event.body_as_str()
event_data = json.loads(event_body)
logger.debug(f"원본 이벤트 데이터 (처음 200자): {event_body[:200]}")
# Java LocalDateTime 배열을 문자열로 변환하여 JSON 파싱 가능하게 변환
converted_body = self._convert_java_datetime_arrays(event_body)
logger.debug(f"변환된 이벤트 데이터 (처음 200자): {converted_body[:200]}")
event_data = json.loads(converted_body)
event_type = event_data.get('eventType', 'unknown')
logger.info(f"이벤트 수신: {event_type}")
@ -132,7 +139,7 @@ class EventHubConsumer:
except json.JSONDecodeError as e:
logger.error(f"이벤트 파싱 실패: {str(e)}")
except Exception as e:
logger.error(f"이벤트 처리 실패: {str(e)}")
logger.error(f"이벤트 처리 실패: {str(e)}", exc_info=True)
async def _on_error(self, partition_context, error):
"""
@ -144,6 +151,46 @@ class EventHubConsumer:
"""
logger.error(f"Event Hub 에러 (Partition {partition_context.partition_id}): {str(error)}")
def _convert_java_datetime_arrays(self, json_str: str) -> str:
"""
JSON 문자열 내의 Java LocalDateTime 배열을 ISO 8601 문자열로 변환
Java의 Jackson이 LocalDateTime을 배열 형식으로 직렬화하는 것을
Python이 파싱 가능한 문자열 형식으로 변환
Args:
json_str: 원본 JSON 문자열
Returns:
변환된 JSON 문자열
Examples:
>>> _convert_java_datetime_arrays('{"timestamp":[2025,10,29,10,25,37,579030000]}')
'{"timestamp":"2025-10-29T10:25:37.579030"}'
"""
# Java LocalDateTime 배열 패턴: [년,월,일,시,분,초,나노초]
# 나노초는 항상 7개 요소로 전송됨
pattern = r'\[(\d{4}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d+)\]'
def replace_datetime(match):
year = int(match.group(1))
month = int(match.group(2))
day = int(match.group(3))
hour = int(match.group(4))
minute = int(match.group(5))
second = int(match.group(6))
nanosecond = int(match.group(7))
# 나노초를 마이크로초로 변환
microsecond = nanosecond // 1000
# ISO 8601 형식 문자열 생성
dt = datetime(year, month, day, hour, minute, second, microsecond)
return f'"{dt.isoformat()}"'
# 모든 datetime 배열을 문자열로 변환
return re.sub(pattern, replace_datetime, json_str)
async def _process_segment_event(self, event_data: Dict[str, Any]):
"""
세그먼트 생성 이벤트 처리 - 용어검색 실행
@ -162,6 +209,9 @@ class EventHubConsumer:
text = event_data.get("text", "")
meeting_id = event_data.get("meetingId")
# 이벤트 데이터 구조 로깅 (디버깅용)
logger.debug(f"이벤트 데이터 키: {list(event_data.keys())}")
if not text:
logger.warning(f"세그먼트 {segment_id}에 텍스트가 없습니다")
return
@ -242,8 +292,50 @@ class EventHubConsumer:
else:
logger.info(f"세그먼트 {segment_id}에서 매칭되는 용어를 찾지 못했습니다")
# 7. 선택적: 검색 결과를 별도 테이블에 저장하거나 Event Hub로 발행
# TODO: 필요시 검색 결과를 저장하거나 downstream 서비스로 전달
# 7. SSE를 통해 결과 전송
# Event Hub 메시지에서 sessionId 추출 (여러 필드 확인)
session_id = event_data.get("sessionId") or event_data.get("session_id") or event_data.get("meetingId") or meeting_id
logger.info(f"SSE 전송 시도: sessionId={session_id}, meetingId={meeting_id}")
if session_id:
from ..services.sse_manager import sse_manager
# 용어 정보를 직렬화 가능한 형태로 변환
terms_data = []
for result in results:
term = result["term"]
terms_data.append({
"term_id": term.term_id,
"term_name": term.term_name,
"definition": term.definition,
"category": term.category,
"synonyms": term.synonyms,
"related_terms": term.related_terms,
"context": term.context,
"relevance_score": result["relevance_score"],
"match_type": result.get("match_type", "unknown")
})
# SSE로 전송
success = await sse_manager.send_to_session(
session_id=session_id,
data={
"segment_id": segment_id,
"meeting_id": meeting_id,
"text": text[:100], # 텍스트 일부만 전송
"terms": terms_data,
"total_count": len(terms_data)
},
event_type="term_result"
)
if success:
logger.info(f"용어 검색 결과를 SSE로 전송 완료: {session_id}")
else:
logger.warning(f"SSE 전송 실패 (세션 미연결): {session_id}")
else:
logger.warning("이벤트 데이터에 sessionId가 없어 SSE 전송을 건너뜁니다")
except Exception as e:
logger.error(f"세그먼트 이벤트 처리 실패: {str(e)}", exc_info=True)

View File

@ -0,0 +1,183 @@
"""
SSE(Server-Sent Events) 연결 관리자
"""
import asyncio
import logging
from typing import Dict, Any, Optional
from datetime import datetime
logger = logging.getLogger(__name__)
class SSEManager:
"""SSE 연결 관리자"""
def __init__(self, max_connections: int = 1000, heartbeat_interval: int = 30):
"""
초기화
Args:
max_connections: 최대 동시 연결
heartbeat_interval: Heartbeat 전송 간격 ()
"""
self._connections: Dict[str, asyncio.Queue] = {}
self._last_activity: Dict[str, datetime] = {}
self.max_connections = max_connections
self.heartbeat_interval = heartbeat_interval
self._cleanup_task: Optional[asyncio.Task] = None
async def start(self):
"""SSE Manager 시작 - 정리 태스크 실행"""
self._cleanup_task = asyncio.create_task(self._cleanup_inactive_connections())
logger.info("SSE Manager 시작됨")
async def stop(self):
"""SSE Manager 중지"""
if self._cleanup_task:
self._cleanup_task.cancel()
self._connections.clear()
self._last_activity.clear()
logger.info("SSE Manager 중지됨")
def register(self, session_id: str) -> asyncio.Queue:
"""
SSE 연결 등록
Args:
session_id: 세션 ID
Returns:
메시지
Raises:
ValueError: 최대 연결 초과
"""
if len(self._connections) >= self.max_connections:
raise ValueError(f"최대 연결 수({self.max_connections})를 초과했습니다")
if session_id in self._connections:
logger.warning(f"세션 {session_id}가 이미 연결되어 있습니다")
return self._connections[session_id]
queue = asyncio.Queue(maxsize=100)
self._connections[session_id] = queue
self._last_activity[session_id] = datetime.now()
logger.info(f"SSE 연결 등록: {session_id} (전체 연결 수: {len(self._connections)})")
return queue
def unregister(self, session_id: str):
"""
SSE 연결 제거
Args:
session_id: 세션 ID
"""
if session_id in self._connections:
del self._connections[session_id]
del self._last_activity[session_id]
logger.info(f"SSE 연결 제거: {session_id} (전체 연결 수: {len(self._connections)})")
async def send_to_session(self, session_id: str, data: Dict[str, Any], event_type: str = "message") -> bool:
"""
특정 세션에 데이터 전송
Args:
session_id: 세션 ID
data: 전송할 데이터
event_type: 이벤트 타입
Returns:
전송 성공 여부
"""
if session_id not in self._connections:
logger.warning(f"세션 {session_id}가 연결되어 있지 않습니다")
return False
try:
message = {
"event": event_type,
"data": data,
"timestamp": datetime.now().isoformat()
}
queue = self._connections[session_id]
# 큐가 가득 차면 오래된 메시지 제거
if queue.full():
try:
queue.get_nowait()
logger.warning(f"세션 {session_id} 큐가 가득 차서 오래된 메시지를 제거했습니다")
except asyncio.QueueEmpty:
pass
await queue.put(message)
self._last_activity[session_id] = datetime.now()
logger.debug(f"메시지 전송 성공: {session_id} (이벤트: {event_type})")
return True
except Exception as e:
logger.error(f"메시지 전송 실패: {session_id}, 에러: {str(e)}")
return False
async def send_heartbeat(self, session_id: str) -> bool:
"""
Heartbeat 전송
Args:
session_id: 세션 ID
Returns:
전송 성공 여부
"""
return await self.send_to_session(
session_id,
{"type": "heartbeat"},
event_type="heartbeat"
)
def is_connected(self, session_id: str) -> bool:
"""
연결 상태 확인
Args:
session_id: 세션 ID
Returns:
연결 여부
"""
return session_id in self._connections
def get_active_sessions(self) -> list:
"""활성 세션 목록 반환"""
return list(self._connections.keys())
async def _cleanup_inactive_connections(self):
"""비활성 연결 정리 (백그라운드 태스크)"""
timeout_minutes = 30
while True:
try:
await asyncio.sleep(60) # 1분마다 확인
now = datetime.now()
inactive_sessions = []
for session_id, last_time in self._last_activity.items():
elapsed = (now - last_time).total_seconds() / 60
if elapsed > timeout_minutes:
inactive_sessions.append(session_id)
for session_id in inactive_sessions:
logger.info(f"비활성 세션 제거: {session_id} ({timeout_minutes}분 초과)")
self.unregister(session_id)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"연결 정리 중 에러: {str(e)}")
# 전역 SSE Manager 인스턴스
sse_manager = SSEManager()

View File

@ -1,47 +0,0 @@
#!/bin/bash
# RAG 서비스 - API 서버와 Event Hub Consumer 동시 실행 스크립트
set -e # 에러 발생 시 스크립트 종료
echo "=========================================="
echo "RAG 서비스 시작"
echo "=========================================="
# 로그 디렉토리 생성
mkdir -p logs
# Event Hub Consumer를 백그라운드로 실행
echo "[1/2] Event Hub Consumer 시작..."
python start_consumer.py > logs/consumer.log 2>&1 &
CONSUMER_PID=$!
echo "Consumer PID: $CONSUMER_PID"
# API 서버 시작 (포그라운드)
echo "[2/2] REST API 서버 시작..."
python -m uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
API_PID=$!
echo "API Server PID: $API_PID"
# PID 파일 저장
echo $CONSUMER_PID > logs/consumer.pid
echo $API_PID > logs/api.pid
echo "=========================================="
echo "RAG 서비스 시작 완료"
echo " - API Server: http://0.0.0.0:8000"
echo " - Consumer PID: $CONSUMER_PID"
echo " - API PID: $API_PID"
echo "=========================================="
# 종료 시그널 처리 (graceful shutdown)
trap "echo 'Shutting down...'; kill $CONSUMER_PID $API_PID; exit 0" SIGTERM SIGINT
# 두 프로세스 모두 실행 중인지 모니터링
while kill -0 $CONSUMER_PID 2>/dev/null && kill -0 $API_PID 2>/dev/null; do
sleep 5
done
# 하나라도 종료되면 모두 종료
echo "One of the processes stopped. Shutting down all..."
kill $CONSUMER_PID $API_PID 2>/dev/null || true
wait

View File

@ -1,180 +0,0 @@
"""
RAG 서비스 통합 실행 스크립트
API 서버와 Event Hub Consumer를 동시에 실행
"""
import asyncio
import logging
import multiprocessing
import signal
import sys
import time
from pathlib import Path
import uvicorn
from src.utils.config import load_config, get_database_url
from src.db.rag_minutes_db import RagMinutesDB
from src.db.postgres_vector import PostgresVectorDB
from src.utils.embedding import EmbeddingGenerator
from src.services.eventhub_consumer import start_consumer
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def run_api_server():
"""
REST API 서버 실행 (별도 프로세스)
"""
try:
logger.info("=" * 50)
logger.info("REST API 서버 시작")
logger.info("=" * 50)
uvicorn.run(
"src.api.main:app",
host="0.0.0.0",
port=8000,
log_level="info",
access_log=True
)
except Exception as e:
logger.error(f"API 서버 실행 실패: {str(e)}")
sys.exit(1)
def run_event_consumer():
"""
Event Hub Consumer 실행 (별도 프로세스)
"""
try:
logger.info("=" * 50)
logger.info("Event Hub Consumer 시작")
logger.info("=" * 50)
# 설정 로드
config_path = Path(__file__).parent / "config.yaml"
config = load_config(str(config_path))
# 데이터베이스 연결
db_url = get_database_url(config)
rag_minutes_db = RagMinutesDB(db_url)
logger.info("RAG Minutes DB 연결 완료")
# 용어집 데이터베이스 연결
term_db = PostgresVectorDB(db_url)
logger.info("용어집 DB 연결 완료")
# Embedding 생성기 초기화
azure_openai = config["azure_openai"]
embedding_gen = EmbeddingGenerator(
api_key=azure_openai["api_key"],
endpoint=azure_openai["endpoint"],
model=azure_openai["embedding_model"],
dimension=azure_openai["embedding_dimension"],
api_version=azure_openai["api_version"]
)
logger.info("Embedding 생성기 초기화 완료")
# Event Hub Consumer 시작
asyncio.run(start_consumer(config, rag_minutes_db, embedding_gen, term_db))
except KeyboardInterrupt:
logger.info("Consumer 종료 신호 수신")
except Exception as e:
logger.error(f"Consumer 실행 실패: {str(e)}")
sys.exit(1)
def main():
"""
메인 함수: 프로세스를 생성하고 관리
"""
logger.info("=" * 60)
logger.info("RAG 서비스 통합 시작")
logger.info(" - REST API 서버: http://0.0.0.0:8000")
logger.info(" - Event Hub Consumer: Background")
logger.info("=" * 60)
# 프로세스 생성
api_process = multiprocessing.Process(
target=run_api_server,
name="API-Server"
)
consumer_process = multiprocessing.Process(
target=run_event_consumer,
name="Event-Consumer"
)
# 종료 시그널 핸들러
def signal_handler(signum, frame):
logger.info("\n종료 신호 수신. 프로세스 종료 중...")
if api_process.is_alive():
logger.info("API 서버 종료 중...")
api_process.terminate()
api_process.join(timeout=5)
if api_process.is_alive():
api_process.kill()
if consumer_process.is_alive():
logger.info("Consumer 종료 중...")
consumer_process.terminate()
consumer_process.join(timeout=5)
if consumer_process.is_alive():
consumer_process.kill()
logger.info("모든 프로세스 종료 완료")
sys.exit(0)
# 시그널 핸들러 등록
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
try:
# 프로세스 시작
api_process.start()
time.sleep(2) # API 서버 시작 대기
consumer_process.start()
time.sleep(2) # Consumer 시작 대기
logger.info("=" * 60)
logger.info("모든 서비스 시작 완료")
logger.info(f" - API Server PID: {api_process.pid}")
logger.info(f" - Consumer PID: {consumer_process.pid}")
logger.info("=" * 60)
# 프로세스 모니터링
while True:
if not api_process.is_alive():
logger.error("API 서버 프로세스 종료됨")
consumer_process.terminate()
break
if not consumer_process.is_alive():
logger.error("Consumer 프로세스 종료됨")
api_process.terminate()
break
time.sleep(5)
# 대기
api_process.join()
consumer_process.join()
except Exception as e:
logger.error(f"서비스 실행 중 에러: {str(e)}")
api_process.terminate()
consumer_process.terminate()
sys.exit(1)
if __name__ == "__main__":
# multiprocessing을 위한 설정
multiprocessing.set_start_method('spawn', force=True)
main()

View File

@ -1,62 +0,0 @@
import asyncio
import logging
from pathlib import Path
from src.utils.config import load_config, get_database_url
from src.db.rag_minutes_db import RagMinutesDB
from src.db.postgres_vector import PostgresVectorDB
from src.utils.embedding import EmbeddingGenerator
from src.services.eventhub_consumer import start_consumer
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
async def main():
"""메인 함수"""
try:
# 설정 로드
config_path = Path(__file__).parent / "config.yaml"
config = load_config(str(config_path))
logger.info(config)
logger.info("설정 로드 완료")
# 데이터베이스 연결
db_url = get_database_url(config)
rag_minutes_db = RagMinutesDB(db_url)
logger.info("RAG Minutes DB 연결 완료")
# 용어집 데이터베이스 연결
term_db = PostgresVectorDB(db_url)
logger.info("용어집 DB 연결 완료")
# Embedding 생성기 초기화
azure_openai = config["azure_openai"]
embedding_gen = EmbeddingGenerator(
api_key=azure_openai["api_key"],
endpoint=azure_openai["endpoint"],
model=azure_openai["embedding_model"],
dimension=azure_openai["embedding_dimension"],
api_version=azure_openai["api_version"]
)
logger.info("Embedding 생성기 초기화 완료")
# Event Hub Consumer 시작
logger.info("Event Hub Consumer 시작...")
await start_consumer(config, rag_minutes_db, embedding_gen, term_db)
except KeyboardInterrupt:
logger.info("프로그램 종료")
except Exception as e:
logger.error(f"에러 발생: {str(e)}")
raise
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,37 +0,0 @@
"""
명사 추출 기능 테스트
"""
import sys
from pathlib import Path
# 프로젝트 루트 경로를 sys.path에 추가
project_root = Path(__file__).parent
sys.path.insert(0, str(project_root))
from src.utils.text_processor import extract_nouns, extract_nouns_as_query
def test_extract_nouns():
"""명사 추출 테스트"""
test_cases = [
"안녕하세요. 오늘은 OFDM 기술 관련하여 회의를 진행하겠습니다.",
"5G 네트워크와 AI 기술을 활용한 자율주행 자동차",
"데이터베이스 설계 및 API 개발",
"클라우드 컴퓨팅 환경에서 마이크로서비스 아키텍처 구현"
]
print("=" * 80)
print("명사 추출 테스트")
print("=" * 80)
for text in test_cases:
print(f"\n원본: {text}")
nouns = extract_nouns(text)
print(f"명사: {nouns}")
query = extract_nouns_as_query(text)
print(f"쿼리: {query}")
print("-" * 80)
if __name__ == "__main__":
test_extract_nouns()

573
rag/test_sse.html Normal file
View File

@ -0,0 +1,573 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE 용어 검색 테스트</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
}
.controls {
padding: 30px;
border-bottom: 2px solid #f0f0f0;
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
.input-group input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
.input-group input:focus {
outline: none;
border-color: #667eea;
}
.button-group {
display: flex;
gap: 10px;
}
button {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 5px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-connect {
background: #667eea;
color: white;
}
.btn-connect:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-disconnect {
background: #f44336;
color: white;
}
.btn-disconnect:hover {
background: #da190b;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(244, 67, 54, 0.4);
}
.btn-clear {
background: #9e9e9e;
color: white;
}
.btn-clear:hover {
background: #757575;
}
.btn-check {
background: #2196f3;
color: white;
}
.btn-check:hover {
background: #0d47a1;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(33, 150, 243, 0.4);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.status {
padding: 20px 30px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
}
.status-item {
display: flex;
align-items: center;
gap: 10px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-indicator.connected {
background: #4caf50;
}
.status-indicator.disconnected {
background: #f44336;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.content {
padding: 30px;
}
.results {
max-height: 500px;
overflow-y: auto;
}
.result-item {
padding: 20px;
margin-bottom: 15px;
border-radius: 8px;
background: #f8f9fa;
border-left: 4px solid #667eea;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.result-time {
color: #666;
font-size: 12px;
}
.result-text {
color: #333;
margin-bottom: 15px;
padding: 10px;
background: white;
border-radius: 5px;
font-style: italic;
}
.terms {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.term-card {
padding: 15px;
background: white;
border-radius: 5px;
border: 1px solid #e0e0e0;
transition: transform 0.2s, box-shadow 0.2s;
}
.term-card:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.term-name {
font-weight: 600;
color: #667eea;
margin-bottom: 5px;
}
.term-definition {
font-size: 13px;
color: #666;
margin-bottom: 8px;
}
.term-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #999;
}
.term-category {
background: #667eea;
color: white;
padding: 2px 8px;
border-radius: 12px;
}
.term-score {
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state svg {
width: 100px;
height: 100px;
margin-bottom: 20px;
opacity: 0.3;
}
.log-item {
padding: 8px 12px;
margin-bottom: 5px;
border-radius: 4px;
font-size: 13px;
font-family: 'Courier New', monospace;
}
.log-connected {
background: #e8f5e9;
color: #2e7d32;
}
.log-term-result {
background: #e3f2fd;
color: #1565c0;
}
.log-heartbeat {
background: #f3e5f5;
color: #7b1fa2;
}
.log-error {
background: #ffebee;
color: #c62828;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔍 SSE 용어 검색 테스트</h1>
<p>실시간 용어 검색 결과를 확인하세요</p>
</div>
<div class="controls">
<div class="input-group">
<label for="serverUrl">RAG 서버 URL</label>
<input type="text" id="serverUrl" value="http://localhost:8000" placeholder="http://localhost:8000">
</div>
<div class="input-group">
<label for="sessionId">세션 ID (Meeting ID)</label>
<input type="text" id="sessionId" value="meeting-124" placeholder="meeting-124">
</div>
<div class="button-group">
<button class="btn-connect" onclick="connect()">🔌 연결</button>
<button class="btn-disconnect" onclick="disconnect()" disabled>🔌 연결 해제</button>
<button class="btn-check" onclick="checkStatus()">🔍 연결 상태 확인</button>
<button class="btn-clear" onclick="clearResults()">🗑️ 결과 지우기</button>
</div>
</div>
<div class="status">
<div class="status-item">
<span class="status-indicator disconnected" id="statusIndicator"></span>
<span id="statusText">연결 안됨</span>
</div>
<div class="status-item">
<span>수신: <strong id="messageCount">0</strong></span>
</div>
</div>
<div class="content">
<h2 style="margin-bottom: 20px;">📊 용어 검색 결과</h2>
<div class="results" id="results">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<p>연결 후 용어 검색 결과가 여기에 표시됩니다</p>
</div>
</div>
<h2 style="margin: 30px 0 20px 0;">📝 로그</h2>
<div class="results" id="logs" style="max-height: 200px;">
<div class="empty-state" style="padding: 30px;">
<p>로그가 여기에 표시됩니다</p>
</div>
</div>
</div>
</div>
<script>
let eventSource = null;
let messageCount = 0;
function connect() {
const serverUrl = document.getElementById('serverUrl').value;
const sessionId = document.getElementById('sessionId').value;
if (!serverUrl || !sessionId) {
alert('서버 URL과 세션 ID를 입력해주세요');
return;
}
const url = `${serverUrl}/api/rag/terms/stream/${sessionId}`;
addLog('연결 시도 중...', 'connected');
eventSource = new EventSource(url);
// 연결 성공
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
updateStatus(true);
addLog(`연결 성공: ${JSON.stringify(data)}`, 'connected');
});
// 용어 검색 결과 수신
eventSource.addEventListener('term_result', (e) => {
const data = JSON.parse(e.data);
messageCount++;
document.getElementById('messageCount').textContent = messageCount;
addResult(data);
addLog(`용어 결과 수신: ${data.total_count}개`, 'term-result');
});
// Heartbeat
eventSource.addEventListener('heartbeat', (e) => {
addLog('Heartbeat 수신', 'heartbeat');
});
// 에러 처리
eventSource.onerror = (error) => {
console.error('SSE 에러:', error);
addLog('연결 에러 발생', 'error');
updateStatus(false);
};
// UI 업데이트
document.querySelector('.btn-connect').disabled = true;
document.querySelector('.btn-disconnect').disabled = false;
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
updateStatus(false);
addLog('연결 해제됨', 'error');
}
document.querySelector('.btn-connect').disabled = false;
document.querySelector('.btn-disconnect').disabled = true;
}
function updateStatus(connected) {
const indicator = document.getElementById('statusIndicator');
const text = document.getElementById('statusText');
if (connected) {
indicator.className = 'status-indicator connected';
text.textContent = '연결됨';
} else {
indicator.className = 'status-indicator disconnected';
text.textContent = '연결 안됨';
}
}
function addResult(data) {
const resultsDiv = document.getElementById('results');
// 빈 상태 제거
const emptyState = resultsDiv.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
const now = new Date().toLocaleTimeString('ko-KR');
let termsHtml = '';
if (data.terms && data.terms.length > 0) {
termsHtml = '<div class="terms">';
data.terms.forEach(term => {
termsHtml += `
<div class="term-card">
<div class="term-name">${term.term_name}</div>
<div class="term-definition">${term.definition}</div>
<div class="term-meta">
<span class="term-category">${term.category}</span>
<span class="term-score">점수: ${term.relevance_score.toFixed(3)}</span>
</div>
</div>
`;
});
termsHtml += '</div>';
} else {
termsHtml = '<p style="color: #999;">매칭되는 용어가 없습니다</p>';
}
resultItem.innerHTML = `
<div class="result-header">
<strong>세그먼트: ${data.segment_id}</strong>
<span class="result-time">${now}</span>
</div>
<div class="result-text">"${data.text}..."</div>
${termsHtml}
`;
resultsDiv.insertBefore(resultItem, resultsDiv.firstChild);
}
function addLog(message, type) {
const logsDiv = document.getElementById('logs');
// 빈 상태 제거
const emptyState = logsDiv.querySelector('.empty-state');
if (emptyState) {
emptyState.remove();
}
const logItem = document.createElement('div');
logItem.className = `log-item log-${type}`;
const now = new Date().toLocaleTimeString('ko-KR');
logItem.textContent = `[${now}] ${message}`;
logsDiv.insertBefore(logItem, logsDiv.firstChild);
// 최대 50개까지만 유지
while (logsDiv.children.length > 50) {
logsDiv.removeChild(logsDiv.lastChild);
}
}
async function checkStatus() {
const serverUrl = document.getElementById('serverUrl').value;
const sessionId = document.getElementById('sessionId').value;
if (!serverUrl || !sessionId) {
alert('서버 URL과 세션 ID를 입력해주세요');
return;
}
addLog('연결 상태 확인 중...', 'connected');
try {
const response = await fetch(`${serverUrl}/api/rag/terms/stream/${sessionId}/status`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.connected) {
addLog(`✅ 연결 상태: 연결됨 (세션 ID: ${data.session_id})`, 'connected');
updateStatus(true);
} else {
addLog(`❌ 연결 상태: 연결 안됨 (세션 ID: ${data.session_id})`, 'error');
updateStatus(false);
}
} catch (error) {
addLog(`❌ 상태 확인 실패: ${error.message}`, 'error');
console.error('상태 확인 에러:', error);
}
}
function clearResults() {
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<p>연결 후 용어 검색 결과가 여기에 표시됩니다</p>
</div>
`;
messageCount = 0;
document.getElementById('messageCount').textContent = '0';
}
// 페이지 이탈 시 연결 해제
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script>
</body>
</html>