DEV Community

Cover image for Skein: a knowledge graph that never overwrites a contradiction
Sean
Sean

Posted on

Skein: a knowledge graph that never overwrites a contradiction

I kept losing the thread across AI chat sessions. Ask about something, the idea evolves over three or four separate chats, and by the end I've got five overlapping conversations open and no clean record of which decision actually won, or why I changed my mind halfway through.

So I built Skein — paste a transcript in, it extracts the atomic claims (individual decisions and facts, not a summary), clusters them by topic, and when a new claim contradicts an old one, it chains onto it instead of overwriting it. Then you can actually query the result with real RAG.

Two things about the build turned out more interesting than I expected going in.

Claims chain, they don't overwrite

The core data model is almost embarrassingly simple. A claim looks like this:

{
  id, text, topic, label,
  status: "active" | "superseded" | "correction" | "discarded",
  supersedes: string | null, // id of the claim this one replaces
}
Enter fullscreen mode Exit fullscreen mode

When a new claim comes in on a topic that already has an active claim, and the text actually differs, the old claim flips to superseded and the new one gets tagged correction with supersedes pointing back:

export function applyNewClaims(existingClaims, newClaims) {
  const claims = [...existingClaims];
  for (const incoming of newClaims) {
    const activeSameTopic = claims.find(
      (c) => c.topic === incoming.topic && c.status === "active"
    );
    if (activeSameTopic && activeSameTopic.text !== incoming.text) {
      activeSameTopic.status = "superseded";
      claims.push({ ...incoming, status: "correction", supersedes: activeSameTopic.id });
    } else if (!activeSameTopic) {
      claims.push(incoming);
    }
  }
  return claims;
}
Enter fullscreen mode Exit fullscreen mode

Nothing gets deleted. Walking supersedes backward from any claim gives you the full decision history — Postgres, then Mongo, then back to Postgres because Mongo made relational queries harder than expected — instead of just the current answer with the reasoning stripped out.

It's naive on purpose: same topic, different text, is the whole conflict-detection heuristic. It'll misfire on any topic that legitimately holds multiple simultaneous active claims. I know exactly where the edges are and haven't hit them yet in real use, which felt like the right bar for shipping it rather than building a more sophisticated version speculatively.

Retrieval had to be decoupled from chat provider — Anthropic has no embeddings API

This one actually shaped the architecture. The app supports Anthropic, OpenAI-compatible (including local models via Ollama), and WebLLM running fully in-browser via WebGPU. Naturally I wanted embeddings to just come from whichever provider you'd picked for chat.

Anthropic doesn't offer an embeddings endpoint at all. Not a gap in my code — a gap in their API surface; they point people to Voyage AI. So if embedding were tied to chat provider, anyone using Anthropic (probably most people) would get zero real retrieval.

The fix was to stop treating "embedding provider" and "chat provider" as the same decision. Embeddings always run locally via WebLLM (a real embedding model, snowflake-arctic-embed-s, not a chat model pressed into service) regardless of what's answering the question. Free, no key, and it means retrieval quality doesn't depend on which chat provider happens to have an embeddings endpoint.

The retrieval layer has one more rule worth mentioning: semantic similarity has no concept of a correction. If the highest-scoring match for a query is a superseded claim — its old wording just happened to match more closely than whatever replaced it — the system resolves it to that topic's current head before it's used as context. Otherwise you'd get an embedding-powered way to confidently answer with outdated information, which would defeat the entire premise of the correction-chain model above it.

// One claim per topic in context, resolved to that topic's CURRENT head
const clusterByTopic = new Map(buildClusters(claims).map((cl) => [cl.topic, cl]));
const seenTopics = new Set();
const sources = [];
for (const { claim } of scored) {
  if (sources.length >= TOP_K) break;
  if (seenTopics.has(claim.topic)) continue;
  seenTopics.add(claim.topic);
  sources.push(clusterByTopic.get(claim.topic)?.head ?? claim);
}
Enter fullscreen mode Exit fullscreen mode

Synthesis goes through whichever chat provider is selected, asked to answer strictly from the retrieved claims and cite them inline. If the claims don't contain enough to answer, it says so instead of guessing.

A CLI that shares prompts with the web app, not a copy of them

There's also a zero-dependency CLI doing the same extract → categorize → query pipeline with no browser involved — for anyone who wants to be fast and doesn't need to see a graph. It started as a hand-copied port of the web app's prompts. That lasted about a week before they drifted: a fix applied to the web app's extraction prompt never made it to the CLI's copy.

The actual fix wasn't "import everything from one side into the other" — the two have genuinely different provider layers (the web app supports three providers including a browser-only local model and passes around a settings object; the CLI is BYOK-only across two with flat params), and unifying that would mean papering over a real difference. Instead, just the prompt content moved into a shared module both sides import:

Skein/
  shared/prompts.js   <- the actual prompt strings, single source of truth
  src/lib/             <- web app, imports from ../../shared/prompts
  cli/src/lib/          <- CLI, imports from ../../../shared/prompts
Enter fullscreen mode Exit fullscreen mode

One nice side effect: fixing the drift required rewording a sentence that referenced "the graph node" a label appears on — phrasing that didn't make sense from a CLI with no graph. Turned out the underlying advice (a label needs to read standalone, without the full claim text next to it) was equally true of the CLI's list output. It wasn't environment-specific after all, just narrowly worded. Once reworded generically, the whole prompt became shareable verbatim.

What it looks like

The graph clusters claims by topic using a real force-directed layout (not a canned graph library — dropped the bundle by about 400KB), with topic groups rendered as soft metaball blobs rather than one circle sized to the cluster's farthest node. Correction history shows on the node itself (a status ring, not a line back to a predecessor) — click any node, or query the graph, and the full chain shows in a compact popover.

Fully client-side. IndexedDB for storage, no backend, MIT licensed. Repo's at github.com/Virerra/Skein, live demo linked from the README. If you poke at it and something breaks, I'd genuinely like to know.

Top comments (1)

Collapse
 
seven7763 profile image
Seven

The "resolve superseded hits to the current head before injecting into context" step is the detail most RAG implementations skip entirely. Embeddings find what sounds like the answer; without that chain-walk you'd confidently retrieve stale decisions with high cosine similarity and never know.

One edge I'd be curious about: when two claims on the same topic differ in scope rather than contradict — "use Postgres for the user table" and "use Postgres for the audit log" are both active simultaneously, not a correction chain. The naive same-topic heuristic would supersede the first. Have you hit that yet, or does topic granularity from the extraction prompt stay fine enough to avoid it in practice?

Also, decoupling embedding provider from chat provider is the right call architecturally. The OpenAI-compatible ecosystem has the same gap in reverse — plenty of gateways expose /v1/chat/completions but not /v1/embeddings, so anyone routing through a relay hits the same split you solved with local WebLLM.