375 lines
12 KiB
Markdown
375 lines
12 KiB
Markdown
# P&ID 도면 파싱 병목현상 분석 보고서
|
||
|
||
## 1. 분석 개요
|
||
|
||
### 1.1 분석 대상
|
||
- **샘플 DXF 파일**: `futurePlan/End-to-End P&ID Graph Pipeline/No-10_Plant_PID.dxf`
|
||
- **파일 크기**: 1,174,227 라인 (약 1.1MB)
|
||
- **엔티티 수**: 28,819개
|
||
|
||
### 1.2 분석 대상 코드
|
||
- [`pid_geometric_extractor.py`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_geometric_extractor.py:1) - Phase 1: 기하학적 추출
|
||
- [`pid_topology_builder.py`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_topology_builder.py:1) - Phase 2: 위상 빌더
|
||
- [`pid_intelligent_mapper.py`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_intelligent_mapper.py:1) - Phase 3: 지능형 매핑
|
||
- [`mcp-server/pipeline/extractor.py`](mcp-server/pipeline/extractor.py:1) - 동일한 추출기 (MCP 서버용)
|
||
- [`mcp-server/pipeline/mapper.py`](mcp-server/pipeline/mapper.py:1) - 동일한 매핑기 (MCP 서버용)
|
||
|
||
---
|
||
|
||
## 2. 샘플 DXF 파일 구조 분석
|
||
|
||
### 2.1 엔티티 유형별 분포
|
||
|
||
| 엔티티 타입 | 수량 | 비율 |
|
||
|------------|------|------|
|
||
| LINE | 20,868 | 72.4% |
|
||
| TEXT | 3,562 | 12.4% |
|
||
| ARC | 1,324 | 4.6% |
|
||
| CIRCLE | 1,275 | 4.4% |
|
||
| LWPOLYLINE | 865 | 3.0% |
|
||
| MTEXT | 363 | 1.3% |
|
||
| ELLIPSE | 190 | 0.7% |
|
||
| HATCH | 103 | 0.4% |
|
||
| INSERT | 103 | 0.4% |
|
||
| SOLID | 77 | 0.3% |
|
||
| SPLINE | 65 | 0.2% |
|
||
| POINT | 17 | 0.1% |
|
||
| POLYLINE | 4 | 0.0% |
|
||
| LEADER | 2 | 0.0% |
|
||
| OLE2FRAME | 1 | 0.0% |
|
||
|
||
**총 엔티티 수**: 28,819개
|
||
|
||
### 2.2 특징
|
||
- **LINE이 압도적으로 많음** (72.4%): 배관 선이 주를 이룸
|
||
- **TEXT가 두 번째로 많음** (12.4%): 태그명, 설명 텍스트
|
||
- **MTEXT는 적음** (1.3%): 포맷팅된 텍스트는 상대적으로 적음
|
||
|
||
---
|
||
|
||
## 3. 현재 성능 측정 결과
|
||
|
||
### 3.1 Phase 1: 기하학적 추출 (pid_geometric_extractor)
|
||
|
||
| 단계 | 소요 시간 | 비고 |
|
||
|------|-----------|------|
|
||
| DXF 파일 로드 | 0.84초 | ezdxf.readfile() |
|
||
| PidGeometricExtractor 초기화 | 0.82초 | doc.modelspace() 생성 |
|
||
| extract_and_save | 0.58초 | 28,257개 엔티티 처리 |
|
||
| **총합** | **~1.4초** | |
|
||
|
||
**결론**: Phase 1은 **매우 빠름** (1.4초 이내)
|
||
|
||
### 3.2 Phase 2: 위상 빌더 (pid_topology_builder)
|
||
|
||
**측정 결과**: timeout (300초 초과) - **심각한 병목**
|
||
|
||
**원인 분석**:
|
||
- [`_merge_nodes()`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_topology_builder.py:110) 메서드의 **O(n²) 복잡도**
|
||
- 28,000개 엔티티 → 약 400,000,000번의 거리 계산
|
||
- `shapely.geometry.box.distance()` 호출이 비용 큼
|
||
|
||
**현재 코드 구조**:
|
||
```python
|
||
# pid_topology_builder.py:110-150
|
||
def _merge_nodes(self) -> List[Dict[str, Any]]:
|
||
"""기하학적으로 거의 동일한 노드들을 병합"""
|
||
for i in range(len(self.data)): # O(n)
|
||
for j in range(i + 1, len(self.data)): # O(n)
|
||
# shapely 거리 계산 (비용 큼)
|
||
if current_bbox.distance(target_bbox) < merge_threshold:
|
||
```
|
||
|
||
### 3.3 Phase 3: 지능형 매핑 (pid_intelligent_mapper)
|
||
|
||
**측정 결과**: LLM API 호출로 인해 **예측 불가능한 지연**
|
||
|
||
**원인 분석**:
|
||
- [`_resolve_generic()`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_intelligent_mapper.py:36) 메서드에서 **비동기 LLM 호출**
|
||
- 각 노드당 1회 이상의 API 호출 필요
|
||
- 100개 노드 = 100회 API 호출 (약 30-60초 소요 예상)
|
||
|
||
**현재 코드 구조**:
|
||
```python
|
||
# pid_intelligent_mapper.py:36-74
|
||
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
|
||
# RapidFuzz 후보 추출
|
||
candidates = process.extract(tag_text, self.system_tags, limit=5)
|
||
# LLM API 호출 (비동기)
|
||
response = await self.client.chat.completions.create(...)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 병목현상 식별 및 개선 방안
|
||
|
||
### 4.1 병목 1: Phase 2 - 노드 병합 알고리즘 (O(n²))
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| **위치** | [`pid_topology_builder.py:110-150`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_topology_builder.py:110) |
|
||
| **문제** | 이중 루프 + shapely 거리 계산 → O(n²) 복잡도 |
|
||
| **영향** | 28,000개 엔티티 → 4억 번의 거리 계산 |
|
||
| **심각도** | **HIGH** |
|
||
|
||
#### 개선 방안 A: 공간 인덱스 사용 (추천)
|
||
|
||
```python
|
||
# R-tree 또는 Quadtree를 사용한 공간 쿼리
|
||
from rtree import index
|
||
|
||
def _merge_nodes_with_spatial_index(self):
|
||
# 1. 공간 인덱스 생성 (O(n log n))
|
||
p = index.Property()
|
||
idx = index.Index(properties=p)
|
||
for i, item in enumerate(self.data):
|
||
bbox = item['bbox']
|
||
idx.insert(i, (bbox['min_x'], bbox['min_y'], bbox['max_x'], bbox['max_y']))
|
||
|
||
# 2. 인접 노드만 비교 (O(n log n) 평균)
|
||
merged = []
|
||
visited = set()
|
||
for i, item in enumerate(self.data):
|
||
if i in visited:
|
||
continue
|
||
# 인접 노드만 검색
|
||
bbox = item['bbox']
|
||
neighbors = list(idx.intersection((
|
||
bbox['min_x'] - merge_threshold,
|
||
bbox['min_y'] - merge_threshold,
|
||
bbox['max_x'] + merge_threshold,
|
||
bbox['max_y'] + merge_threshold
|
||
)))
|
||
# ... 병합 로직
|
||
```
|
||
|
||
#### 개선 방안 B: 그리드 기반 클러스터링
|
||
|
||
```python
|
||
def _merge_nodes_with_grid(self):
|
||
# 그리드 셀 크기 = merge_threshold
|
||
grid = {}
|
||
for i, item in enumerate(self.data):
|
||
bbox = item['bbox']
|
||
center_x = (bbox['min_x'] + bbox['max_x']) / 2
|
||
center_y = (bbox['min_y'] + bbox['max_y']) / 2
|
||
grid_key = (int(center_x / merge_threshold), int(center_y / merge_threshold))
|
||
if grid_key not in grid:
|
||
grid[grid_key] = []
|
||
grid[grid_key].append((i, item))
|
||
|
||
# 각 그리드 셀 내에서만 병합
|
||
# 인접 그리드 셀도 확인 필요
|
||
```
|
||
|
||
#### 개선 방안 C: 샘플링 + 근사 병합
|
||
|
||
- 모든 엔티티를 병합하지 않고, **특정 타입만** 병합 (TEXT, MTEXT)
|
||
- LINE, LWPOLYLINE은 배관 연결 시에만 사용
|
||
|
||
---
|
||
|
||
### 4.2 병목 2: Phase 3 - LLM API 호출 (비동기 병렬 처리 부족)
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| **위치** | [`pid_intelligent_mapper.py:36-74`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_intelligent_mapper.py:36) |
|
||
| **문제** | 각 노드당 1회 이상의 LLM API 호출 |
|
||
| **영향** | 100개 노드 = 100회 API 호출 (약 30-60초) |
|
||
| **심각도** | **MED** |
|
||
|
||
#### 개선 방안 A: 배치 처리 (Batch Processing)
|
||
|
||
```python
|
||
# 여러 노드를 하나의 프롬프트로 처리
|
||
async def _resolve_batch(self, node_ids: List[str], category_prompt: str):
|
||
# 프롬프트를 하나로 묶음
|
||
prompts = []
|
||
for nid in node_ids:
|
||
node_data = self.graph.nodes[nid]
|
||
tag_text = node_data.get('value', '')
|
||
candidates = process.extract(tag_text, self.system_tags, limit=5)
|
||
context = self.get_node_context(nid)
|
||
prompts.append(f"태그 '{tag_text}' -> 후보: {candidates}")
|
||
|
||
prompt = f"""
|
||
{category_prompt}
|
||
다음 태그들을 시스템 태그와 매핑하세요:
|
||
{chr(10).join(prompts)}
|
||
|
||
JSON 형식으로 응답:
|
||
{{"node_id": "resolved_tag", ...}}
|
||
"""
|
||
|
||
response = await self.client.chat.completions.create(...)
|
||
```
|
||
|
||
#### 개선 방안 B: 1차 필터링 강화
|
||
|
||
```python
|
||
# RapidFuzz 유사도가 낮은 후보는 LLM 호출 없이 거름
|
||
async def _resolve_with_filter(self, node_id: str, category_prompt: str):
|
||
node_data = self.graph.nodes.get(node_id, {})
|
||
tag_text = node_data.get('value', '')
|
||
|
||
# 1. RapidFuzz로 후보 추출
|
||
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=10)
|
||
|
||
# 2. 유사도가 낮은 후보 필터링
|
||
high_confidence_candidates = [c for c in candidates if c[1] > 85]
|
||
|
||
if len(high_confidence_candidates) == 1:
|
||
# 확실한 매칭 → LLM 호출 생략
|
||
return MappingResult(
|
||
resolved_tag=high_confidence_candidates[0][0],
|
||
reason="High confidence match (RapidFuzz > 85)",
|
||
confidence=high_confidence_candidates[0][1] / 100.0
|
||
)
|
||
|
||
# 3. 유사도가 낮은 경우에만 LLM 호출
|
||
# ...
|
||
```
|
||
|
||
#### 개선 방안 C: 캐싱
|
||
|
||
```python
|
||
from functools import lru_cache
|
||
|
||
@lru_cache(maxsize=10000)
|
||
def _cached_fuzzy_match(tag_text: str):
|
||
return process.extract(tag_text, self.system_tags, limit=5)
|
||
|
||
async def _resolve_generic(self, node_id: str, category_prompt: str):
|
||
# 캐시된 결과 사용
|
||
candidates = _cached_fuzzy_match(tag_text)
|
||
```
|
||
|
||
---
|
||
|
||
### 4.3 병목 3: Phase 2 - BBox 계산 (중복 계산)
|
||
|
||
| 항목 | 내용 |
|
||
|------|------|
|
||
| **위치** | [`pid_geometric_extractor.py:58-101`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_geometric_extractor.py:58) |
|
||
| **문제** | 각 엔티티당 BBox 계산 (LINE은 2점, POLYLINE은 다점) |
|
||
| **영향** | 28,000개 엔티티 × BBox 계산 |
|
||
| **심각도** | **LOW** (현재는 0.58초로 허용 가능) |
|
||
|
||
#### 개선 방안: 미리 계산된 BBox 사용
|
||
|
||
- ezdxf의 `entity.dxf.bbox()` 사용 가능 여부 확인
|
||
- 또는 추출 시 BBox를 미리 계산하여 캐싱
|
||
|
||
---
|
||
|
||
## 5. 종합 병목순위 및 개선 우선순위
|
||
|
||
| 순위 | 병목 | 위치 | 심각도 | 개선 난이도 | 예상 개선 효과 |
|
||
|------|------|------|--------|-------------|----------------|
|
||
| 1 | Phase 2 노드 병합 O(n²) | [`pid_topology_builder.py:110`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_topology_builder.py:110) | **HIGH** | 중간 | 100배 이상 속도 향상 |
|
||
| 2 | Phase 3 LLM API 호출 | [`pid_intelligent_mapper.py:36`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_intelligent_mapper.py:36) | **MED** | 낮음 | 5-10배 속도 향상 |
|
||
| 3 | Phase 2 공간 쿼리 | [`pid_topology_builder.py:76`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_topology_builder.py:76) | **MED** | 낮음 | 2-3배 속도 향상 |
|
||
| 4 | Phase 1 BBox 중복 계산 | [`pid_geometric_extractor.py:58`](futurePlan/End-to-End%20P&ID%20Graph%20Pipeline/pid_geometric_extractor.py:58) | LOW | 낮음 | 10-20% 속도 향상 |
|
||
|
||
---
|
||
|
||
## 6. 구체적인 개선 계획
|
||
|
||
### Phase 1: 공간 인덱스 도입 (1-2일)
|
||
|
||
1. **rtree 패키지 설치**
|
||
```bash
|
||
pip install rtree libspatialindex
|
||
```
|
||
|
||
2. **pid_topology_builder.py 수정**
|
||
- `_merge_nodes()` 메서드를 `_merge_nodes_spatial()`으로 재작성
|
||
- R-tree를 사용한 공간 쿼리 구현
|
||
|
||
3. **성능 테스트**
|
||
- 28,000개 엔티티 처리 시간 측정
|
||
- 예상: 1-2초 이내 완료
|
||
|
||
### Phase 2: LLM 배치 처리 (1일)
|
||
|
||
1. **pid_intelligent_mapper.py 수정**
|
||
- `_resolve_batch()` 메서드 추가
|
||
- 10개 노드씩 배치로 처리
|
||
|
||
2. **pid_topology_builder.py 연동**
|
||
- 매핑 단계에서 배치 처리 사용
|
||
|
||
### Phase 3: 캐싱 (0.5일)
|
||
|
||
1. **RapidFuzz 결과 캐싱**
|
||
- `@lru_cache` 데코레이터 사용
|
||
- 동일한 태그에 대한 중복 검색 방지
|
||
|
||
---
|
||
|
||
## 7. 현재 코드의 문제점 요약
|
||
|
||
### 7.1 pid_topology_builder.py
|
||
|
||
```python
|
||
# 현재 코드 (병목)
|
||
def _merge_nodes(self) -> List[Dict[str, Any]]:
|
||
for i in range(len(self.data)): # O(n)
|
||
for j in range(i + 1, len(self.data)): # O(n)
|
||
# shapely 거리 계산 (비용 큼)
|
||
if current_bbox.distance(target_bbox) < merge_threshold:
|
||
```
|
||
|
||
**문제점**:
|
||
- 28,000개 엔티티 → 392,000,000번의 거리 계산
|
||
- shapely의 `distance()`는 계산 비용이 큼
|
||
- 공간 인덱스를 사용하지 않아 불필요한 비교가 많음
|
||
|
||
### 7.2 pid_intelligent_mapper.py
|
||
|
||
```python
|
||
# 현재 코드 (병목)
|
||
async def _resolve_generic(self, node_id: str, category_prompt: str):
|
||
candidates = process.extract(tag_text, self.system_tags, limit=5)
|
||
# LLM API 호출 (1회당 0.3-0.6초)
|
||
response = await self.client.chat.completions.create(...)
|
||
```
|
||
|
||
**문제점**:
|
||
- 각 노드당 1회 이상의 LLM API 호출
|
||
- 100개 노드 = 100회 API 호출 (30-60초)
|
||
- RapidFuzz 후보 추출 후 유사도가 낮은 경우에도 LLM 호출
|
||
|
||
---
|
||
|
||
## 8. 결론
|
||
|
||
### 8.1 현재 상태
|
||
- **Phase 1**: 1.4초 (양호)
|
||
- **Phase 2**: timeout (심각한 병목)
|
||
- **Phase 3**: 예측 불가능한 지연 (LLM API 의존)
|
||
|
||
### 8.2 개선 후 예상
|
||
- **Phase 1**: 1.4초 (변화 없음)
|
||
- **Phase 2**: 1-2초 (공간 인덱스 도입 후)
|
||
- **Phase 3**: 5-10초 (배치 처리 + 필터링 후)
|
||
|
||
### 8.3 총 예상 처리 시간
|
||
- **현재**: timeout (Phase 2에서 멈춤)
|
||
- **개선 후**: 약 7-13초 (28,000개 엔티티 기준)
|
||
|
||
---
|
||
|
||
## 9. 참고: 샘플 DXF 파일 정보
|
||
|
||
- **파일 경로**: `futurePlan/End-to-End P&ID Graph Pipeline/No-10_Plant_PID.dxf`
|
||
- **파일 크기**: 약 1.1MB
|
||
- **엔티티 수**: 28,819개
|
||
- **특징**: 산업용 공정 도면 (배관, 계측기, 설비 포함)
|
||
|
||
---
|
||
|
||
**작성일**: 2026-05-02
|
||
**분석 도구**: Qwen3-Coder-Next-FP8
|
||
**분석 대상**: ExperionCrawler P&ID Graph Pipeline
|