
Skein: 모순을 절대 덮어쓰지 않는 지식 그래프 (Knowledge Graph)
요약
AI 채팅 세션의 맥락 유실 문제를 해결하기 위해 원자적 주장(atomic claims)을 추출하고 체인 형태로 연결하는 지식 그래프 도구 Skein을 소개합니다. 새로운 정보가 기존 정보와 충돌할 때 데이터를 덮어쓰지 않고 이력을 추적할 수 있는 구조를 제공합니다.
핵심 포인트
- 원자적 주장 추출 및 주제별 클러스터링 기능 제공
- 모순 발생 시 데이터를 덮어쓰지 않고 체인으로 연결하여 이력 보존
- Postgres와 MongoDB를 활용한 데이터 모델링 경험 공유
- RAG를 통해 지식 그래프 기반의 쿼리 가능
저는 AI 채팅 세션 전반에 걸쳐 맥락을 계속 놓치곤 했습니다. 무언가에 대해 질문하면 아이디어가 서너 개의 별도 채팅을 통해 진화하고, 결국에는 다섯 개의 중복된 대화창이 열려 있지만 어떤 결정이 실제로 채택되었는지, 혹은 왜 중간에 마음을 바꿨는지에 대한 깔끔한 기록이 남지 않았습니다.
그래서 저는 Skein을 만들었습니다. 대화 기록을 붙여넣으면, 요약이 아닌 원자적 주장 (atomic claims, 개별 결정 및 사실)을 추출하고, 주제별로 클러스터링(clustering)하며, 새로운 주장이 기존의 주장과 모순될 경우 이를 덮어쓰는 대신 체인(chain)으로 연결합니다. 그러면 실제 RAG (Retrieval-Augmented Generation)를 통해 그 결과를 실제로 쿼리할 수 있습니다.
개발 과정에서 예상보다 더 흥미로웠던 두 가지 점이 있었습니다.
주장 체인, 덮어쓰지 않습니다
핵심 데이터 모델은 부끄러울 정도로 단순합니다. 주장은 다음과 같은 형태를 가집니다:
{
id, text, topic, label,
status: "active" | "superseded" | "correction" | "discarded",
...
이미 활성화된(active) 주장이 있는 주제에 대해 새로운 주장이 들어오고 텍스트가 실제로 다를 경우, 기존 주장은 superseded (대체됨)로 전환되고, 새로운 주장은 supersedes (대체함)가 이전 주장을 가리키는 correction (수정) 태그가 붙습니다:
export function applyNewClaims(existingClaims, newClaims) {
const claims = [...existingClaims];
for (const incoming of newClaims) {
...
어떠한 것도 삭제되지 않습니다. 어떤 주장에서든 supersedes를 따라 역방향으로 추적하면, 추론 과정이 생략된 현재의 답변만 얻는 대신 전체 결정 이력을 확인할 수 있습니다. — Postgres를 사용하다가 Mongo로 옮겼고, 다시 Postgres로 돌아왔는데, 이는 Mongo가 예상보다 관계형 쿼리 (relational queries)를 더 어렵게 만들었기 때문입니다.
이는 의도적으로 단순하게 설계되었습니다. 동일한 주제에 대해 서로 다른 텍스트가 존재할 때 이를 충돌로 간주하는 것이 전체 충돌 감지 휴리스틱 (conflict-detection heuristic)의 전부입니다. 따라서 동시에 여러 개의 유효한 주장이 공존할 수 있는 주제에 대해서는 오작동할 수 있습니다. 저는 한계점이 어디인지 정확히 알고 있으며, 실제 사용 과정에서 아직 그 한계에 부딪히지 않았습니다. 이는 더 정교한 버전을 추측하여 만들기보다는, 현재 상태로 출시하기에 적절한 기준이라고 판단했습니다.
검색 (Retrieval)은 채팅 제공자로부터 분리되어야 했습니다 — Anthropic은 임베딩 (embeddings) API를 제공하지 않습니다
이 점이 실제로 아키텍처 (architecture)를 형성하는 데 결정적인 역할을 했습니다. 이 앱은 Anthropic, OpenAI 호환 모델 (Ollama를 통한 로컬 모델 포함), 그리고 WebGPU를 통해 브라우저 내에서 완전히 실행되는 WebLLM을 지원합니다. 당연히 저는 채팅을 위해 선택한 제공자로부터 임베딩도 그대로 제공받기를 원했습니다.
하지만 Anthropic은 임베딩 엔드포인트 (embeddings endpoint)를 전혀 제공하지 않습니다. 이는 제 코드의 결함이 아니라, 그들의 API 표면 (API surface) 자체의 공백입니다. 그들은 사람들을 Voyage AI로 안내합니다. 따라서 만약 임베딩이 채팅 제공자에 종속되어 있었다면, Anthropic을 사용하는 모든 사람 (아마도 대부분의 사용자)은 실질적인 검색 기능을 전혀 사용할 수 없었을 것입니다.
해결책은 "임베딩 제공자 (embedding provider)"와 "채팅 제공자 (chat provider)"를 동일한 결정 사항으로 취급하지 않는 것이었습니다. 질문에 답하는 주체가 무엇이든 관계없이, 임베딩은 항상 WebLLM을 통해 로컬에서 실행됩니다 (채팅 모델을 억지로 사용하는 것이 아니라, 실제 임베딩 모델인 snowflake-arctic-embed-s를 사용합니다). 이는 무료이며, 키 (key)가 필요하지 않고, 어떤 채팅 제공자가 우연히 임베딩 엔드포인트를 가지고 있는지 여부에 따라 검색 품질이 좌우되지 않음을 의미합니다.
검색 계층(retrieval layer)에는 언급할 가치가 있는 규칙이 하나 더 있습니다. 의미론적 유사성(semantic similarity)은 '수정(correction)'이라는 개념을 이해하지 못한다는 점입니다. 만약 쿼리에 대해 가장 높은 점수를 받은 매칭 결과가 대체된(superseded) 주장(claim)일 경우 — 즉, 이전의 문구가 그것을 대체한 내용보다 우연히 더 밀접하게 일치할 경우 — 시스템은 해당 정보가 컨텍스트(context)로 사용되기 전에 해당 토픽의 현재 헤드(current head)로 이를 해결(resolve)합니다. 그렇지 않으면 임베딩(embedding)을 기반으로 구식 정보를 확신을 가지고 답변하는 방식이 될 것이며, 이는 앞서 설명한 수정 체인(correction-chain) 모델의 전제 자체를 무너뜨리게 됩니다.
// 컨텍스트 내 토픽당 하나의 주장, 해당 토픽의 현재(CURRENT) 헤드로 해결됨
const clusterByTopic = new Map(buildClusters(claims).map((cl) => [cl.topic, cl]));
const seenTopics = new Set();
...
합성(Synthesis) 단계는 선택된 채팅 제공자(chat provider)를 통해 진행되며, 검색된 주장들로부터만 엄격하게 답변하고 이를 인라인(inline)으로 인용하도록 요청받습니다. 만약 주장들이 답변하기에 충분한 정보를 포함하고 있지 않다면, 추측하는 대신 정보가 부족하다고 답변합니다.
웹 앱의 복사본이 아닌, 프롬프트를 공유하는 CLI
브라우저를 사용하지 않고도 동일한 추출(extract) → 분류(categorize) → 쿼리(query) 파이프라인을 수행하는 의존성 없는(zero-dependency) CLI도 존재합니다. 이는 속도를 중시하고 그래프를 볼 필요가 없는 사용자들을 위한 것입니다. 처음에는 웹 앱의 프롬프트를 수동으로 복사하여 포팅하는 방식으로 시작되었습니다. 하지만 약 일주일 후 두 시스템 사이에 차이가 생기기 시작했습니다. 웹 앱의 추출 프롬프트에 적용된 수정 사항이 CLI의 복사본에는 반영되지 않은 것입니다.
실제 해결책은 "한쪽의 모든 것을 다른 쪽으로 가져오는 것"이 아니었습니다. 두 시스템은 진정으로 다른 제공자 계층(provider layers)을 가지고 있기 때문입니다 (웹 앱은 브라우저 전용 로컬 모델을 포함하여 세 개의 제공자를 지원하며 settings 객체를 전달하는 반면, CLI는 두 개의 제공자에 대해 평면적인 파라미터(flat params)를 사용하는 BYOK 방식만 지원합니다). 이를 통합하는 것은 실제 차이점을 덮어버리는 일이 될 것입니다. 대신, 프롬프트의 _내용(content)_만을 양쪽 모두가 가져다 쓰는 공유 모듈로 이동시켰습니다.
Skein/
shared/prompts.js <- 실제 프롬프트 문자열, 단일 진실 공급원(single source of truth)
src/lib/ <- 웹 앱, ../../shared/prompts에서 가져옴
...
한 가지 좋은 부수 효과(side effect)도 있었습니다. 드리프트(drift)를 수정하는 과정에서 "레이블이 나타나는 그래프 노드"를 참조하던 문장을 다시 작성해야 했는데, 이 표현은 그래프가 없는 CLI 환경에서는 의미가 맞지 않았습니다. 알고 보니 그 근저에 깔린 조언(레이블은 전체 주장 텍스트 옆에 붙어 있지 않고 단독으로 읽힐 수 있어야 한다)은 CLI의 list 출력 결과에도 똑같이 적용되는 것이었습니다. 결국 특정 환경에 국한된 문제가 아니라, 단지 표현이 좁게 설정되었던 문제였습니다. 일반적인 표현으로 다시 작성하자, 프롬프트 전체를 그대로 공유할 수 있게 되었습니다.
작동 모습
그래프는 실제 힘 지향 레이아웃 (force-directed layout)을 사용하여 주제별로 주장(claims)을 클러스터링합니다 (기성 그래프 라이브러리를 사용하지 않아 번들 크기를 약 400KB 줄였습니다). 주제 그룹은 클러스터의 가장 먼 노드 크기에 맞춘 하나의 원이 아니라, 부드러운 메타볼 블롭 (metaball blobs) 형태로 렌더링됩니다. 수정 이력은 노드 자체에 표시됩니다 (이전 노드로 이어지는 선이 아닌 상태 링 (status ring) 형태). 노드를 클릭하거나 그래프를 쿼리하면 전체 체인이 컴팩트한 팝오버 (popover)에 나타납니다.
완전한 클라이언트 사이드 (client-side) 방식입니다. 저장에는 IndexedDB를 사용하며, 백엔드는 없고, MIT 라이선스를 따릅니다. 저장소는 github.com/Virerra/Skein에 있으며, 라이브 데모는 README에서 확인할 수 있습니다. 만약 이것을 살펴보시다 무언가 고장 난다면, 진심으로 알려주시면 감사하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
