Skip to content

Commit 87ab058

Browse files
SonAIengineclaude
andcommitted
feat: LLM 기반 온톨로지 자동 구축 — 검색 최적화 메타데이터 생성
## LLM Provider (llm_provider.py) - OllamaLLMProvider: /api/generate + format:"json", 기본 모델 qwen3:0.6b - OpenAILLMProvider: /v1/chat/completions + response_format:json_object ## LLM Classifier (classifier_llm.py) - LLM이 kind 분류 + tags + search_keywords + search_scenarios + summary 한 번에 생성 - "나중에 이 지식을 언제 찾게 될지" 예측하여 검색 최적화 메타데이터 설계 - classify_async()로 ClassificationResult 반환, 결과 SHA-256 해시 LRU 캐시 - 에러 시 RuleBasedClassifier fallback - JSON 파싱 3단 fallback (직접 → ```json 블록 → 첫 {...} 추출) ## LLM RelationDetector (relation_detector_llm.py) - InvertedIndex + vector search로 후보 추출 → LLM에게 관계 판단 위임 - EdgeKind 7종 매핑 (related, caused, learned_from, depends_on, produced, contradicts, supersedes) - 후보 0개면 LLM 호출 skip, 에러 시 RuleBasedRelationDetector fallback ## graph.py 통합 - classifier에 classify_async가 있으면 LLM 모드 → tags, properties 자동 설정 - search_keywords + summary를 embedding 텍스트에 포함 → 벡터 검색 정확도 향상 ## 테스트 (Ollama qwen3:0.6b) - 결제 장애 → lesson, API 배포 결정 → decision, API 명세 → artifact 분류 확인 - search_keywords: "환불 가능", "PG사 API 타임아웃" 등 검색 최적화 키워드 자동 생성 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 598b30b commit 87ab058

5 files changed

Lines changed: 747 additions & 4 deletions

File tree

src/synaptic/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@
5959
"RelationDetector",
6060
"RelationConstraint",
6161
"ResonanceWeights",
62+
"ClassificationResult",
63+
"LLMClassifier",
64+
"LLMRelationDetector",
65+
"OllamaLLMProvider",
66+
"OpenAILLMProvider",
6267
"RuleBasedClassifier",
6368
"RuleBasedRelationDetector",
6469
"SearchIntent",
@@ -82,5 +87,25 @@ def __getattr__(name: str) -> object:
8287
from synaptic.extensions.embedder import OllamaEmbeddingProvider # noqa: PLC0415
8388

8489
return OllamaEmbeddingProvider
90+
if name == "LLMClassifier":
91+
from synaptic.extensions.classifier_llm import LLMClassifier # noqa: PLC0415
92+
93+
return LLMClassifier
94+
if name == "ClassificationResult":
95+
from synaptic.extensions.classifier_llm import ClassificationResult # noqa: PLC0415
96+
97+
return ClassificationResult
98+
if name == "LLMRelationDetector":
99+
from synaptic.extensions.relation_detector_llm import LLMRelationDetector # noqa: PLC0415
100+
101+
return LLMRelationDetector
102+
if name == "OllamaLLMProvider":
103+
from synaptic.extensions.llm_provider import OllamaLLMProvider # noqa: PLC0415
104+
105+
return OllamaLLMProvider
106+
if name == "OpenAILLMProvider":
107+
from synaptic.extensions.llm_provider import OpenAILLMProvider # noqa: PLC0415
108+
109+
return OpenAILLMProvider
85110
msg = f"module 'synaptic' has no attribute {name!r}"
86111
raise AttributeError(msg)
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""LLM-based NodeKind classifier — 풍부한 메타데이터 자동 생성.
2+
3+
LLM이 나중에 꺼내 쓸 지식을, LLM이 잘 찾을 수 있는 구조로 적재한다.
4+
적재 시점에 "이 지식을 나중에 언제 찾게 될지"까지 예측하여 메타데이터 생성.
5+
6+
classify()는 동기 프로토콜 호환 — 캐시 히트면 반환, 아니면 fallback.
7+
classify_async()가 LLM 호출로 ClassificationResult를 생성하며,
8+
결과는 content 해시 기반 LRU 캐시에 보관된다.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import hashlib
14+
import json
15+
import logging
16+
import re
17+
from collections import OrderedDict
18+
from dataclasses import dataclass
19+
from typing import TYPE_CHECKING
20+
21+
from synaptic.models import NodeKind
22+
23+
if TYPE_CHECKING:
24+
from synaptic.extensions.llm_provider import LLMProvider
25+
from synaptic.protocols import KindClassifier
26+
27+
logger = logging.getLogger(__name__)
28+
29+
# ---------------------------------------------------------------------------
30+
# 분류 결과
31+
# ---------------------------------------------------------------------------
32+
33+
@dataclass(slots=True)
34+
class ClassificationResult:
35+
"""LLM 분류 결과 — 검색 최적화 메타데이터 포함."""
36+
37+
kind: NodeKind
38+
tags: list[str]
39+
search_keywords: list[str]
40+
search_scenarios: list[str]
41+
summary: str
42+
confidence: float = 0.8
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# 시스템 프롬프트
47+
# ---------------------------------------------------------------------------
48+
49+
_SYSTEM_PROMPT = """\
50+
너는 지식 그래프 온톨로지 엔지니어다. 주어진 텍스트를 분석하여 지식 노드의 메타데이터를 생성한다.
51+
52+
이 지식은 LLM 에이전트가 나중에 검색해서 사용할 것이다.
53+
"이 지식을 나중에 언제, 어떤 상황에서 찾게 될까?"를 예측하여 메타데이터를 설계하라.
54+
55+
반드시 아래 JSON 형식으로 응답하라:
56+
{
57+
"kind": "concept|entity|lesson|decision|rule|artifact",
58+
"confidence": 0.0~1.0,
59+
"tags": ["태그1", "태그2"],
60+
"search_keywords": ["이 지식을 찾을 때 사용할 검색어들"],
61+
"search_scenarios": ["이 지식이 필요한 상황 묘사"],
62+
"summary": "1줄 요약"
63+
}
64+
65+
kind 분류 기준:
66+
- concept: 일반적인 개념, 정의, 설명
67+
- entity: 회사, 제품, 인물, 시스템 등 고유 대상
68+
- lesson: 경험에서 배운 교훈, 장애 사례, 실패/성공 패턴
69+
- decision: 의사결정과 근거, 대안 비교
70+
- rule: 정책, 규정, 가이드라인, 제약 조건
71+
- artifact: API, 문서, 코드, 시스템 컴포넌트
72+
73+
tags: 도메인/기술 키워드 3~7개, 한글+영어 포함
74+
search_keywords: 이 지식을 찾을 에이전트가 입력할 검색 쿼리 3~5개, "~하려면", "~할 때" 같은 실용적 질문형 포함
75+
search_scenarios: 이 지식이 필요한 구체적 상황 1~2개
76+
77+
JSON만 출력하라. 설명이나 사고 과정을 쓰지 마라. /no_think"""
78+
79+
# content 최대 길이 (토큰 절약)
80+
_MAX_CONTENT_LEN = 2000
81+
82+
# NodeKind로 변환 가능한 값
83+
_VALID_KINDS = {k.value for k in NodeKind}
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# LLM 캐시 (content 해시 기반 LRU)
88+
# ---------------------------------------------------------------------------
89+
90+
class _LRUCache:
91+
"""Thread-unsafe LRU cache backed by OrderedDict."""
92+
93+
__slots__ = ("_maxsize", "_data")
94+
95+
def __init__(self, maxsize: int = 512) -> None:
96+
self._maxsize = maxsize
97+
self._data: OrderedDict[str, ClassificationResult] = OrderedDict()
98+
99+
def get(self, key: str) -> ClassificationResult | None:
100+
if key in self._data:
101+
self._data.move_to_end(key)
102+
return self._data[key]
103+
return None
104+
105+
def put(self, key: str, value: ClassificationResult) -> None:
106+
if key in self._data:
107+
self._data.move_to_end(key)
108+
self._data[key] = value
109+
if len(self._data) > self._maxsize:
110+
self._data.popitem(last=False)
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# LLMClassifier
115+
# ---------------------------------------------------------------------------
116+
117+
class LLMClassifier:
118+
"""LLM 기반 NodeKind 분류기 — 검색 최적화 메타데이터 자동 생성.
119+
120+
Parameters
121+
----------
122+
llm:
123+
LLMProvider 프로토콜 구현체 (OllamaLLMProvider, OpenAILLMProvider 등).
124+
fallback:
125+
LLM 실패 시 사용할 KindClassifier. 기본값 None이면 CONCEPT 반환.
126+
cache_maxsize:
127+
content 해시 기반 LRU 캐시 크기.
128+
"""
129+
130+
__slots__ = ("_llm", "_fallback", "_cache")
131+
132+
def __init__(
133+
self,
134+
llm: LLMProvider,
135+
*,
136+
fallback: KindClassifier | None = None,
137+
cache_maxsize: int = 512,
138+
) -> None:
139+
self._llm = llm
140+
self._fallback = fallback
141+
self._cache = _LRUCache(maxsize=cache_maxsize)
142+
143+
# -- 동기 프로토콜 호환 (KindClassifier) --
144+
145+
def classify(self, title: str, content: str) -> NodeKind:
146+
"""동기 분류 — 캐시 히트면 반환, 아니면 fallback.
147+
148+
asyncio.run()은 사용하지 않는다. 비동기 결과가 필요하면
149+
classify_async()를 사용하고, 이후 get_cached_result()로 조회.
150+
"""
151+
cached = self.get_cached_result(title, content)
152+
if cached is not None:
153+
return cached.kind
154+
155+
if self._fallback is not None:
156+
return self._fallback.classify(title, content)
157+
158+
return NodeKind.CONCEPT
159+
160+
# -- 비동기 LLM 분류 --
161+
162+
async def classify_async(self, title: str, content: str) -> ClassificationResult:
163+
"""LLM 호출로 풍부한 분류 메타데이터 생성.
164+
165+
결과는 캐시에 저장되며, 이후 classify()나 get_cached_result()로
166+
동기적으로 조회할 수 있다.
167+
"""
168+
cache_key = self._make_cache_key(title, content)
169+
170+
cached = self._cache.get(cache_key)
171+
if cached is not None:
172+
return cached
173+
174+
try:
175+
result = await self._call_llm(title, content)
176+
except Exception:
177+
logger.exception("LLM classification failed, using fallback")
178+
result = self._make_fallback_result(title, content)
179+
180+
self._cache.put(cache_key, result)
181+
return result
182+
183+
# -- 캐시 조회 --
184+
185+
def get_cached_result(self, title: str, content: str) -> ClassificationResult | None:
186+
"""캐시에서 분류 결과 조회. graph.py 등에서 classify_async 이후 사용."""
187+
cache_key = self._make_cache_key(title, content)
188+
return self._cache.get(cache_key)
189+
190+
# -- 내부 메서드 --
191+
192+
async def _call_llm(self, title: str, content: str) -> ClassificationResult:
193+
"""LLM에 분류 요청 후 응답 파싱."""
194+
truncated = content[:_MAX_CONTENT_LEN]
195+
user_msg = f"제목: {title}\n내용: {truncated}"
196+
197+
raw = await self._llm.generate(
198+
system=_SYSTEM_PROMPT,
199+
user=user_msg,
200+
max_tokens=512,
201+
)
202+
203+
return self._parse_response(raw)
204+
205+
def _parse_response(self, raw: str) -> ClassificationResult:
206+
"""LLM 응답 JSON 파싱. 실패 시 정규식 추출 시도."""
207+
data = self._extract_json(raw)
208+
209+
kind_str = data.get("kind", "concept")
210+
if kind_str not in _VALID_KINDS:
211+
kind_str = "concept"
212+
213+
return ClassificationResult(
214+
kind=NodeKind(kind_str),
215+
tags=self._ensure_str_list(data.get("tags", [])),
216+
search_keywords=self._ensure_str_list(data.get("search_keywords", [])),
217+
search_scenarios=self._ensure_str_list(data.get("search_scenarios", [])),
218+
summary=str(data.get("summary", "")),
219+
confidence=self._clamp(float(data.get("confidence", 0.8)), 0.0, 1.0),
220+
)
221+
222+
@staticmethod
223+
def _extract_json(raw: str) -> dict[str, object]:
224+
"""JSON 파싱 — 직접 시도 후 코드블록 추출 fallback."""
225+
# 1차: 직접 파싱
226+
try:
227+
return json.loads(raw) # type: ignore[return-value]
228+
except (json.JSONDecodeError, ValueError):
229+
pass
230+
231+
# 2차: ```json ... ``` 블록 추출
232+
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
233+
if match:
234+
try:
235+
return json.loads(match.group(1)) # type: ignore[return-value]
236+
except (json.JSONDecodeError, ValueError):
237+
pass
238+
239+
# 3차: 첫 번째 { ... } 블록 추출
240+
match = re.search(r"\{[^{}]*\}", raw, re.DOTALL)
241+
if match:
242+
try:
243+
return json.loads(match.group(0)) # type: ignore[return-value]
244+
except (json.JSONDecodeError, ValueError):
245+
pass
246+
247+
logger.warning("Failed to parse LLM response as JSON: %s", raw[:200])
248+
return {}
249+
250+
def _make_fallback_result(self, title: str, content: str) -> ClassificationResult:
251+
"""LLM 실패 시 fallback 기반 결과 생성."""
252+
if self._fallback is not None:
253+
kind = self._fallback.classify(title, content)
254+
else:
255+
kind = NodeKind.CONCEPT
256+
257+
return ClassificationResult(
258+
kind=kind,
259+
tags=[],
260+
search_keywords=[],
261+
search_scenarios=[],
262+
summary=title,
263+
confidence=0.3,
264+
)
265+
266+
@staticmethod
267+
def _make_cache_key(title: str, content: str) -> str:
268+
"""title + content 해시로 캐시 키 생성."""
269+
h = hashlib.sha256()
270+
h.update(title.encode())
271+
h.update(content[:_MAX_CONTENT_LEN].encode())
272+
return h.hexdigest()[:24]
273+
274+
@staticmethod
275+
def _ensure_str_list(val: object) -> list[str]:
276+
"""값이 list[str]인지 확인하고 변환."""
277+
if isinstance(val, list):
278+
return [str(v) for v in val]
279+
return []
280+
281+
@staticmethod
282+
def _clamp(value: float, lo: float, hi: float) -> float:
283+
return max(lo, min(hi, value))

0 commit comments

Comments
 (0)