12 KiB
12 KiB
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- Phase 1: 기하학적 추출pid_topology_builder.py- Phase 2: 위상 빌더pid_intelligent_mapper.py- Phase 3: 지능형 매핑mcp-server/pipeline/extractor.py- 동일한 추출기 (MCP 서버용)mcp-server/pipeline/mapper.py- 동일한 매핑기 (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()메서드의 O(n²) 복잡도- 28,000개 엔티티 → 약 400,000,000번의 거리 계산
shapely.geometry.box.distance()호출이 비용 큼
현재 코드 구조:
# 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()메서드에서 비동기 LLM 호출- 각 노드당 1회 이상의 API 호출 필요
- 100개 노드 = 100회 API 호출 (약 30-60초 소요 예상)
현재 코드 구조:
# 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 |
| 문제 | 이중 루프 + shapely 거리 계산 → O(n²) 복잡도 |
| 영향 | 28,000개 엔티티 → 4억 번의 거리 계산 |
| 심각도 | HIGH |
개선 방안 A: 공간 인덱스 사용 (추천)
# 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: 그리드 기반 클러스터링
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 |
| 문제 | 각 노드당 1회 이상의 LLM API 호출 |
| 영향 | 100개 노드 = 100회 API 호출 (약 30-60초) |
| 심각도 | MED |
개선 방안 A: 배치 처리 (Batch Processing)
# 여러 노드를 하나의 프롬프트로 처리
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차 필터링 강화
# 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: 캐싱
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 |
| 문제 | 각 엔티티당 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 |
HIGH | 중간 | 100배 이상 속도 향상 |
| 2 | Phase 3 LLM API 호출 | pid_intelligent_mapper.py:36 |
MED | 낮음 | 5-10배 속도 향상 |
| 3 | Phase 2 공간 쿼리 | pid_topology_builder.py:76 |
MED | 낮음 | 2-3배 속도 향상 |
| 4 | Phase 1 BBox 중복 계산 | pid_geometric_extractor.py:58 |
LOW | 낮음 | 10-20% 속도 향상 |
6. 구체적인 개선 계획
Phase 1: 공간 인덱스 도입 (1-2일)
-
rtree 패키지 설치
pip install rtree libspatialindex -
pid_topology_builder.py 수정
_merge_nodes()메서드를_merge_nodes_spatial()으로 재작성- R-tree를 사용한 공간 쿼리 구현
-
성능 테스트
- 28,000개 엔티티 처리 시간 측정
- 예상: 1-2초 이내 완료
Phase 2: LLM 배치 처리 (1일)
-
pid_intelligent_mapper.py 수정
_resolve_batch()메서드 추가- 10개 노드씩 배치로 처리
-
pid_topology_builder.py 연동
- 매핑 단계에서 배치 처리 사용
Phase 3: 캐싱 (0.5일)
- RapidFuzz 결과 캐싱
@lru_cache데코레이터 사용- 동일한 태그에 대한 중복 검색 방지
7. 현재 코드의 문제점 요약
7.1 pid_topology_builder.py
# 현재 코드 (병목)
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
# 현재 코드 (병목)
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