Wintersalmon | Blog

GraphKnow 피벗: 한 번도 쓰지 않은 RAG 챗에서 LLM 워크플로우가 필요로 하는 위키로

5 min read

GraphKnow는 LLM 기반 개발 워크플로우의 나머지 부분에 메모리를 부여하기 위해 존재합니다: 태스크 로그와 PR 노트가 다시 읽을 수 있는 속도보다 더 빠르게 쌓이고 있었고, 처음에 만든 채팅 표면은 잘못된 기본 단위였음이 드러났습니다. 한 문장으로 요약한 피벗: 같은 Mongo / MinIO / Qdrant / Neo4j 스택, 4개의 새로운 일급 엔터티(raw_sources, canonical_markdown, wiki_pages, wiki_log), Slice 0가 2026-04-22 https://graphknow.wintersalmon.com에 배포되었습니다.

  • Open WebUI 클론 단계는 215개의 단위 테스트, 0 실패, biome clean으로 출시되었지만 — 잘못된 제품을 향하고 있었습니다.
  • 새 형태: raw 입력은 immutable, canonical markdown은 1:1 derived, wiki page는 누적되며, wiki_log는 capped collection으로 audit trail 역할을 합니다.
  • Slice 0 인수 기준: 27개 Go 단위 테스트 통과, RETURN apoc.version()5.26.23 반환, bearer middleware가 토큰 없으면 401, 토큰 있으면 404 반환.
  • 교훈: 데이터 평면을 먼저, 제품 표면을 마지막에. 데이터 평면은 피벗에도 손대지 않고 살아남았습니다. 채팅 표면은 그러지 못했습니다.

위키는 워크플로우를 뒷받침하는 인프라이지 독립 제품이 아닙니다

LLM 개발 흐름 실험의 운반체는 일/주 단위로 게임을 만드는 일입니다. 매 사이클마다 태스크 로그, ADR, 블로그 초안, 코드 리뷰가 트리에 쌓이는데, 누적되는 저장소가 없으면 다음 사이클은 cold start로 시작합니다. Obsidian이 어느 정도까지는 끌고 가 줬지만, 제가 직접 [[link]]를 손으로 유지하는 일이 싫어졌습니다. GraphKnow는 메모리 계층입니다 — 게임과 같은 monorepo, 같은 FluxCD 파이프라인, 다른 형태의 산출물.

Open WebUI 클론 단계는 품질은 높았지만 잘못된 제품을 향하고 있었습니다

피벗 전에 11개 슬라이스에 걸쳐 Open WebUI의 채팅 표면을 apps/graphknow-client/에 클론했습니다: 트리 기반 메시지 히스토리, fetch + AbortController를 통한 SSE, Zustand + localStorage, 보안 처리된 마크다운(marked → DOMPurify → highlight.js → DOMPurify), 통합 리사이저블 사이드바, 모바일 오버레이. 최종 집계: 215 tests, 0 failures, biome clean.

전체 중복 작업 로그는 docs/task-log/archive/2026/graphknow-openwebui-duplication.md에 있습니다. 다시 읽으면 마음이 불편합니다 — 에이전트는 시키는 대로 했고, 리뷰어는 진짜 CRITICAL 버그를 잡아냈으며, 그 어느 것도 의미가 없었습니다. PM(저)이 messages에 팀을 향하게 했지만 정작 제가 신경 쓰던 기본 단위는 pages였기 때문입니다.

새 형태: “documents”가 아닌 4개의 일급 엔터티

raw_sources         // immutable user uploads (PDF, MD, CSV, DOCX, TXT)
canonical_markdown  // 1:1 derived plain MD, normalized + diffable
wiki_pages          // LLM-generated wiki entries — the output product
wiki_log            // append-only audit trail, capped collection

wiki_pages 도큐먼트는 slug, title, markdownText, tags[], derivedFromSources[], outboundLinks[], 그리고 매 Stage B 실행마다 재계산되는 비정규화된 inboundLinks[]를 가집니다. 읽기가 지배적이고 writer가 단일 프로세스이므로 — denormalize가 옳은 선택입니다.

같은 데이터 평면, 그 자리에서 용도 변경

graphknow 네임스페이스의 4개 저장소는 위치를 바꾸지 않았고, 의미만 바뀌었습니다:

Store 이전 역할 새 역할
MongoDB (gk-rs0) 대화 + 도큐먼트 메타데이터 source/page 메타데이터 + wiki_log capped collection
MinIO bucket graphknow 업로드된 도큐먼트 raw bytes + canonical/{sourceId}.md
Qdrant document chunk embeddings wiki_pages collection, 768-dim cosine, nomic-embed-text
Neo4j + APOC doc-to-topic graph (WikiPage)-[:LINKS_TO]->(WikiPage), :TAGGED, :DERIVED_FROM
Ollama at 192.168.10.3:11434 RAG 생성 wikify (gemma4:26b) + embed (nomic-embed-text) — 클러스터 llm-gateway를 거치지 않고 직접

apps/graphknow/internal/pipeline/의 3개 파이프라인 단계는 각각 idempotent하고 재개 가능합니다: A (raw → canonical, 포맷별 어댑터 + LLM cleanup), B (canonical → wiki page, gemma4:26b 사용), C (chunk → embed → Qdrant + Neo4j upsert).

Slice 0는 파이프라인 코드 이전에 뼈대를 검증했습니다

Slice 0의 목적은 파이프라인 로직을 작성하기 전에 새 형태를 배포하는 것이었습니다 — 인증 뒤의 빈 4-탭 셸, 4개 저장소 모두 가동, 두 Go 바이너리(service + worker) 실행. 가장 지저분한 부분은 인증이었습니다. 원래의 LoginPage는 Cloudflare Access 도입 이전 코드라 사용자가 만족시킬 방법이 없는 수동 bearer-token 입력 폼을 보여주고 있었습니다. 해결책은 bearer middleware 바깥에 두는 silent token-exchange 엔드포인트였습니다:

// POST /api/auth/token — outside the bearer-protected group.
// Trusts Cf-Access-Jwt-Assertion presence; service is only reachable
// through the CF Access tunnel.
if !h.devBypass && r.Header.Get("Cf-Access-Jwt-Assertion") == "" {
    respondError(w, 401, "no_cf_assertion")
    return
}
respondJSON(w, 200, map[string]string{"token": h.bearerToken})

LoginPage는 4개 상태(connecting, connected, denied, error)를 가진 “Connecting…” 상태 화면으로 바뀌었고, ProtectedRoute도 deep-link 방문 시 동일하게 동작합니다 — unmount 이후의 state update를 막기 위한 cancelled 플래그 포함. 이 부분은 코드 리뷰에서 잡혔고, 저 혼자였다면 절대 발견하지 못했을 것입니다.

또 다른 Slice 0 교훈은 Flux였습니다. Work package WP-1은 refactor/graphknow-personal-wiki 브랜치에서 레거시 myapps/graphknow-* 매니페스트를 삭제했습니다. 클러스터는 약 24시간 동안 Flux의 inventory에 따라 배포를 다시 만들었는데, Flux는 main만 읽기 때문입니다. 해결책은 PR #384 — main에 머지해서 Flux가 새로운 세계를 보고 prune할 수 있게 했습니다. Flux state를 정리하는 방법은 branch merge이며, Flux가 소유한 리소스에 kubectl delete를 하는 것은 busy-loop입니다.

앞으로 무엇이 달라지는가

데이터 평면이 일반적이면 피벗은 저렴하고, 표면이 의견을 강하게 가지면 비싸집니다. Mongo / MinIO / Qdrant / Neo4j / Ollama 스택은 손대지 않고 살아남았습니다. 채팅 클론은 살아남지 못했습니다. 미래의 저에게 보내는 체크리스트: 데이터 평면 먼저, 제품 표면 마지막, 절대 그 반대로는 하지 말 것. 위키는 이제 실험의 나머지가 글을 쓰게 될 영속 계층입니다. #graphknow #pivot

AI 워크플로우 메모

채팅 클론 단계는 조율된 멀티 에이전트 팀(researcher, tech lead, frontend engineers, code/security reviewers)을 사용했고 잘못된 기본 단위 주변에 잘 다듬어진 215개의 테스트를 만들어냈습니다. 피벗 자체에서는 반대 방향으로 갔습니다 — architect 에이전트가 데이터 평면 대 표면의 분리를 명시적으로 짚어 주었고, 어떤 코드도 작성하기 전에 01-refactor-plan.md(비전, 타겟 UX, 아키텍처, 데이터 모델, ingest 파이프라인, 빌드 슬라이스, 접근 제어, 미해결 질문)를 강제로 먼저 썼습니다. 에이전트가 곧장 구현으로 가게 두면 pages를 원했는데 messages 주위에 215개의 테스트가 쌓이는 결과가 나옵니다. 계획을 먼저 쓰는 것이 그것을 잡아냅니다. doc-updater 에이전트는 recorder로 연결되어 의미 있는 이벤트마다 99-progress.md에 항목을 추가하고 있으며 — 그 습관이 이 재작성을 가능하게 했습니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.