mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 23:06:23 +00:00
- 아키텍처_최적안_결정.md 신규 생성 (ADR-000 ~ ADR-004) - 하이브리드 접근 전략 결정: 단계적 독립 운영 → 조건부 통합 - 용어집: PostgreSQL+pgvector, 관련회의록: Azure AI Search - 구현방안-관련자료.md: AD-000 섹션 업데이트 - 구현방안-용어집.md: ADR-000, ADR-001 업데이트 및 버전 1.1 반영 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1522 lines
55 KiB
Markdown
1522 lines
55 KiB
Markdown
# 회의주제 관련자료 구현 방안
|
|
|
|
**문서 버전**: 1.0
|
|
**작성일**: 2025-10-27
|
|
**작성자**: AI Service 팀
|
|
**관련 유저스토리**: UFR-AI-040 (관련회의록연결)
|
|
|
|
---
|
|
|
|
## 목차
|
|
1. [개요](#개요)
|
|
2. [최종 선정 방안](#최종-선정-방안)
|
|
3. [데이터 수집 및 처리](#데이터-수집-및-처리)
|
|
4. [Claude AI 연동 구조](#claude-ai-연동-구조)
|
|
5. [Architectural Decisions](#architectural-decisions)
|
|
6. [구현 로드맵](#구현-로드맵)
|
|
7. [성능 및 품질 기준](#성능-및-품질-기준)
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
### 목표
|
|
회의 참석자가 이전 회의 내용을 쉽게 참조할 수 있도록, **AI가 자동으로 관련 회의록을 찾아 유사한 내용을 요약**하여 제공합니다.
|
|
|
|
### 핵심 차별화 포인트
|
|
- **단순 키워드 검색 ❌** → **벡터 유사도 + 맥락 기반 추천 ✅**
|
|
- 전체 회의록을 열지 않고도 **유사 내용 요약(3문장)**으로 핵심 파악
|
|
- 같은 폴더 내 회의록 우선 추천
|
|
- Claude 3.5 Sonnet으로 정확한 유사 내용 추출
|
|
|
|
### 용어집과의 차이점
|
|
|
|
| 구분 | 용어집 | 관련회의록 |
|
|
|-----|--------|-----------|
|
|
| **데이터 규모** | 소규모 (수백 건) | 대규모 (수만 건) |
|
|
| **Vector DB** | Shared Azure AI Search | 별도 Azure AI Search 인덱스 |
|
|
| **청크 크기** | 500-1000 tokens | 2000-2500 tokens |
|
|
| **검색 방식** | 키워드 우선 | Vector 우선 + Semantic Ranking |
|
|
| **Claude 역할** | 맥락 설명 생성 | 유사 내용 요약 생성 |
|
|
| **업데이트 주기** | 수동 | 야간 배치 자동 |
|
|
|
|
---
|
|
|
|
## 최종 선정 방안
|
|
|
|
**10회 반복 평가 결과, 아래 방안을 최종 선정했습니다:**
|
|
|
|
### ✅ **Azure AI Search + Semantic Ranking + Claude 3.5 Sonnet**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ 사용자: 회의 종료 또는 회의록 수정 │
|
|
└────────────────┬────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ Step 1: 현재 회의 분석 (AI Service) │
|
|
│ - 회의 제목, 안건, 키워드, 참석자 추출 │
|
|
│ - 벡터 임베딩 생성 (Azure OpenAI Embedding) │
|
|
└────────────────┬────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ Step 2: Hybrid Search (Azure AI Search) │
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|
│ │ • Keyword Search (제목, 안건) │ │
|
|
│ │ • Vector Search (임베딩 유사도) │ │
|
|
│ │ • Folder Filter (같은 폴더 우선, 가중치 +20%) │ │
|
|
│ │ • Semantic Ranking (Top 50 → Top 10) │ │
|
|
│ │ • RRF (Reciprocal Rank Fusion) 통합 │ │
|
|
│ └──────────────────────────────────────────────────┘ │
|
|
│ 결과: Top 5 후보 회의록 │
|
|
└────────────────┬────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ Step 3: 관련도 필터링 (AI Service) │
|
|
│ - 관련도 < 70% 제외 │
|
|
│ - 최대 3개 선정 (관련도 높은 순) │
|
|
└────────────────┬────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ Step 4: 유사 내용 요약 생성 (Claude API) │
|
|
│ ┌──────────────────────────────────────────────────┐ │
|
|
│ │ 각 회의록에 대해: │ │
|
|
│ │ 1. 현재 회의와 과거 회의록 비교 │ │
|
|
│ │ 2. 유사한 안건/결정사항 추출 │ │
|
|
│ │ 3. 3문장으로 요약 (Claude 3.5 Sonnet) │ │
|
|
│ └──────────────────────────────────────────────────┘ │
|
|
└────────────────┬────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌────────────────────────────────────────────────────────┐
|
|
│ Step 5: 캐싱 및 결과 반환 │
|
|
│ - Redis L1: 회의별 추천 결과 (1시간) │
|
|
│ - PostgreSQL L2: 회의별 매핑 테이블 │
|
|
│ - Frontend: Progressive Loading (제목 → 요약) │
|
|
└────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 선정 이유
|
|
|
|
| 평가 기준 | 점수 | 이유 |
|
|
|----------|------|------|
|
|
| **정확도** | 9/10 | Semantic Ranking + RRF로 높은 정확도 |
|
|
| **성능** | 9/10 | 캐싱 + 배치 처리로 빠른 응답 |
|
|
| **비용** | 8/10 | Azure 통합으로 비용 효율적 |
|
|
| **확장성** | 9/10 | 수만 건 문서 처리 가능 |
|
|
| **MVP 적합** | 10/10 | 회의록만 처리, 점진적 확장 |
|
|
| **총점** | **45/50** | |
|
|
|
|
---
|
|
|
|
## 데이터 수집 및 처리
|
|
|
|
### 1. 데이터 소스 및 우선순위
|
|
|
|
#### Phase 1: MVP (회의록만)
|
|
- **이전 회의록**: 같은 폴더 내 과거 회의록
|
|
- Meeting 서비스 DB에서 확정된 회의록 추출
|
|
- 제목, 안건, 상세 요약, 결정사항, 참석자 포함
|
|
|
|
#### Phase 2: 확장 (프로젝트 문서)
|
|
- **프로젝트 회의록**:
|
|
- 요구사항 정의서 (Requirements Document)
|
|
- 설계 문서 (Design Document)
|
|
- 프로젝트 수행 결과서 (Project Report)
|
|
- 분기별 진행 보고서
|
|
|
|
#### Phase 3: 전체 확장 (업무/운영 문서)
|
|
- **업무 관련 회의록**:
|
|
- 업무 매뉴얼 (User Manual)
|
|
- 정책 및 규정 (Policy & Regulations)
|
|
- 표준화 문서 (Standards Document)
|
|
|
|
- **운영 관련 회의록**:
|
|
- 장애 보고서 (Incident Report)
|
|
- 고객 응대 문서 (Customer Support Log)
|
|
- 유지보수 기록 (Maintenance Log)
|
|
|
|
### 2. 데이터 수집 파이프라인
|
|
|
|
```java
|
|
/**
|
|
* 회의록 수집 배치 서비스
|
|
* 매일 새벽 3시 실행
|
|
*/
|
|
@Service
|
|
public class MeetingDocumentCollectionService {
|
|
|
|
@Scheduled(cron = "0 0 3 * * *")
|
|
public void collectDailyDocuments() {
|
|
log.info("회의록 수집 배치 시작");
|
|
|
|
// Step 1: 어제 확정된 회의록 조회
|
|
LocalDate yesterday = LocalDate.now().minusDays(1);
|
|
List<Meeting> confirmedMeetings = meetingRepository
|
|
.findByStatusAndUpdatedDateBetween(
|
|
MeetingStatus.CONFIRMED,
|
|
yesterday.atStartOfDay(),
|
|
yesterday.plusDays(1).atStartOfDay()
|
|
);
|
|
|
|
log.info("수집 대상 회의록 {}건", confirmedMeetings.size());
|
|
|
|
// Step 2: 각 회의록 처리
|
|
for (Meeting meeting : confirmedMeetings) {
|
|
try {
|
|
processDocument(meeting);
|
|
} catch (Exception e) {
|
|
log.error("회의록 처리 실패: {}", meeting.getId(), e);
|
|
// 실패한 문서는 재처리 큐에 추가
|
|
failedQueue.add(meeting.getId());
|
|
}
|
|
}
|
|
|
|
log.info("회의록 수집 배치 완료");
|
|
}
|
|
|
|
private void processDocument(Meeting meeting) {
|
|
// 데이터 추출 → 정제 → 벡터화
|
|
DocumentMetadata metadata = extractMetadata(meeting);
|
|
List<DocumentChunk> chunks = chunkDocument(meeting);
|
|
vectorizeAndIndex(metadata, chunks);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. 데이터 추출 (Extraction)
|
|
|
|
```java
|
|
/**
|
|
* 회의록에서 메타데이터와 본문 추출
|
|
*/
|
|
public DocumentMetadata extractMetadata(Meeting meeting) {
|
|
return DocumentMetadata.builder()
|
|
.documentId(meeting.getId().toString())
|
|
.documentType(DocumentType.MEETING_MINUTES)
|
|
.title(meeting.getTitle())
|
|
.createdDate(meeting.getCreatedAt())
|
|
.folder(meeting.getFolder())
|
|
.participants(meeting.getParticipants().stream()
|
|
.map(User::getName)
|
|
.collect(Collectors.toList()))
|
|
.keywords(extractKeywords(meeting))
|
|
.build();
|
|
}
|
|
|
|
/**
|
|
* 회의록에서 키워드 추출
|
|
*/
|
|
private List<String> extractKeywords(Meeting meeting) {
|
|
List<String> keywords = new ArrayList<>();
|
|
|
|
// 안건에서 명사 추출
|
|
for (Agenda agenda : meeting.getAgendas()) {
|
|
keywords.addAll(nlpService.extractNouns(agenda.getTitle()));
|
|
keywords.addAll(nlpService.extractNouns(agenda.getSummary()));
|
|
}
|
|
|
|
// 중복 제거 및 빈도순 정렬
|
|
return keywords.stream()
|
|
.distinct()
|
|
.limit(20)
|
|
.collect(Collectors.toList());
|
|
}
|
|
```
|
|
|
|
### 4. 데이터 정제 (Refinement)
|
|
|
|
```java
|
|
/**
|
|
* 회의록 데이터 정제
|
|
*/
|
|
public String refineContent(Meeting meeting) {
|
|
StringBuilder refinedContent = new StringBuilder();
|
|
|
|
// 제목
|
|
refinedContent.append("# ").append(meeting.getTitle()).append("\n\n");
|
|
|
|
// 기본 정보
|
|
refinedContent.append("**일시**: ").append(meeting.getMeetingDate()).append("\n");
|
|
refinedContent.append("**참석자**: ")
|
|
.append(meeting.getParticipants().stream()
|
|
.map(User::getName)
|
|
.collect(Collectors.joining(", ")))
|
|
.append("\n\n");
|
|
|
|
// 안건별 내용
|
|
for (Agenda agenda : meeting.getAgendas()) {
|
|
refinedContent.append("## ").append(agenda.getTitle()).append("\n\n");
|
|
|
|
// AI 한줄 요약
|
|
if (agenda.getOneLinerSummary() != null) {
|
|
refinedContent.append("**요약**: ")
|
|
.append(agenda.getOneLinerSummary()).append("\n\n");
|
|
}
|
|
|
|
// 상세 내용
|
|
refinedContent.append(agenda.getDetailedSummary()).append("\n\n");
|
|
|
|
// 결정사항
|
|
if (!agenda.getDecisions().isEmpty()) {
|
|
refinedContent.append("**결정사항**:\n");
|
|
for (String decision : agenda.getDecisions()) {
|
|
refinedContent.append("- ").append(decision).append("\n");
|
|
}
|
|
refinedContent.append("\n");
|
|
}
|
|
}
|
|
|
|
// 불필요한 문자 제거
|
|
String cleaned = refinedContent.toString()
|
|
.replaceAll("\\s+", " ") // 연속 공백 제거
|
|
.replaceAll("\\n{3,}", "\n\n") // 연속 줄바꿈 제거
|
|
.trim();
|
|
|
|
return cleaned;
|
|
}
|
|
```
|
|
|
|
### 5. 청킹 (Chunking)
|
|
|
|
**전략**: **Semantic-based Chunking** (의미 기반 분할)
|
|
|
|
```java
|
|
/**
|
|
* 회의록을 의미 단위로 청킹
|
|
*/
|
|
public List<DocumentChunk> chunkDocument(Meeting meeting) {
|
|
List<DocumentChunk> chunks = new ArrayList<>();
|
|
String refinedContent = refineContent(meeting);
|
|
|
|
// 안건 단위로 청킹 (Primary Strategy)
|
|
int chunkIndex = 0;
|
|
for (Agenda agenda : meeting.getAgendas()) {
|
|
String agendaContent = buildAgendaContent(agenda);
|
|
|
|
// 안건이 너무 크면 추가 분할
|
|
if (TokenCounter.count(agendaContent) > 2500) {
|
|
List<String> subChunks = splitLargeAgenda(agendaContent, 2000);
|
|
for (String subChunk : subChunks) {
|
|
chunks.add(createChunk(meeting, agenda, subChunk, chunkIndex++));
|
|
}
|
|
} else {
|
|
chunks.add(createChunk(meeting, agenda, agendaContent, chunkIndex++));
|
|
}
|
|
}
|
|
|
|
// 전체 요약 청크 추가 (Overview Chunk)
|
|
String overviewChunk = buildOverviewChunk(meeting);
|
|
chunks.add(createChunk(meeting, null, overviewChunk, chunkIndex));
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* 큰 안건을 추가 분할
|
|
*/
|
|
private List<String> splitLargeAgenda(String content, int maxTokens) {
|
|
List<String> chunks = new ArrayList<>();
|
|
|
|
// 문단 단위로 분할
|
|
String[] paragraphs = content.split("\n\n");
|
|
StringBuilder currentChunk = new StringBuilder();
|
|
|
|
for (String paragraph : paragraphs) {
|
|
if (TokenCounter.count(currentChunk.toString() + paragraph) > maxTokens) {
|
|
chunks.add(currentChunk.toString());
|
|
currentChunk = new StringBuilder(paragraph);
|
|
} else {
|
|
currentChunk.append("\n\n").append(paragraph);
|
|
}
|
|
}
|
|
|
|
if (currentChunk.length() > 0) {
|
|
chunks.add(currentChunk.toString());
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* DocumentChunk 생성
|
|
*/
|
|
private DocumentChunk createChunk(Meeting meeting, Agenda agenda,
|
|
String content, int index) {
|
|
return DocumentChunk.builder()
|
|
.documentId(meeting.getId().toString())
|
|
.chunkIndex(index)
|
|
.content(content)
|
|
.agendaId(agenda != null ? agenda.getId().toString() : null)
|
|
.agendaTitle(agenda != null ? agenda.getTitle() : "전체 요약")
|
|
.tokenCount(TokenCounter.count(content))
|
|
.build();
|
|
}
|
|
```
|
|
|
|
### 6. 벡터화 (Vectorization)
|
|
|
|
```java
|
|
/**
|
|
* 청크를 벡터화하고 Azure AI Search에 인덱싱
|
|
*/
|
|
public void vectorizeAndIndex(DocumentMetadata metadata,
|
|
List<DocumentChunk> chunks) {
|
|
|
|
// Step 1: Azure OpenAI Embedding API 호출
|
|
List<EmbeddingRequest> requests = chunks.stream()
|
|
.map(chunk -> new EmbeddingRequest(chunk.getContent()))
|
|
.collect(Collectors.toList());
|
|
|
|
List<float[]> embeddings = azureOpenAIClient.createEmbeddings(
|
|
"text-embedding-ada-002",
|
|
requests
|
|
);
|
|
|
|
// Step 2: Azure AI Search Document 생성
|
|
List<SearchDocument> searchDocuments = new ArrayList<>();
|
|
for (int i = 0; i < chunks.size(); i++) {
|
|
DocumentChunk chunk = chunks.get(i);
|
|
float[] embedding = embeddings.get(i);
|
|
|
|
SearchDocument doc = new SearchDocument();
|
|
doc.put("id", metadata.getDocumentId() + "_chunk_" + i);
|
|
doc.put("documentId", metadata.getDocumentId());
|
|
doc.put("documentType", metadata.getDocumentType().name());
|
|
doc.put("title", metadata.getTitle());
|
|
doc.put("folder", metadata.getFolder());
|
|
doc.put("createdDate", metadata.getCreatedDate());
|
|
doc.put("participants", metadata.getParticipants());
|
|
doc.put("keywords", metadata.getKeywords());
|
|
doc.put("agendaId", chunk.getAgendaId());
|
|
doc.put("agendaTitle", chunk.getAgendaTitle());
|
|
doc.put("chunkIndex", chunk.getChunkIndex());
|
|
doc.put("content", chunk.getContent());
|
|
doc.put("contentVector", embedding);
|
|
doc.put("tokenCount", chunk.getTokenCount());
|
|
|
|
searchDocuments.add(doc);
|
|
}
|
|
|
|
// Step 3: Batch Indexing (50개씩)
|
|
for (int i = 0; i < searchDocuments.size(); i += 50) {
|
|
List<SearchDocument> batch = searchDocuments.subList(
|
|
i,
|
|
Math.min(i + 50, searchDocuments.size())
|
|
);
|
|
|
|
searchIndexClient.uploadDocuments(batch);
|
|
}
|
|
|
|
log.info("문서 벡터화 완료: {} ({}개 청크)",
|
|
metadata.getDocumentId(), chunks.size());
|
|
}
|
|
```
|
|
|
|
### 7. 증분 업데이트 (Incremental Update)
|
|
|
|
```java
|
|
/**
|
|
* 증분 업데이트: 변경된 문서만 재처리
|
|
*/
|
|
@Service
|
|
public class IncrementalUpdateService {
|
|
|
|
public void updateModifiedDocuments() {
|
|
// 마지막 업데이트 이후 수정된 회의록 조회
|
|
LocalDateTime lastUpdate = getLastUpdateTime();
|
|
List<Meeting> modifiedMeetings = meetingRepository
|
|
.findByUpdatedAtAfterAndStatus(lastUpdate, MeetingStatus.CONFIRMED);
|
|
|
|
for (Meeting meeting : modifiedMeetings) {
|
|
// 기존 문서 삭제
|
|
deleteDocumentFromIndex(meeting.getId());
|
|
|
|
// 새로 인덱싱
|
|
processDocument(meeting);
|
|
}
|
|
|
|
updateLastUpdateTime(LocalDateTime.now());
|
|
}
|
|
|
|
private void deleteDocumentFromIndex(UUID meetingId) {
|
|
// Azure AI Search에서 해당 문서의 모든 청크 삭제
|
|
String filter = String.format("documentId eq '%s'", meetingId);
|
|
searchIndexClient.deleteDocumentsByFilter(filter);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Claude AI 연동 구조
|
|
|
|
### 1. 관련 회의록 검색 프로세스
|
|
|
|
```java
|
|
/**
|
|
* 관련 회의록 검색 및 요약 생성
|
|
*/
|
|
@Service
|
|
public class RelatedMeetingService {
|
|
|
|
public List<RelatedMeeting> findRelatedMeetings(UUID currentMeetingId) {
|
|
// Step 1: 캐시 확인
|
|
String cacheKey = "related_meetings:" + currentMeetingId;
|
|
List<RelatedMeeting> cached = redisTemplate.opsForValue().get(cacheKey);
|
|
if (cached != null) {
|
|
return cached;
|
|
}
|
|
|
|
// Step 2: 현재 회의 분석
|
|
Meeting currentMeeting = meetingRepository.findById(currentMeetingId)
|
|
.orElseThrow(() -> new EntityNotFoundException("Meeting not found"));
|
|
|
|
MeetingContext context = analyzeMeeting(currentMeeting);
|
|
|
|
// Step 3: Hybrid Search (Azure AI Search)
|
|
List<SearchResult> searchResults = performHybridSearch(context);
|
|
|
|
// Step 4: 관련도 필터링 (상위 3개, 70% 이상)
|
|
List<SearchResult> topResults = searchResults.stream()
|
|
.filter(r -> r.getScore() >= 0.70)
|
|
.limit(3)
|
|
.collect(Collectors.toList());
|
|
|
|
// Step 5: Claude API로 유사 내용 요약 생성
|
|
List<RelatedMeeting> relatedMeetings = generateSummaries(
|
|
currentMeeting,
|
|
topResults
|
|
);
|
|
|
|
// Step 6: 캐싱 (1시간)
|
|
redisTemplate.opsForValue().set(cacheKey, relatedMeetings,
|
|
Duration.ofHours(1));
|
|
|
|
return relatedMeetings;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. 현재 회의 분석
|
|
|
|
```java
|
|
/**
|
|
* 현재 회의 맥락 분석
|
|
*/
|
|
public MeetingContext analyzeMeeting(Meeting meeting) {
|
|
// 제목, 안건, 키워드 추출
|
|
List<String> keywords = extractKeywords(meeting);
|
|
|
|
// 전체 내용 임베딩 생성
|
|
String fullContent = refineContent(meeting);
|
|
float[] embedding = azureOpenAIClient.createEmbedding(
|
|
"text-embedding-ada-002",
|
|
fullContent
|
|
);
|
|
|
|
return MeetingContext.builder()
|
|
.meetingId(meeting.getId())
|
|
.title(meeting.getTitle())
|
|
.folder(meeting.getFolder())
|
|
.keywords(keywords)
|
|
.participants(meeting.getParticipants())
|
|
.embedding(embedding)
|
|
.build();
|
|
}
|
|
```
|
|
|
|
### 3. Hybrid Search with Semantic Ranking
|
|
|
|
```java
|
|
/**
|
|
* 하이브리드 검색: Keyword + Vector + Semantic Ranking
|
|
*/
|
|
public List<SearchResult> performHybridSearch(MeetingContext context) {
|
|
SearchOptions options = new SearchOptions();
|
|
|
|
// Keyword Search Query
|
|
String keywordQuery = String.join(" ", context.getKeywords());
|
|
options.setSearchText(keywordQuery);
|
|
|
|
// Vector Search
|
|
VectorSearchOptions vectorOptions = new VectorSearchOptions();
|
|
vectorOptions.setVectorQueries(Arrays.asList(
|
|
new VectorQuery(context.getEmbedding())
|
|
.setKNearestNeighborsCount(50)
|
|
.setFields("contentVector")
|
|
));
|
|
options.setVectorSearchOptions(vectorOptions);
|
|
|
|
// Folder Filter (같은 폴더 우선, 가중치 +20%)
|
|
String filter = String.format("folder eq '%s'", context.getFolder());
|
|
options.setFilter(filter);
|
|
|
|
// Semantic Ranking (Top 50 → Top 10)
|
|
options.setSemanticSearchOptions(new SemanticSearchOptions()
|
|
.setSemanticConfigurationName("meeting-semantic-config")
|
|
.setQueryType(QueryType.SEMANTIC)
|
|
.setQueryAnswerCount(10)
|
|
);
|
|
|
|
// Select Fields
|
|
options.setSelect("documentId", "title", "createdDate", "content",
|
|
"agendaTitle", "folder");
|
|
options.setTop(50);
|
|
|
|
// Execute Search
|
|
SearchPagedIterable results = searchIndexClient.search(
|
|
keywordQuery,
|
|
options,
|
|
Context.NONE
|
|
);
|
|
|
|
// RRF (Reciprocal Rank Fusion) 적용
|
|
return applyRRF(results);
|
|
}
|
|
|
|
/**
|
|
* RRF (Reciprocal Rank Fusion) 통합
|
|
*/
|
|
private List<SearchResult> applyRRF(SearchPagedIterable results) {
|
|
Map<String, Double> scores = new HashMap<>();
|
|
int rank = 1;
|
|
|
|
for (SearchResult result : results) {
|
|
String docId = result.getDocument(SearchDocument.class)
|
|
.get("documentId").toString();
|
|
|
|
// RRF Score: 1 / (rank + 60)
|
|
double rrfScore = 1.0 / (rank + 60);
|
|
scores.merge(docId, rrfScore, Double::sum);
|
|
rank++;
|
|
}
|
|
|
|
// 점수 기준 정렬
|
|
return scores.entrySet().stream()
|
|
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
|
|
.limit(10)
|
|
.map(entry -> findSearchResult(results, entry.getKey()))
|
|
.collect(Collectors.toList());
|
|
}
|
|
```
|
|
|
|
### 4. Claude API 요약 생성
|
|
|
|
#### 요청 JSON 구조
|
|
|
|
```json
|
|
{
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"max_tokens": 500,
|
|
"temperature": 0.3,
|
|
"system": "당신은 회의록 분석 전문가입니다. 두 회의록을 비교하여 유사한 내용을 정확하게 추출하고 간결하게 요약합니다.",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "아래 두 회의록을 비교하여 유사한 내용을 정확히 3문장으로 요약해주세요.\n\n## 현재 회의\n제목: {currentTitle}\n날짜: {currentDate}\n안건:\n{currentAgendas}\n\n## 과거 회의\n제목: {pastTitle}\n날짜: {pastDate}\n안건:\n{pastAgendas}\n\n## 요구사항\n1. 두 회의에서 공통적으로 논의된 주제나 결정사항을 찾아주세요\n2. 정확히 3문장으로 요약해주세요 (각 문장은 한 문단)\n3. 구체적인 내용을 포함해주세요 (예: 날짜, 수치, 결정사항)\n4. 과거 회의에서 실제로 다뤄진 내용만 포함해주세요 (환각 금지)"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### 응답 JSON 구조
|
|
|
|
```json
|
|
{
|
|
"id": "msg_01XYZ...",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "두 회의 모두 신제품 개발 일정을 중점적으로 논의했으며, 과거 회의에서는 1차 프로토타입을 11월 15일까지 완성하기로 결정했습니다.\n\n타겟 고객층 설정에서 20-30대 모바일 사용자를 우선 대상으로 하는 전략이 양쪽 회의에서 공통적으로 언급되었습니다.\n\n마케팅 예산 배분은 과거 회의에서 SNS 광고 60%, 인플루언서 마케팅 40%로 결정되었으며, 현재 회의에서도 유사한 비율로 논의되고 있습니다."
|
|
}
|
|
],
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"stop_reason": "end_turn",
|
|
"usage": {
|
|
"input_tokens": 1250,
|
|
"output_tokens": 180
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Java 서비스 구현
|
|
|
|
```java
|
|
/**
|
|
* Claude API를 통한 유사 내용 요약 생성
|
|
*/
|
|
public List<RelatedMeeting> generateSummaries(
|
|
Meeting currentMeeting,
|
|
List<SearchResult> topResults
|
|
) {
|
|
List<RelatedMeeting> relatedMeetings = new ArrayList<>();
|
|
|
|
for (SearchResult result : topResults) {
|
|
try {
|
|
// Step 1: 과거 회의록 조회
|
|
UUID pastMeetingId = UUID.fromString(
|
|
result.getDocument(SearchDocument.class)
|
|
.get("documentId").toString()
|
|
);
|
|
Meeting pastMeeting = meetingRepository.findById(pastMeetingId)
|
|
.orElseThrow();
|
|
|
|
// Step 2: Claude API 요청 생성
|
|
ClaudeRequest request = buildClaudeRequest(
|
|
currentMeeting,
|
|
pastMeeting
|
|
);
|
|
|
|
// Step 3: Claude API 호출
|
|
ClaudeResponse response = claudeClient.sendMessage(request);
|
|
|
|
// Step 4: 요약 추출
|
|
String summary = response.getContent().get(0).getText();
|
|
|
|
// Step 5: RelatedMeeting 객체 생성
|
|
relatedMeetings.add(RelatedMeeting.builder()
|
|
.meetingId(pastMeeting.getId())
|
|
.title(pastMeeting.getTitle())
|
|
.meetingDate(pastMeeting.getMeetingDate())
|
|
.relevanceScore(result.getScore())
|
|
.similarContentSummary(summary)
|
|
.build());
|
|
|
|
} catch (Exception e) {
|
|
log.error("Claude API 요약 생성 실패: {}",
|
|
result.getDocumentId(), e);
|
|
// Fallback: 요약 없이 기본 정보만 제공
|
|
relatedMeetings.add(createFallbackRelatedMeeting(result));
|
|
}
|
|
}
|
|
|
|
return relatedMeetings;
|
|
}
|
|
|
|
/**
|
|
* Claude API 요청 생성
|
|
*/
|
|
private ClaudeRequest buildClaudeRequest(Meeting current, Meeting past) {
|
|
String prompt = String.format("""
|
|
아래 두 회의록을 비교하여 유사한 내용을 정확히 3문장으로 요약해주세요.
|
|
|
|
## 현재 회의
|
|
제목: %s
|
|
날짜: %s
|
|
안건:
|
|
%s
|
|
|
|
## 과거 회의
|
|
제목: %s
|
|
날짜: %s
|
|
안건:
|
|
%s
|
|
|
|
## 요구사항
|
|
1. 두 회의에서 공통적으로 논의된 주제나 결정사항을 찾아주세요
|
|
2. 정확히 3문장으로 요약해주세요 (각 문장은 한 문단)
|
|
3. 구체적인 내용을 포함해주세요 (예: 날짜, 수치, 결정사항)
|
|
4. 과거 회의에서 실제로 다뤄진 내용만 포함해주세요 (환각 금지)
|
|
""",
|
|
current.getTitle(),
|
|
current.getMeetingDate(),
|
|
formatAgendas(current.getAgendas()),
|
|
past.getTitle(),
|
|
past.getMeetingDate(),
|
|
formatAgendas(past.getAgendas())
|
|
);
|
|
|
|
return ClaudeRequest.builder()
|
|
.model("claude-3-5-sonnet-20241022")
|
|
.maxTokens(500)
|
|
.temperature(0.3)
|
|
.system("당신은 회의록 분석 전문가입니다. 두 회의록을 비교하여 유사한 내용을 정확하게 추출하고 간결하게 요약합니다.")
|
|
.messages(List.of(
|
|
new Message("user", prompt)
|
|
))
|
|
.build();
|
|
}
|
|
|
|
/**
|
|
* 안건 포맷팅
|
|
*/
|
|
private String formatAgendas(List<Agenda> agendas) {
|
|
return agendas.stream()
|
|
.map(a -> String.format("- %s: %s",
|
|
a.getTitle(),
|
|
a.getOneLinerSummary()))
|
|
.collect(Collectors.joining("\n"));
|
|
}
|
|
|
|
/**
|
|
* Fallback: Claude API 실패 시
|
|
*/
|
|
private RelatedMeeting createFallbackRelatedMeeting(SearchResult result) {
|
|
SearchDocument doc = result.getDocument(SearchDocument.class);
|
|
|
|
return RelatedMeeting.builder()
|
|
.meetingId(UUID.fromString(doc.get("documentId").toString()))
|
|
.title(doc.get("title").toString())
|
|
.meetingDate(LocalDateTime.parse(doc.get("createdDate").toString()))
|
|
.relevanceScore(result.getScore())
|
|
.similarContentSummary(null) // 요약 없음
|
|
.build();
|
|
}
|
|
```
|
|
|
|
### 6. API 응답 구조
|
|
|
|
#### GET `/api/ai/meetings/{meetingId}/related`
|
|
|
|
**응답**:
|
|
```json
|
|
{
|
|
"meetingId": "550e8400-e29b-41d4-a716-446655440000",
|
|
"relatedMeetings": [
|
|
{
|
|
"meetingId": "660e8400-e29b-41d4-a716-446655440001",
|
|
"title": "Q4 회의록 작성 가이드 배포",
|
|
"meetingDate": "2024-10-01T14:00:00",
|
|
"relevanceScore": 95.5,
|
|
"relevanceLevel": "HIGH",
|
|
"similarContentSummary": "두 회의 모두 회의록 자동 작성 기능 개발을 중점적으로 논의했으며, 과거 회의에서는 AI 정확도 목표를 85% 이상으로 설정했습니다.\n\n요구사항 정의 단계에서 사용자가 수동으로 검증할 수 있는 기능을 포함하기로 결정했으며, 현재 회의에서도 유사한 검증 프로세스를 논의하고 있습니다.\n\n개발 일정은 과거 회의에서 1차 프로토타입을 11월 15일까지 완성하기로 결정했으며, 현재 회의에서도 유사한 일정으로 진행 중입니다.",
|
|
"url": "/meetings/660e8400-e29b-41d4-a716-446655440001"
|
|
},
|
|
{
|
|
"meetingId": "770e8400-e29b-41d4-a716-446655440002",
|
|
"title": "신제품 개발 킥오프",
|
|
"meetingDate": "2024-09-15T10:00:00",
|
|
"relevanceScore": 82.3,
|
|
"relevanceLevel": "MEDIUM",
|
|
"similarContentSummary": "양쪽 회의에서 협업 도구 시장 분석을 수행했으며, 과거 회의에서는 경쟁사 대비 차별화 전략을 수립했습니다.\n\n1분기 신제품 개발 방향성이 논의되었고, 과거 회의에서는 AI 기반 자동화 기능을 핵심 차별화 포인트로 선정했습니다.\n\n타겟 시장은 중소기업 및 스타트업으로 설정했으며, 현재 회의에서도 동일한 시장을 대상으로 하고 있습니다.",
|
|
"url": "/meetings/770e8400-e29b-41d4-a716-446655440002"
|
|
},
|
|
{
|
|
"meetingId": "880e8400-e29b-41d4-a716-446655440003",
|
|
"title": "API 설계 리뷰",
|
|
"meetingDate": "2024-09-28T15:00:00",
|
|
"relevanceScore": 78.1,
|
|
"relevanceLevel": "MEDIUM",
|
|
"similarContentSummary": "두 회의에서 RESTful API 설계 원칙을 논의했으며, 과거 회의에서는 보안 정책으로 JWT 토큰 기반 인증을 채택했습니다.\n\n마이크로서비스 아키텍처 구성이 공통 주제였으며, 과거 회의에서는 API Gateway로 AWS API Gateway를 선택했습니다.\n\n담당자별 역할 분담이 이루어졌고, 과거 회의에서는 백엔드 개발자 2명, 프론트엔드 개발자 2명으로 팀을 구성했습니다.",
|
|
"url": "/meetings/880e8400-e29b-41d4-a716-446655440003"
|
|
}
|
|
],
|
|
"totalCount": 3,
|
|
"generatedAt": "2025-10-27T10:30:00"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Architectural Decisions
|
|
|
|
### AD-000: 통합 플랫폼 전략 (용어집 + 관련회의록)
|
|
|
|
> **⚠️ 업데이트**: 2025-10-28
|
|
>
|
|
> 이 섹션은 초기 계획이었으며, 최종적으로 **하이브리드 접근 전략**으로 결정되었습니다.
|
|
>
|
|
> **최종 결정 문서**: `design/아키텍처_최적안_결정.md` 참조
|
|
|
|
**최종 결정**: 하이브리드 접근 - 단계적 독립 → 선택적 통합
|
|
|
|
**Phase 1-3 아키텍처** (현재 적용):
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ AI Service (Backend) │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
|
│ │ 용어집 기능 │ │ 관련회의록 기능 │ │
|
|
│ │ (Term Glossary) │ │ (Related Meetings) │ │
|
|
│ └──────────┬───────────┘ └──────────┬───────────┘ │
|
|
│ │ │ │
|
|
│ ┌──────────▼───────────┐ ┌──────────▼───────────┐ │
|
|
│ │ PostgreSQL │ │ Azure AI Search │ │
|
|
│ │ + pgvector │ │ (meetings-index) │ │
|
|
│ │ │ │ │ │
|
|
│ │ • 소규모 (수백 건) │ │ • 대규모 (수만 건) │ │
|
|
│ │ • 키워드 우선 │ │ • Vector 우선 │ │
|
|
│ │ • 트랜잭션 보장 │ │ • Semantic Ranking │ │
|
|
│ └──────────────────────┘ └──────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ 공통 컴포넌트 (Shared Components) │ │
|
|
│ ├─────────────────────────────────────────────────────┤ │
|
|
│ │ • Azure OpenAI Embedding (text-embedding-ada-002) │ │
|
|
│ │ • Claude 3.5 Sonnet (맥락 설명 / 요약 생성) │ │
|
|
│ │ • Redis (L1 캐싱, TTL 1-24시간) │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Phase 4 통합 옵션** (조건부):
|
|
|
|
**통합 조건**:
|
|
1. 용어집 데이터 규모 500개 이상
|
|
2. PostgreSQL+pgvector 성능 이슈 (응답 시간 > 500ms)
|
|
3. 관리 복잡도가 비용 절감액($85/월)을 상회
|
|
|
|
**통합시 아키텍처**:
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Azure AI Search (단일 계정) │
|
|
│ ┌──────────────────────┐ ┌────────────────────────────┐ │
|
|
│ │ terms-index │ │ meetings-index │ │
|
|
│ │ (용어집) │ │ (관련회의록) │ │
|
|
│ │ - 수백 건 │ │ - 수만 건 │ │
|
|
│ │ - 256-512 tok │ │ - 2000-2500 tok │ │
|
|
│ │ - Keyword 우선 │ │ - Hybrid + Semantic │ │
|
|
│ └──────────────────────┘ └────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 점수 |
|
|
|-----|------|------|------|
|
|
| **하이브리드 접근** ✅ | • 각 기능별 최적 DB 선택<br>• 초기 비용 40% 절감<br>• 점진적 확장<br>• 리스크 분산 | • 관리 복잡도 증가<br>• 통합시 마이그레이션 필요 | **9/10** |
|
|
| 단일 Azure AI Search (초기) | • 통합 관리<br>• 일관된 검색 엔진 | • 용어집 초기에 과도한 인프라<br>• 초기 비용 높음 ($250/월 추가) | 7/10 |
|
|
| 완전 독립 (Qdrant) | • 완전 독립성<br>• 각 기능 최적화 | • 높은 운영 비용 ($1,400/월)<br>• 관리 복잡도 최고 | 5/10 |
|
|
|
|
**결정 이유**:
|
|
1. **비용 효율성**: Phase 1-2에서 $250/월 절감 ($966 → $410)
|
|
2. **기술적 타당성**: 용어집(정확 매칭) vs 관련회의록(의미 검색)의 요구사항 차이
|
|
3. **운영 복잡도**: PostgreSQL 기존 활용으로 학습 곡선 최소화
|
|
4. **확장성**: 명확한 마이그레이션 경로 보유
|
|
|
|
**공통 컴포넌트**:
|
|
- **Embedding**: Azure OpenAI text-embedding-ada-002 (일관된 벡터 공간)
|
|
- **LLM**: Claude 3.5 Sonnet (동일한 품질 기준)
|
|
- **L1 Cache**: Redis (통합 캐싱 플랫폼)
|
|
|
|
**차별화 전략**:
|
|
- **용어집**: 키워드 우선 + Vector Fallback (정확성 중시)
|
|
- **관련회의록**: Hybrid Search + Semantic Ranking (맥락 중시)
|
|
- **캐싱 L2**: PostgreSQL (메타데이터 + 관계)
|
|
|
|
**비용 분석** (월간):
|
|
|
|
**Phase 3 (독립 운영)**:
|
|
```
|
|
PostgreSQL (용어집): $50
|
|
Azure AI Search (회의록): $250
|
|
Redis: $80 (통합)
|
|
Azure OpenAI Embedding: $50
|
|
Claude API: $516
|
|
Storage: $20
|
|
─────────────────────────────
|
|
총계: $966/월
|
|
```
|
|
|
|
**Phase 4 (통합시)**:
|
|
```
|
|
Azure AI Search (통합): $250
|
|
PostgreSQL (메타만): $50
|
|
Redis: $50
|
|
Azure OpenAI Embedding: $55
|
|
Claude API: $516
|
|
Storage: $40
|
|
─────────────────────────────
|
|
총계: $881/월
|
|
절감액: $85/월 (8.8%)
|
|
```
|
|
|
|
**마이그레이션 비용**: $5,000 (일회성)
|
|
|
|
**참조 문서**:
|
|
- **최종 결정**: `design/아키텍처_최적안_결정.md`
|
|
- **용어집 상세**: `design/구현방안-용어집.md`
|
|
|
|
---
|
|
|
|
### AD-001: Vector Database 선정 - Azure AI Search (meetings-index)
|
|
|
|
**결정**: Azure AI Search의 `meetings-index` 사용 (관련회의록 전용)
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 평가 |
|
|
|-----|------|------|------|
|
|
| **Azure AI Search** ✅ | • Semantic Ranking 내장<br>• Hybrid Search 지원<br>• Azure 생태계 통합<br>• 한글 형태소 분석 우수 | • 비용 높음 (Standard $250/월)<br>• 벡터 차원 제한 (최대 2048) | **9/10** |
|
|
| Pinecone | • 빠른 벡터 검색<br>• Serverless 옵션<br>• 간단한 API | • Keyword Search 약함<br>• 한글 지원 부족<br>• 별도 통합 필요 | 7/10 |
|
|
| Weaviate | • 오픈소스<br>• Hybrid Search 지원<br>• 비용 절감 | • 운영 부담<br>• 성능 튜닝 필요<br>• Azure 통합 약함 | 6/10 |
|
|
| Elasticsearch | • 검색 기능 강력<br>• 한글 형태소 분석<br>• 오픈소스 | • Vector Search 약함<br>• 직접 구축 필요<br>• Semantic Ranking 없음 | 7/10 |
|
|
|
|
**선정 이유**:
|
|
1. **Semantic Ranking**: 키워드+벡터 결과를 의미적으로 재순위화
|
|
2. **Hybrid Search**: Keyword + Vector를 RRF로 통합
|
|
3. **Azure 통합**: Azure OpenAI Embedding과 원활한 연동
|
|
4. **한글 지원**: Lucene Korean Analyzer 기본 제공
|
|
5. **확장성**: 10만 건 이상 문서 처리 가능
|
|
|
|
**비용**: Standard tier $250/월 (10,000 documents 기준)
|
|
|
|
---
|
|
|
|
### AD-002: Embedding Model 선정 - Azure OpenAI text-embedding-ada-002
|
|
|
|
**결정**: Azure OpenAI text-embedding-ada-002 사용
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 평가 |
|
|
|-----|------|------|------|
|
|
| **Azure OpenAI Embedding** ✅ | • 높은 정확도<br>• 1536 dimensions<br>• Azure 통합<br>• 한글 지원 우수 | • 비용 ($0.0001/1K tokens)<br>• API 호출 필요 | **9/10** |
|
|
| OpenAI Embedding (Direct) | • 최신 모델<br>• 높은 정확도 | • Azure 통합 없음<br>• 별도 인증<br>• 비용 동일 | 7/10 |
|
|
| Sentence Transformers | • 무료<br>• 로컬 실행<br>• 빠른 속도 | • 정확도 낮음<br>• 한글 성능 부족<br>• GPU 필요 | 5/10 |
|
|
|
|
**선정 이유**:
|
|
1. **Azure AI Search 최적화**: Azure OpenAI와 네이티브 통합
|
|
2. **한글 성능**: 다국어 지원 우수
|
|
3. **일관성**: 용어집과 동일한 모델 사용 (운영 단순화)
|
|
|
|
**비용**: 월 10,000 문서 기준 약 $30
|
|
|
|
---
|
|
|
|
### AD-003: 청킹 전략 - Semantic-based Chunking
|
|
|
|
**결정**: 안건 단위 의미 기반 청킹 (2000-2500 tokens)
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 평가 |
|
|
|-----|------|------|------|
|
|
| **Semantic-based** ✅ | • 맥락 보존<br>• 안건별 분리<br>• 검색 정확도 높음 | • 청크 크기 불균등<br>• 복잡한 로직 | **9/10** |
|
|
| Fixed-size (1000 tokens) | • 구현 간단<br>• 균등한 크기 | • 맥락 단절<br>• 안건 분리 안됨 | 5/10 |
|
|
| Fixed-size (2000 tokens) | • 큰 맥락<br>• 구현 간단 | • 여전히 맥락 단절<br>• 안건 혼재 | 6/10 |
|
|
| Recursive Chunking | • 계층적 분할<br>• 유연성 | • 복잡도 높음<br>• 오버헤드 | 7/10 |
|
|
|
|
**선정 이유**:
|
|
1. **안건 단위 검색**: 특정 안건과 관련된 과거 논의 찾기
|
|
2. **맥락 보존**: 안건 전체 내용을 하나의 청크로 유지
|
|
3. **검색 정확도**: 의미 단위로 분할하여 정확도 향상
|
|
|
|
**청크 크기**:
|
|
- 기본: 안건 1개 = 1 청크
|
|
- 큰 안건: 2000 tokens 기준 추가 분할
|
|
- 전체 요약: Overview Chunk 별도 생성
|
|
|
|
---
|
|
|
|
### AD-004: LLM 선정 - Claude 3.5 Sonnet
|
|
|
|
**결정**: Claude 3.5 Sonnet 사용 (유사 내용 요약 생성)
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 평가 |
|
|
|-----|------|------|------|
|
|
| **Claude 3.5 Sonnet** ✅ | • 높은 정확도<br>• 환각 낮음<br>• 긴 컨텍스트 (200K)<br>• 한글 성능 우수 | • 비용 높음 ($3/MTok input)<br>• API 레이턴시 | **10/10** |
|
|
| GPT-4 Turbo | • 높은 정확도<br>• Azure 통합<br>• 빠른 속도 | • 환각 비율 높음<br>• 비용 높음 ($10/MTok) | 8/10 |
|
|
| GPT-3.5 Turbo | • 빠른 속도<br>• 저렴 ($0.5/MTok)<br>• Azure 통합 | • 정확도 낮음<br>• 한글 성능 부족<br>• 요약 품질 낮음 | 5/10 |
|
|
| LLaMA 3 (Self-hosted) | • 무료<br>• 데이터 프라이버시 | • 정확도 낮음<br>• GPU 필요<br>• 운영 부담 | 4/10 |
|
|
|
|
**선정 이유**:
|
|
1. **낮은 환각율**: 과거 회의록에서 실제 내용만 추출 (정확성 중요)
|
|
2. **긴 컨텍스트**: 두 회의록 전체를 한 번에 비교 가능
|
|
3. **한글 성능**: 한글 요약 품질 우수
|
|
4. **일관성**: 용어집과 동일한 모델 사용
|
|
|
|
**비용**: 월 5만 건 요약 기준 약 $150-200
|
|
|
|
---
|
|
|
|
### AD-005: 캐싱 전략 - L1 (Redis) + L2 (PostgreSQL)
|
|
|
|
**결정**: 2단계 캐싱 (L1: Redis, L2: PostgreSQL)
|
|
|
|
**대안 비교**:
|
|
|
|
| 대안 | 장점 | 단점 | 평가 |
|
|
|-----|------|------|------|
|
|
| **L1 + L2 캐싱** ✅ | • 빠른 응답<br>• 비용 절감<br>• 부하 분산 | • 캐시 무효화 복잡<br>• 저장 공간 필요 | **9/10** |
|
|
| Redis만 | • 단순함<br>• 빠름 | • 휘발성<br>• 재시작 시 손실 | 6/10 |
|
|
| PostgreSQL만 | • 영구 저장<br>• 트랜잭션 | • 느림<br>• 부하 증가 | 5/10 |
|
|
| 캐싱 없음 | • 구현 단순<br>• 항상 최신 | • 높은 비용<br>• 느린 응답 | 3/10 |
|
|
|
|
**캐싱 전략**:
|
|
|
|
**L1 (Redis)**:
|
|
- 키: `related_meetings:{meetingId}`
|
|
- TTL: 1시간
|
|
- 값: RelatedMeeting 객체 리스트 (JSON)
|
|
- 용도: 실시간 조회 성능
|
|
|
|
**L2 (PostgreSQL)**:
|
|
- 테이블: `meeting_relationships`
|
|
- 영구 저장
|
|
- 용도: Redis 캐시 미스 시 fallback, 통계 분석
|
|
|
|
```sql
|
|
CREATE TABLE meeting_relationships (
|
|
id UUID PRIMARY KEY,
|
|
current_meeting_id UUID NOT NULL,
|
|
related_meeting_id UUID NOT NULL,
|
|
relevance_score DECIMAL(5,2) NOT NULL,
|
|
similar_content_summary TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (current_meeting_id, related_meeting_id)
|
|
);
|
|
|
|
CREATE INDEX idx_current_meeting
|
|
ON meeting_relationships(current_meeting_id);
|
|
```
|
|
|
|
**선정 이유**:
|
|
1. **비용 절감**: Claude API 호출 70% 감소 (캐시 히트율 70% 가정)
|
|
2. **응답 속도**: Redis에서 < 10ms 응답
|
|
3. **데이터 영속성**: PostgreSQL에 영구 저장
|
|
4. **통계 분석**: 어떤 회의록이 자주 연결되는지 분석 가능
|
|
|
|
---
|
|
|
|
## 구현 로드맵
|
|
|
|
### Phase 1: MVP (회의록 전용) - 3주
|
|
|
|
#### Week 1: 인프라 및 데이터 파이프라인
|
|
- **Task 1.1**: Azure AI Search 인덱스 생성 및 스키마 설계
|
|
- 회의록 전용 인덱스 생성
|
|
- Semantic Configuration 설정
|
|
- Vector Search 활성화
|
|
|
|
- **Task 1.2**: 데이터 수집 배치 서비스 구현
|
|
- MeetingDocumentCollectionService 개발
|
|
- 매일 새벽 3시 자동 실행 (Cron)
|
|
- 실패 재처리 로직
|
|
|
|
- **Task 1.3**: 데이터 추출 및 정제 로직 구현
|
|
- extractMetadata() 함수
|
|
- refineContent() 함수
|
|
- 키워드 추출 (NLP)
|
|
|
|
#### Week 2: 검색 및 Claude 연동
|
|
- **Task 2.1**: Semantic-based Chunking 구현
|
|
- 안건 단위 청킹
|
|
- 큰 안건 분할 로직
|
|
- Overview Chunk 생성
|
|
|
|
- **Task 2.2**: 벡터화 및 인덱싱
|
|
- Azure OpenAI Embedding API 연동
|
|
- Batch Indexing (50개씩)
|
|
- 증분 업데이트 로직
|
|
|
|
- **Task 2.3**: Hybrid Search 구현
|
|
- Keyword + Vector Search
|
|
- Folder Filter (가중치 +20%)
|
|
- Semantic Ranking + RRF
|
|
|
|
- **Task 2.4**: Claude API 연동
|
|
- ClaudeClient 구현
|
|
- 유사 내용 요약 생성
|
|
- 에러 핸들링 및 Fallback
|
|
|
|
#### Week 3: 캐싱 및 API 개발
|
|
- **Task 3.1**: L1 캐싱 (Redis)
|
|
- RelatedMeeting 캐싱 로직
|
|
- TTL 1시간 설정
|
|
|
|
- **Task 3.2**: L2 캐싱 (PostgreSQL)
|
|
- meeting_relationships 테이블 생성
|
|
- 영구 저장 로직
|
|
|
|
- **Task 3.3**: REST API 개발
|
|
- `GET /api/ai/meetings/{meetingId}/related`
|
|
- Progressive Loading 지원
|
|
|
|
- **Task 3.4**: 테스트 및 튜닝
|
|
- 100개 회의록 샘플 테스트
|
|
- Precision@3 측정 (목표 80%)
|
|
- 성능 튜닝
|
|
|
|
### Phase 2: 확장 (프로젝트 문서) - 2주
|
|
|
|
#### Week 4: 프로젝트 문서 수집
|
|
- **Task 4.1**: 문서 저장소 연동
|
|
- SharePoint/Confluence API 연동
|
|
- 문서 타입 분류 (요구사항/설계/수행결과)
|
|
|
|
- **Task 4.2**: 멀티포맷 파싱
|
|
- PDF 파서
|
|
- DOCX 파서
|
|
- HWP 파서 (한글 문서)
|
|
|
|
- **Task 4.3**: 메타데이터 확장
|
|
- 프로젝트 ID
|
|
- 문서 버전
|
|
- 작성자
|
|
|
|
#### Week 5: 통합 및 최적화
|
|
- **Task 5.1**: 통합 검색 로직
|
|
- 회의록 + 프로젝트 문서 통합 검색
|
|
- 문서 타입별 가중치 적용
|
|
|
|
- **Task 5.2**: 성능 최적화
|
|
- 인덱스 튜닝
|
|
- 쿼리 최적화
|
|
|
|
- **Task 5.3**: 모니터링 대시보드
|
|
- Grafana 대시보드 구축
|
|
- 검색 품질 지표 추적
|
|
|
|
### Phase 3: 전체 확장 (업무/운영 문서) - 2주
|
|
|
|
#### Week 6-7: 업무/운영 문서 추가
|
|
- **Task 6.1**: 업무 문서 수집
|
|
- 업무 매뉴얼
|
|
- 정책 및 규정
|
|
- 표준화 문서
|
|
|
|
- **Task 6.2**: 운영 문서 수집
|
|
- 장애 보고서
|
|
- 고객 응대 문서
|
|
- 유지보수 기록
|
|
|
|
- **Task 6.3**: 보안 강화
|
|
- 문서 접근 권한 필터링
|
|
- 민감 정보 마스킹
|
|
|
|
- **Task 6.4**: 최종 테스트
|
|
- 1000개 문서 대상 성능 테스트
|
|
- 사용자 피드백 수집
|
|
- 품질 개선
|
|
|
|
---
|
|
|
|
## 성능 및 품질 기준
|
|
|
|
### 1. 성능 SLA
|
|
|
|
| 항목 | 목표 | 측정 방법 |
|
|
|-----|------|----------|
|
|
| **검색 응답 시간** | P50 < 1.5초<br>P95 < 3초<br>P99 < 5초 | Prometheus + Grafana |
|
|
| **Claude 요약 생성** | 평균 1.5초/건<br>최대 3초/건 | API 레이턴시 모니터링 |
|
|
| **캐시 히트율** | > 70% | Redis 통계 |
|
|
| **배치 처리 시간** | 1000건 < 30분 | Batch Job 로그 |
|
|
| **동시 처리** | 50명 동시 조회 가능 | 부하 테스트 |
|
|
|
|
### 2. 품질 SLA
|
|
|
|
| 항목 | 목표 | 측정 방법 |
|
|
|-----|------|----------|
|
|
| **Precision@3** | > 80% | 수동 라벨링 100건 |
|
|
| **요약 정확도** | 환각 < 5% | 수동 검증 50건 |
|
|
| **관련도 정확성** | 70% 이상 = 실제 관련<br>95% 이상 = 매우 관련 | 사용자 클릭률 분석 |
|
|
| **다운타임** | < 0.1% (연 8.7시간) | Uptime 모니터링 |
|
|
|
|
### 3. 비용 추정 (월간)
|
|
|
|
| 항목 | 수량 | 단가 | 월 비용 |
|
|
|-----|------|------|--------|
|
|
| **Azure AI Search** | Standard tier | - | $250 |
|
|
| **Azure OpenAI Embedding** | 10,000 documents<br>25M tokens | $0.0001/1K | $25 |
|
|
| **Claude API** | 50,000 summaries<br>3M input tokens<br>0.5M output tokens | $3/MTok input<br>$15/MTok output | $9 + $7.5 = $16.5 |
|
|
| **Redis** | Enterprise tier | - | $50 |
|
|
| **Storage** | 100GB | $0.10/GB | $10 |
|
|
| **총계** | | | **$351.5** |
|
|
|
|
**비용 절감 전략**:
|
|
- 캐싱으로 Claude API 호출 70% 감소 → $351.5 → **$246/월**
|
|
- MVP (회의록만): $200/월
|
|
|
|
### 4. 모니터링 지표
|
|
|
|
#### 검색 성능
|
|
```
|
|
# Prometheus Metrics
|
|
|
|
# 검색 응답 시간
|
|
hybrid_search_duration_seconds{quantile="0.5"} 0.8
|
|
hybrid_search_duration_seconds{quantile="0.95"} 1.5
|
|
hybrid_search_duration_seconds{quantile="0.99"} 2.5
|
|
|
|
# Claude API 응답 시간
|
|
claude_summarization_duration_seconds{quantile="0.5"} 1.2
|
|
claude_summarization_duration_seconds{quantile="0.95"} 2.3
|
|
|
|
# 캐시 히트율
|
|
related_meetings_cache_hit_ratio 0.72
|
|
|
|
# 배치 처리
|
|
document_batch_processed_total 1000
|
|
document_batch_failed_total 5
|
|
```
|
|
|
|
#### Grafana 대시보드
|
|
|
|
**Panel 1: 검색 성능**
|
|
- 응답 시간 분포 (P50, P95, P99)
|
|
- 캐시 히트율 추이
|
|
- Claude API 호출 횟수
|
|
|
|
**Panel 2: 품질 지표**
|
|
- Precision@3 트렌드
|
|
- 사용자 클릭률 (관련도별)
|
|
- 환각 발생률
|
|
|
|
**Panel 3: 비용 모니터링**
|
|
- Azure AI Search 쿼리 수
|
|
- Claude API 토큰 사용량
|
|
- 월간 예상 비용
|
|
|
|
**Panel 4: 데이터 현황**
|
|
- 총 인덱싱된 문서 수
|
|
- 문서 타입별 분포
|
|
- 일별 신규 문서 수
|
|
|
|
### 5. 테스트 계획
|
|
|
|
#### 단위 테스트
|
|
- DocumentChunking 로직
|
|
- MetadataExtraction 로직
|
|
- ClaudeRequestBuilder 로직
|
|
|
|
#### 통합 테스트
|
|
- Azure AI Search 연동
|
|
- Claude API 연동
|
|
- Redis 캐싱
|
|
|
|
#### 성능 테스트
|
|
- 동시 50명 조회 (목표: P95 < 3초)
|
|
- 1000건 배치 처리 (목표: < 30분)
|
|
- 캐시 히트율 측정 (목표: > 70%)
|
|
|
|
#### 품질 테스트
|
|
- 수동 라벨링 100건 (Precision@3)
|
|
- 요약 정확도 검증 50건 (환각률 < 5%)
|
|
- Edge Case 테스트 (관련 회의록 없음, 권한 없음)
|
|
|
|
---
|
|
|
|
## 부록
|
|
|
|
### A. Azure AI Search 인덱스 스키마
|
|
|
|
```json
|
|
{
|
|
"name": "meeting-minutes-index",
|
|
"fields": [
|
|
{
|
|
"name": "id",
|
|
"type": "Edm.String",
|
|
"key": true,
|
|
"searchable": false,
|
|
"filterable": false,
|
|
"sortable": false,
|
|
"facetable": false
|
|
},
|
|
{
|
|
"name": "documentId",
|
|
"type": "Edm.String",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": true
|
|
},
|
|
{
|
|
"name": "documentType",
|
|
"type": "Edm.String",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": true
|
|
},
|
|
{
|
|
"name": "title",
|
|
"type": "Edm.String",
|
|
"searchable": true,
|
|
"filterable": false,
|
|
"sortable": false,
|
|
"facetable": false,
|
|
"analyzer": "ko.lucene"
|
|
},
|
|
{
|
|
"name": "folder",
|
|
"type": "Edm.String",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": true
|
|
},
|
|
{
|
|
"name": "createdDate",
|
|
"type": "Edm.DateTimeOffset",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": true,
|
|
"facetable": false
|
|
},
|
|
{
|
|
"name": "participants",
|
|
"type": "Collection(Edm.String)",
|
|
"searchable": true,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": true
|
|
},
|
|
{
|
|
"name": "keywords",
|
|
"type": "Collection(Edm.String)",
|
|
"searchable": true,
|
|
"filterable": false,
|
|
"sortable": false,
|
|
"facetable": true
|
|
},
|
|
{
|
|
"name": "agendaId",
|
|
"type": "Edm.String",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": false
|
|
},
|
|
{
|
|
"name": "agendaTitle",
|
|
"type": "Edm.String",
|
|
"searchable": true,
|
|
"filterable": false,
|
|
"sortable": false,
|
|
"facetable": false,
|
|
"analyzer": "ko.lucene"
|
|
},
|
|
{
|
|
"name": "chunkIndex",
|
|
"type": "Edm.Int32",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": true,
|
|
"facetable": false
|
|
},
|
|
{
|
|
"name": "content",
|
|
"type": "Edm.String",
|
|
"searchable": true,
|
|
"filterable": false,
|
|
"sortable": false,
|
|
"facetable": false,
|
|
"analyzer": "ko.lucene"
|
|
},
|
|
{
|
|
"name": "contentVector",
|
|
"type": "Collection(Edm.Single)",
|
|
"searchable": true,
|
|
"dimensions": 1536,
|
|
"vectorSearchProfile": "meeting-vector-profile"
|
|
},
|
|
{
|
|
"name": "tokenCount",
|
|
"type": "Edm.Int32",
|
|
"searchable": false,
|
|
"filterable": true,
|
|
"sortable": false,
|
|
"facetable": false
|
|
}
|
|
],
|
|
"vectorSearch": {
|
|
"profiles": [
|
|
{
|
|
"name": "meeting-vector-profile",
|
|
"algorithm": "meeting-hnsw",
|
|
"vectorizer": null
|
|
}
|
|
],
|
|
"algorithms": [
|
|
{
|
|
"name": "meeting-hnsw",
|
|
"kind": "hnsw",
|
|
"hnswParameters": {
|
|
"metric": "cosine",
|
|
"m": 4,
|
|
"efConstruction": 400,
|
|
"efSearch": 500
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"semantic": {
|
|
"configurations": [
|
|
{
|
|
"name": "meeting-semantic-config",
|
|
"prioritizedFields": {
|
|
"titleField": {
|
|
"fieldName": "title"
|
|
},
|
|
"prioritizedContentFields": [
|
|
{
|
|
"fieldName": "content"
|
|
}
|
|
],
|
|
"prioritizedKeywordsFields": [
|
|
{
|
|
"fieldName": "keywords"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### B. 프롬프트 템플릿
|
|
|
|
```java
|
|
public class ClaudePromptTemplates {
|
|
|
|
public static final String SUMMARY_SYSTEM_PROMPT = """
|
|
당신은 회의록 분석 전문가입니다.
|
|
두 회의록을 비교하여 유사한 내용을 정확하게 추출하고 간결하게 요약합니다.
|
|
|
|
중요한 원칙:
|
|
1. 과거 회의록에서 실제로 다뤄진 내용만 포함하세요
|
|
2. 환각(Hallucination)을 절대 생성하지 마세요
|
|
3. 구체적인 날짜, 수치, 결정사항을 포함하세요
|
|
4. 정확히 3문장으로 요약하세요
|
|
""";
|
|
|
|
public static final String SUMMARY_USER_PROMPT_TEMPLATE = """
|
|
아래 두 회의록을 비교하여 유사한 내용을 정확히 3문장으로 요약해주세요.
|
|
|
|
## 현재 회의
|
|
제목: %s
|
|
날짜: %s
|
|
안건:
|
|
%s
|
|
|
|
## 과거 회의
|
|
제목: %s
|
|
날짜: %s
|
|
안건:
|
|
%s
|
|
|
|
## 요구사항
|
|
1. 두 회의에서 공통적으로 논의된 주제나 결정사항을 찾아주세요
|
|
2. 정확히 3문장으로 요약해주세요 (각 문장은 한 문단)
|
|
3. 구체적인 내용을 포함해주세요 (예: 날짜, 수치, 결정사항)
|
|
4. 과거 회의에서 실제로 다뤄진 내용만 포함해주세요 (환각 금지)
|
|
""";
|
|
}
|
|
```
|
|
|
|
### C. 참조 문서
|
|
|
|
- **유저스토리**: `design/userstory.md` - UFR-AI-040 (관련회의록연결)
|
|
- **프로토타입**: `design/uiux/prototype/05-회의진행.html` - 관련회의록 탭
|
|
- **AI Service API**: `design/backend/api/ai-service-api.yaml`
|
|
- **용어집 구현방안**: `design/구현방안-용어집.md`
|
|
|
|
---
|
|
|
|
**문서 히스토리**:
|
|
- v1.0 (2025-10-27): 초안 작성 (AI Service 팀)
|