Skip to content

Commit 598b30b

Browse files
SonAIengineclaude
andcommitted
feat: 온톨로지 자동 구축 + 벤치마크 프레임워크 + 검색 엔진 개선
## 온톨로지 자동 구축 (코어) - KindClassifier 프로토콜 + RuleBasedClassifier: 키워드 기반 NodeKind 자동 분류 - RelationDetector 프로토콜 + RuleBasedRelationDetector: 역인덱스 기반 자동 Edge 탐지 - title 언급 → RELATED, tag overlap → RELATED, embedding cosine similarity → RELATED - NodeKind 쌍 규칙: RULE→CONCEPT=DEPENDS_ON, LESSON→*=LEARNED_FROM - graph.add()에서 kind=None이면 자동 분류, relation_detector 있으면 자동 관계 생성 - InvertedIndex로 O(1) 후보 조회 (전체 노드 순회 없음) ## 벤치마크 프레임워크 - 외부 데이터셋 8종: Ko-StrategyQA, AutoRAG, KLUE-MRC, HotPotQA, Allganize RAG-Eval/ko, PublicHealthQA - 7단계 Ablation Study: S0 Flat → S1 +Ontology → S2 +Relations → S3 +Hebbian → S4 +Consolidation → S5 Full → S6 Auto → S7 Auto+Embed - OntologyMapper (벤치마크용 batch 분류기), SessionSimulator (Hebbian 시뮬레이션) ## 검색 엔진 개선 - spreading activation 감쇠 0.5→0.25, depth 2→1: S2 MRR 하락 완전 해소 (0.547→0.772) - 합산 부스트 0.3→0.1: FTS 직접 매칭 랭킹 보존 - ResonanceWeights relevance 0.4→0.55: 쿼리 관련성 우선 랭킹 - agent_search kind 필터 fallback: 결과 부족 시 필터 없이 재검색 (S5 MRR +19%) - intent별 relevance 가중치 상향 (0.25~0.35 → 0.40~0.45) ## 벤치마크 결과 (FTS only, MemoryBackend) - Allganize RAG-Eval: MRR 0.793, R@10 0.870 - HotPotQA-24: MRR 0.754, SF Recall 72.9% - AutoRAG: MRR 0.639, R@10 0.800 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 871e7d7 commit 598b30b

21 files changed

Lines changed: 2824 additions & 75 deletions

CLAUDE.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Synaptic Memory — 프로젝트 지침
2+
3+
## 프로젝트 개요
4+
LLM/멀티에이전트용 뇌 기반 지식 그래프 라이브러리 + MCP 서버.
5+
에이전트가 경험을 구조화하고, 과거 패턴을 검색/추론할 수 있게 하는 적응형 메모리 아키텍처.
6+
7+
- PyPI: `synaptic-memory` (v0.5.0 배포 완료)
8+
- 라이선스: MIT
9+
- Python: >=3.12
10+
11+
## 아키텍처
12+
13+
### 핵심 모듈
14+
| 모듈 | 역할 |
15+
|------|------|
16+
| `graph.py` | SynapticGraph — 메인 facade (add, search, link, reinforce, consolidate) |
17+
| `search.py` | HybridSearch — 3단계 폴백 (FTS → fuzzy → synonym → rewriter) + spreading activation |
18+
| `agent_search.py` | AgentSearch — intent 기반 검색 (similar_decisions, past_failures, related_rules 등) |
19+
| `resonance.py` | ResonanceScorer — 4축 공명 (relevance × importance × recency × vitality) |
20+
| `hebbian.py` | HebbianEngine — co-activation 강화/약화 |
21+
| `consolidation.py` | ConsolidationCascade — L0→L1→L2→L3 메모리 정리 |
22+
| `ontology.py` | OntologyRegistry — 타입 계층, 관계 제약, 검증 |
23+
| `activity.py` | ActivityTracker — 에이전트 세션/tool call/결정/결과 추적 |
24+
| `models.py` | Node, Edge, NodeKind, EdgeKind, SearchResult 등 |
25+
26+
### 백엔드 (7종)
27+
| 백엔드 | 용도 | 의존성 |
28+
|--------|------|--------|
29+
| `MemoryBackend` | 테스트/개발 | 없음 |
30+
| `SQLiteBackend` | 경량 프로덕션 | aiosqlite |
31+
| `PostgreSQLBackend` | 프로덕션 | asyncpg, pgvector |
32+
| `Neo4jBackend` | 그래프 탐색 | neo4j |
33+
| `QdrantBackend` | 벡터 검색 | qdrant-client |
34+
| `MinIOBackend` | 대용량 콘텐츠 | miniopy-async |
35+
| `CompositeBackend` | 용도별 분리 | Neo4j + Qdrant + MinIO |
36+
37+
## 테스트
38+
39+
### 인프라 요구사항
40+
```bash
41+
# Neo4j (테스트용 인증: neo4j/password)
42+
docker run -d --name neo4j -p 7474:7474 -p 7687:7687 \
43+
-e NEO4J_AUTH=neo4j/password neo4j:5-community
44+
45+
# Qdrant
46+
docker start qdrant # 또는 docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
47+
48+
# PostgreSQL — 기존 컨테이너 사용 (ailab:ailab123@localhost:5432/plateerag)
49+
# MinIO — 기존 컨테이너 사용 (localhost:9000)
50+
```
51+
52+
### 실행
53+
```bash
54+
# 전체 테스트 (281건)
55+
uv run pytest tests/ -v
56+
57+
# PostgreSQL 제외 (asyncpg 미설치 시)
58+
uv run pytest tests/ --ignore=tests/test_backend_postgresql.py -v
59+
60+
# 벤치마크만
61+
uv run pytest tests/benchmark/ -v -s
62+
```
63+
64+
### Qdrant 테스트 주의
65+
- fixture에서 매 테스트 전 `test_synaptic` collection 삭제 후 재생성
66+
- 이전 비정상 종료 시 segment 잔재로 500 에러 발생 가능 → collection 수동 삭제 후 재실행
67+
68+
## 벤치마크
69+
70+
### 구조
71+
```
72+
tests/benchmark/
73+
├── conftest.py # 엔터프라이즈 시나리오 fixture
74+
├── metrics.py # IR 평가 지표 (MRR, nDCG, P@K, R@K, F1@K)
75+
├── generate_data.py # 시나리오 데이터 생성기 (아직 미사용)
76+
├── download_datasets.py # HuggingFace 외부 데이터셋 다운로드
77+
├── test_enterprise_benchmark.py # 자체 시나리오 벤치마크 (50개 쿼리)
78+
├── test_external_datasets.py # 외부 데이터셋 벤치마크
79+
└── data/
80+
├── enterprise_scenario.json # 자체 시나리오 v1 (12 지식 + 4 세션 + 15 쿼리)
81+
├── ko_strategyqa.json # MTEB Ko-StrategyQA (9.2K corpus, 592 queries)
82+
├── autorag_retrieval.json # MTEB AutoRAGRetrieval (720 corpus, 114 queries)
83+
└── klue_mrc.json # KLUE-MRC (5.8K corpus, 5.8K queries)
84+
```
85+
86+
### 외부 데이터셋 다운로드
87+
```bash
88+
uv run python tests/benchmark/download_datasets.py
89+
```
90+
- MIRACL, Mr. TyDi는 HuggingFace datasets 호환 이슈로 현재 skip
91+
92+
### 외부 데이터셋 벤치마크 결과 (FTS only, MemoryBackend)
93+
| 데이터셋 | Corpus | Queries | MRR | nDCG@10 | R@10 |
94+
|----------|--------|---------|-----|---------|------|
95+
| Allganize RAG-Eval | 300 | 300 | 0.796 | 0.811 | 0.863 |
96+
| Allganize rag-ko | 200 | 200 | 0.780 | 0.797 | 0.855 |
97+
| HotPotQA-24 | 226 | 24 | 0.754 | 0.636 | 0.729 |
98+
| HotPotQA-200 | 1990 | 200 | 0.742 | 0.599 | 0.652 |
99+
| AutoRAGRetrieval | 720 | 114 | 0.646 | 0.681 | 0.798 |
100+
| KLUE-MRC | 500 | 100 | 0.607 | 0.643 | 0.760 |
101+
| PublicHealthQA | 77 | 77 | 0.342 | 0.390 | 0.558 |
102+
| Ko-StrategyQA | 9,251 | 100 | 0.315 | 0.261 | 0.293 |
103+
104+
### 자체 시나리오 벤치마크 결과 (v0.5.0 + 검색 개선)
105+
| 지표 | Baseline | 개선 후 |
106+
|------|----------|--------|
107+
| MRR | 0.326 | **0.477** (+46%) |
108+
| Mean P@5 | 0.160 | **0.227** (+42%) |
109+
| Mean R@5 | 0.467 | **0.533** (+14%) |
110+
| Mean nDCG@5 | 0.351 | **0.431** (+23%) |
111+
| Hit rate | 9/15 | **13/15** |
112+
113+
### Ablation Study 핵심 발견
114+
- S1 Ontology: 현재 graph.search()가 NodeKind를 랭킹에 미활용 → 효과 없음
115+
- S2 Relations: spreading activation이 노이즈 유입 (MRR -14~-32%)
116+
- S3 Hebbian: HotPotQA multi-hop에서만 +3.9% 기여
117+
- S5 agent_search: kind 필터링이 과도하게 공격적 → recall 하락
118+
- S6 Auto ontology: 보수적 동작으로 성능 유지하지만 개선도 없음
119+
120+
### 경쟁 제품 비교
121+
| 제품 | 벤치마크 | 결과 | 비고 |
122+
|------|----------|------|------|
123+
| Cognee | HotPotQA 24문항 | Correctness 0.925 | end-to-end QA (LLM 포함) |
124+
| Mem0 | LoCoMo | 66.9% | 메모리 정확도 |
125+
| LightRAG | NaiveRAG 비교 | 39% win rate | 독립 검증 (원래 66.7%) |
126+
| HippoRAG2 | HotPotQA | Recall 95.4% | 최고 수준 |
127+
128+
### 검색 개선 내역
129+
1. FTS: title 가중치 3x, bigram 서브스트링 매칭, tag 매칭
130+
2. FTS: 순위 기반 점수 (1위 0.95 → 감소)
131+
3. Fuzzy: threshold 0.3→0.4, content 샘플 50→100 단어, title boost
132+
4. Spreading activation: depth 1→2, 다중 경로 보상
133+
5. AgentSearch past_failures: LESSON 노드 포함, fallback 추가
134+
135+
## 배포
136+
137+
### PyPI
138+
```bash
139+
source ~/.claude/.secrets # PYPI_TOKEN 로드
140+
uv build && uv publish --username __token__ --password "$PYPI_TOKEN"
141+
```
142+
143+
### 설치
144+
```bash
145+
pip install synaptic-memory # 코어만
146+
pip install synaptic-memory[embedding] # auto-embedding
147+
pip install synaptic-memory[scale] # Neo4j + Qdrant + MinIO
148+
pip install synaptic-memory[all] # 전부
149+
pip install synaptic-memory[mcp] # MCP 서버
150+
```
151+
152+
## 로드맵
153+
154+
### 온톨로지 자동 구축 (3단계)
155+
| Phase | 방식 | 비용 | 한국어 | 의존성 |
156+
|-------|------|------|--------|--------|
157+
| Phase 1 | 임베딩 유사도 자동 연결 | 저렴 | O | sentence-transformers |
158+
| Phase 2 | spaCy dependency parsing | 무료 | 제한적 | spacy (ko_core_news) |
159+
| Phase 3 | LLM 프롬프트 트리플 추출 | 높음 | O | LLM API |
160+
161+
### 검색 엔진 개선 포인트
162+
- spreading activation 가중치 튜닝: edge type별 차등 전파 (RELATED > TAGGED_WITH)
163+
- NodeKind 활용: 검색 랭킹에 kind 정보 반영 (ontology ablation 결과 반영)
164+
- tag 기반 부스팅: tag 매칭 시 가중치 조절
165+
- agent_search kind 필터 완화: recall 보존하면서 precision 유지
166+
167+
### 타겟 수치
168+
| 지표 | 현재 (최고) | 목표 | 근거 |
169+
|------|-------------|------|------|
170+
| MRR (Allganize) | 0.796 | 0.85+ | FTS+embedding 결합 시 |
171+
| MRR (HotPotQA-200) | 0.742 | 0.80+ | spreading activation 개선 시 |
172+
| MRR (Ko-StrategyQA) | 0.315 | 0.50+ | embedding 필수 (9K corpus) |
173+
| 자체 시나리오 MRR | 0.477 | 0.65+ | ontology+embedding 결합 |
174+
175+
## 방향성
176+
- 플래티어 온톨로지 비전과 연계: 엔터프라이즈 시맨틱 레이어
177+
- 정적 검색 → 동적 메모리 (사용 경험에 따라 재편)
178+
- 다층 랭킹: relevance + importance + recency + vitality
179+
- Hebbian 학습: 함께 쓰이는 지식 연결 강화, 실패 패턴 약화
180+
- 선제적 활성화: 태스크 맥락 기반 proactive loading
181+
- **차별점**: 단순 retrieval이 아닌 "에이전트 경험 메모리" — 검색+학습+적응의 통합

src/synaptic/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
from synaptic.activity import ActivityTracker
66
from synaptic.agent_search import AgentSearch, SearchIntent, suggest_intent
7+
from synaptic.extensions.classifier_rules import RuleBasedClassifier
78
from synaptic.extensions.embedder import EmbeddingProvider, MockEmbeddingProvider
9+
from synaptic.extensions.relation_detector import RuleBasedRelationDetector
810
from synaptic.graph import SynapticGraph
911
from synaptic.models import (
1012
ActivatedNode,
@@ -23,7 +25,15 @@
2325
TypeDef,
2426
build_agent_ontology,
2527
)
26-
from synaptic.protocols import Digester, GraphTraversal, QueryRewriter, StorageBackend, TagExtractor
28+
from synaptic.protocols import (
29+
Digester,
30+
GraphTraversal,
31+
KindClassifier,
32+
QueryRewriter,
33+
RelationDetector,
34+
StorageBackend,
35+
TagExtractor,
36+
)
2737
from synaptic.resonance import ResonanceWeights
2838

2939
__version__ = "0.5.0"
@@ -39,14 +49,18 @@
3949
"EdgeKind",
4050
"EmbeddingProvider",
4151
"GraphTraversal",
52+
"KindClassifier",
4253
"MockEmbeddingProvider",
4354
"Node",
4455
"NodeKind",
4556
"OntologyRegistry",
4657
"PropertyDef",
4758
"QueryRewriter",
59+
"RelationDetector",
4860
"RelationConstraint",
4961
"ResonanceWeights",
62+
"RuleBasedClassifier",
63+
"RuleBasedRelationDetector",
5064
"SearchIntent",
5165
"SearchResult",
5266
"StorageBackend",

src/synaptic/agent_search.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@ class SearchIntent(StrEnum):
3131
# Weights tuned per intent
3232
_INTENT_WEIGHTS: dict[SearchIntent, ResonanceWeights] = {
3333
SearchIntent.SIMILAR_DECISIONS: ResonanceWeights(
34-
relevance=0.30, importance=0.30, recency=0.15, vitality=0.05, context=0.20,
34+
relevance=0.45, importance=0.20, recency=0.15, vitality=0.05, context=0.15,
3535
),
3636
SearchIntent.PAST_FAILURES: ResonanceWeights(
37-
relevance=0.25, importance=0.35, recency=0.20, vitality=0.05, context=0.15,
37+
relevance=0.40, importance=0.25, recency=0.20, vitality=0.05, context=0.10,
3838
),
3939
SearchIntent.RELATED_RULES: ResonanceWeights(
40-
relevance=0.35, importance=0.25, recency=0.10, vitality=0.10, context=0.20,
40+
relevance=0.45, importance=0.20, recency=0.10, vitality=0.10, context=0.15,
4141
),
4242
SearchIntent.REASONING_CHAIN: ResonanceWeights(
43-
relevance=0.30, importance=0.20, recency=0.25, vitality=0.05, context=0.20,
43+
relevance=0.40, importance=0.20, recency=0.20, vitality=0.05, context=0.15,
4444
),
4545
SearchIntent.CONTEXT_EXPLORE: ResonanceWeights(
46-
relevance=0.20, importance=0.15, recency=0.15, vitality=0.10, context=0.40,
46+
relevance=0.30, importance=0.15, recency=0.15, vitality=0.10, context=0.30,
4747
),
4848
}
4949

@@ -155,11 +155,15 @@ async def _search_similar_decisions(
155155
start = time()
156156
weights = _INTENT_WEIGHTS[SearchIntent.SIMILAR_DECISIONS]
157157

158-
# Search filtered to decision nodes
158+
# Search filtered to decision nodes, fallback to unfiltered
159159
result = await self._hybrid.search(
160160
backend, query, limit=limit * 2, embedding=embedding,
161161
node_kinds=[NodeKind.DECISION],
162162
)
163+
if len(result.nodes) < 2:
164+
result = await self._hybrid.search(
165+
backend, query, limit=limit * 2, embedding=embedding,
166+
)
163167

164168
# Expand: follow RESULTED_IN edges to include outcomes
165169
expanded: dict[str, tuple[Node, float]] = {}
@@ -189,21 +193,23 @@ async def _search_past_failures(
189193
limit: int,
190194
context_tags: list[str] | None,
191195
) -> SearchResult:
192-
"""Find failed outcomes and their decision context."""
196+
"""Find failed outcomes, lessons, and their decision context."""
193197
start = time()
194198
weights = _INTENT_WEIGHTS[SearchIntent.PAST_FAILURES]
195199

196-
# Search OUTCOME nodes
200+
# Search broadly — OUTCOME, DECISION, LESSON all relevant to failures
197201
result = await self._hybrid.search(
198202
backend, query, limit=limit * 3,
199-
node_kinds=[NodeKind.OUTCOME, NodeKind.DECISION],
203+
node_kinds=[NodeKind.OUTCOME, NodeKind.DECISION, NodeKind.LESSON],
200204
)
201205

202-
# Filter to failures and backtrack to decisions
203206
expanded: dict[str, tuple[Node, float]] = {}
204207
for an in result.nodes:
205208
node = an.node
206-
if node.kind == NodeKind.OUTCOME and node.failure_count > 0:
209+
# LESSON 노드는 장애 교훈이므로 직접 포함
210+
if node.kind == NodeKind.LESSON:
211+
expanded[node.id] = (node, an.activation)
212+
elif node.kind == NodeKind.OUTCOME and node.failure_count > 0:
207213
expanded[node.id] = (node, an.activation)
208214
# Backtrack to decision
209215
edges = await backend.get_edges(node.id, direction="incoming")
@@ -215,7 +221,7 @@ async def _search_past_failures(
215221
elif node.kind == NodeKind.DECISION and node.failure_count > 0:
216222
expanded[node.id] = (node, an.activation)
217223

218-
# Also find lessons learned from failures
224+
# Also find lessons learned from failures via graph edges
219225
for node_id in list(expanded.keys()):
220226
edges = await backend.get_edges(node_id, direction="incoming")
221227
for edge in edges:
@@ -224,6 +230,14 @@ async def _search_past_failures(
224230
if lesson and lesson.id not in expanded:
225231
expanded[lesson.id] = (lesson, 0.6)
226232

233+
# If still empty, fall back to general search (no kind filter)
234+
if not expanded:
235+
result = await self._hybrid.search(
236+
backend, query, limit=limit * 2,
237+
)
238+
for an in result.nodes:
239+
expanded[an.node.id] = (an.node, an.activation)
240+
227241
activated = self._score_candidates(expanded, weights, context_tags)
228242
return SearchResult(
229243
query=query,
@@ -249,6 +263,10 @@ async def _search_related_rules(
249263
backend, query, limit=limit * 2, embedding=embedding,
250264
node_kinds=[NodeKind.RULE, NodeKind.LESSON],
251265
)
266+
if len(result.nodes) < 2:
267+
result = await self._hybrid.search(
268+
backend, query, limit=limit * 2, embedding=embedding,
269+
)
252270

253271
# Expand via graph traversal
254272
expanded: dict[str, tuple[Node, float]] = {}
@@ -280,11 +298,15 @@ async def _search_reasoning_chain(
280298
start = time()
281299
weights = _INTENT_WEIGHTS[SearchIntent.REASONING_CHAIN]
282300

283-
# Find seed decisions
301+
# Find seed decisions, fallback to unfiltered
284302
result = await self._hybrid.search(
285303
backend, query, limit=limit,
286304
node_kinds=[NodeKind.DECISION],
287305
)
306+
if len(result.nodes) < 2:
307+
result = await self._hybrid.search(
308+
backend, query, limit=limit,
309+
)
288310

289311
expanded: dict[str, tuple[Node, float]] = {}
290312
for an in result.nodes:

0 commit comments

Comments
 (0)