mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 07:56:24 +00:00
feat: 실시간 용어설명 조회 기능 추가
This commit is contained in:
parent
a84449e88d
commit
8bb91f646f
@ -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/` 디렉토리에 있습니다.
|
|
||||||
132
rag/README.md
132
rag/README.md
@ -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)
|
|
||||||
@ -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 가이드
|
|
||||||
508
rag/TESTING.md
508
rag/TESTING.md
@ -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 파이프라인 구축
|
|
||||||
- 자동화된 성능 테스트
|
|
||||||
@ -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에 영향 없음
|
|
||||||
- 독립적으로 모든 이벤트를 처음부터 읽을 수 있음
|
|
||||||
- 여러 번 테스트 가능
|
|
||||||
@ -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**: 메모리 절약 우선 (검색 속도 느림)
|
|
||||||
@ -3,6 +3,7 @@ fastapi==0.104.1
|
|||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]==0.24.0
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
|
sse-starlette==1.8.2
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
|||||||
Binary file not shown.
BIN
rag/src/api/__pycache__/term_routes.cpython-311.pyc
Normal file
BIN
rag/src/api/__pycache__/term_routes.cpython-311.pyc
Normal file
Binary file not shown.
@ -5,6 +5,7 @@ from fastapi import FastAPI, HTTPException, Depends
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..models.term import (
|
from ..models.term import (
|
||||||
@ -30,10 +31,12 @@ from ..db.postgres_vector import PostgresVectorDB
|
|||||||
from ..db.azure_search import AzureAISearchDB
|
from ..db.azure_search import AzureAISearchDB
|
||||||
from ..db.rag_minutes_db import RagMinutesDB
|
from ..db.rag_minutes_db import RagMinutesDB
|
||||||
from ..services.claude_service import ClaudeService
|
from ..services.claude_service import ClaudeService
|
||||||
|
from ..services.sse_manager import sse_manager
|
||||||
from ..utils.config import load_config, get_database_url
|
from ..utils.config import load_config, get_database_url
|
||||||
from ..utils.embedding import EmbeddingGenerator
|
from ..utils.embedding import EmbeddingGenerator
|
||||||
from ..utils.text_processor import extract_nouns_as_query
|
from ..utils.text_processor import extract_nouns_as_query
|
||||||
from ..utils.redis_cache import RedisCache
|
from ..utils.redis_cache import RedisCache
|
||||||
|
from . import term_routes
|
||||||
|
|
||||||
# 로깅 설정
|
# 로깅 설정
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -58,6 +61,64 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
_config = None
|
||||||
_term_db = None
|
_term_db = None
|
||||||
@ -66,6 +127,7 @@ _rag_minutes_db = None
|
|||||||
_embedding_gen = None
|
_embedding_gen = None
|
||||||
_claude_service = None
|
_claude_service = None
|
||||||
_redis_cache = None
|
_redis_cache = None
|
||||||
|
_eventhub_consumer_task = None
|
||||||
|
|
||||||
|
|
||||||
def get_config():
|
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])
|
@app.post("/api/rag/terms/search", response_model=List[TermSearchResult])
|
||||||
async def search_terms(
|
async def search_terms(
|
||||||
request: TermSearchRequest,
|
request: TermSearchRequest,
|
||||||
|
|||||||
93
rag/src/api/term_routes.py
Normal file
93
rag/src/api/term_routes.py
Normal 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
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
rag/src/services/__pycache__/sse_manager.cpython-311.pyc
Normal file
BIN
rag/src/services/__pycache__/sse_manager.cpython-311.pyc
Normal file
Binary file not shown.
@ -5,6 +5,7 @@ Azure Event Hub Consumer 서비스
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Dict, Any, Optional, Union, List
|
from typing import Dict, Any, Optional, Union, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@ -112,7 +113,13 @@ class EventHubConsumer:
|
|||||||
try:
|
try:
|
||||||
# 이벤트 데이터 파싱
|
# 이벤트 데이터 파싱
|
||||||
event_body = event.body_as_str()
|
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')
|
event_type = event_data.get('eventType', 'unknown')
|
||||||
logger.info(f"이벤트 수신: {event_type}")
|
logger.info(f"이벤트 수신: {event_type}")
|
||||||
@ -132,7 +139,7 @@ class EventHubConsumer:
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"이벤트 파싱 실패: {str(e)}")
|
logger.error(f"이벤트 파싱 실패: {str(e)}")
|
||||||
except Exception as 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):
|
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)}")
|
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]):
|
async def _process_segment_event(self, event_data: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
세그먼트 생성 이벤트 처리 - 용어검색 실행
|
세그먼트 생성 이벤트 처리 - 용어검색 실행
|
||||||
@ -162,6 +209,9 @@ class EventHubConsumer:
|
|||||||
text = event_data.get("text", "")
|
text = event_data.get("text", "")
|
||||||
meeting_id = event_data.get("meetingId")
|
meeting_id = event_data.get("meetingId")
|
||||||
|
|
||||||
|
# 이벤트 데이터 구조 로깅 (디버깅용)
|
||||||
|
logger.debug(f"이벤트 데이터 키: {list(event_data.keys())}")
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
logger.warning(f"세그먼트 {segment_id}에 텍스트가 없습니다")
|
logger.warning(f"세그먼트 {segment_id}에 텍스트가 없습니다")
|
||||||
return
|
return
|
||||||
@ -242,8 +292,50 @@ class EventHubConsumer:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"세그먼트 {segment_id}에서 매칭되는 용어를 찾지 못했습니다")
|
logger.info(f"세그먼트 {segment_id}에서 매칭되는 용어를 찾지 못했습니다")
|
||||||
|
|
||||||
# 7. 선택적: 검색 결과를 별도 테이블에 저장하거나 Event Hub로 발행
|
# 7. SSE를 통해 결과 전송
|
||||||
# TODO: 필요시 검색 결과를 저장하거나 downstream 서비스로 전달
|
# 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:
|
except Exception as e:
|
||||||
logger.error(f"세그먼트 이벤트 처리 실패: {str(e)}", exc_info=True)
|
logger.error(f"세그먼트 이벤트 처리 실패: {str(e)}", exc_info=True)
|
||||||
|
|||||||
183
rag/src/services/sse_manager.py
Normal file
183
rag/src/services/sse_manager.py
Normal 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()
|
||||||
@ -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
|
|
||||||
@ -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()
|
|
||||||
@ -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())
|
|
||||||
@ -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
573
rag/test_sse.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user