Google's Open Knowledge Format Meets Neo4j

William Lyon

August 4, 2026

21 min read

Neo4jOKFKnowledge GraphGraphRAGAgentsCypherPythonGoogle Cloud

If you've ever written a CLAUDE.md or AGENTS.md file for a coding agent, you've already produced a tiny proto-OKF bundle. In June, Google Cloud published the Open Knowledge Format (OKF) - an open specification that takes that instinct and turns it into a portable, vendor-neutral standard: curated organizational knowledge written as plain markdown files with YAML frontmatter, designed to be consumed by AI agents. No proprietary platform, no SDK - just files in a git repo that any agent can read.

When I read the spec, one thing jumped out immediately: an OKF bundle is already a graph - it's just serialized as files. Paths are identities. Markdown links are edges. Frontmatter carries trust, provenance, and lifecycle signals. The format is begging to be materialized in a graph database, where all of that implicit structure becomes traversable, indexable, and queryable.

So I built neo4j-okf, a demo project that parses OKF bundles into a Neo4j property graph and then uses the neo4j-graphrag Python package to show exactly what the graph buys you at retrieval time. I walked through all of this live on a recent episode of Neo4j's Going Meta livestream series - you can watch the recording below:

In this post we'll map OKF onto a Neo4j property graph, then look at what that graph is good for - first for governance (queries a wiki simply can't answer), then for retrieval (a vector-only RAG trap, and how graph-aware retrieval dismantles it).

An OKF bundle maps onto a Neo4j property graph - the same knowledge, traversable, indexable, retrievable. Click any diagram in this post to view it full size.

What you'll learn: how OKF encodes trust, provenance, and lifecycle in frontmatter; how to map a document format onto a property graph without losing those signals; and how to build retrieval that reads those signals instead of blindly trusting cosine similarity. Who this is for: developers building agent systems or RAG pipelines who are comfortable with Python. You don't need to know Cypher - Neo4j's query language, think SQL where the JOINs are drawn as arrows - and I'll gloss the notation as we go. You don't need prior OKF knowledge either: the spec launched as v0.1 in June and is already at v0.2 (the version my parser targets), so nobody has much.

The Problem OKF Is Solving

Every organization has the same knowledge topology: the real context lives scattered across data catalogs, wiki pages, Slack threads, code comments, dashboards, and - most expensively - the heads of senior engineers. Every time a team builds a new AI agent, they re-solve context assembly from scratch, per vendor, per team. The result is knowledge with no common format, locked into catalogs and wikis, going stale silently, and not portable across orgs.

Context is fragmented across catalogs, wikis, Slack threads, code comments, and senior engineers - so every new agent re-solves context assembly.

OKF's bet is that the fix is a format, not another platform: markdown + frontmatter + links, shippable in git, readable on GitHub, consumable by any agent. Google has already updated their Knowledge Catalog to ingest OKF and serve it to their agents, but the spec itself is deliberately vendor-neutral - which is exactly what makes it interesting to a Neo4j person like me.

Anatomy Of An OKF Concept File

An OKF bundle is a directory of markdown files. Each file is a concept - a metric, a table, a policy, a skill - and its path (minus .md) is its identity. Here's an abridged version of metrics/gross-margin.md from Google's sample acme_retail bundle, which documents a fictional retail warehouse:

---
type: Metric
title: Gross Margin
description: Gross margin for a period, per Acme's FY2026 Cost Allocation Standard.
tags: [finance, margin, headline-metric]
generated: { by: reference_agent/gemini-2.5-pro, at: 2026-06-30T14:00:00Z }
verified:
  - { by: human:jsmith@acme, at: 2026-07-01T09:00:00Z }
status: stable
stale_after: 2026-12-31
not:
  - term: "revenue minus product cost only"
    why: "that is the pre-FY2026 definition (see gross-margin-legacy)."
    instead: "revenue minus full COGS (product cost + fulfillment + shipping + payment fees)"
sources:
  - id: margin-standard
    resource: policies/margin-standard.md
    title: Cost Allocation & Margin Standard (FY2026)
    author: human:jsmith@acme
    last_modified: 2026-06-15
---

# Definition

Gross margin for a period equals recognized [Revenue](./revenue.md) minus
**full COGS**. [^margin-standard]

The sanctioned computation is
[`computations/gross-margin-period.md`](../computations/gross-margin-period.md).

# What changed in FY2026

Prior to 2026-02-01, Acme's gross-margin definition included only product
cost. That legacy definition is preserved in
[`metrics/gross-margin-legacy.md`](./gross-margin-legacy.md) as
`status: deprecated` for historical query reproducibility.

[^margin-standard]: Cost Allocation & Margin Standard (FY2026)

There's a lot packed in here, and almost none of it is decoration:

The anatomy of an OKF concept file: each frontmatter family and body convention, annotated with the graph element it becomes.

The frontmatter organizes into four families, and each one exists to answer a governance question:

The four frontmatter families - provenance, trust, lifecycle, attestation - and the question each answers.

  • Provenance (sources:) - what this concept derives from, with signals (author, usage_count, last_modified) rather than authority scores. Credibility is inferred by the consumer, never stored.
  • Trust (generated: / verified:) - who wrote it is deliberately separated from who confirmed it. Verifier kinds yield a derived trust tier: unverifiedmachine-confirmedhuman-reviewed. (One honesty note: the tier derivation checks verifier kind, not independence - a self-verification still earns human-reviewed, and the sample bundle actually contains one. For anything real I'd enforce verifier ≠ generator, which is a one-line addition to the query.)
  • Lifecycle (status: / stale_after:) - deprecated knowledge is kept, not deleted (history must stay linkable), and staleness is a simple date comparison made at query time.
  • Attestation (§10 of the spec) - Attested Computations are sanctioned SQL with typed parameters. Concretely: computations/gross-margin-period.md carries the exact SQL for gross margin; an agent may fill in the period parameters, but it may not edit the query text, and a deterministic attester checks each run.

Also worth calling out: that not: block is explicit negative knowledge - "gross margin is not revenue minus product cost only, and here's why." Writing down what something isn't turns out to be enormously useful context for an agent that would otherwise happily pattern-match to the outdated formula.

An OKF Bundle Is Already A Graph

I claimed up front that a bundle is already a graph. Let me prove it with four files. gross-margin.md links to revenue.md, to its sanctioned computation, and to its deprecated predecessor. The computation names the skill that executes it and the policy it derives from. Same bytes, two views:

The same four markdown files, viewed as a graph.

Nothing is missing from the files view - OKF is a complete serialization. What's missing is the tooling: traversal, indexes, aggregation, retrieval. "What breaks if this policy changes?" is a recursive grep nightmare over files, and a one-line variable-length path pattern in Cypher. That's the gap Neo4j fills.

The Mapping: OKF Constructs To Property Graph

First, the promised notation gloss, because the rest of the post reads better with it: in Cypher, (:Label {prop: value}) is a node, -[:REL_TYPE]-> is a directed relationship between two nodes, and the curly braces hold properties on either one. MATCH finds patterns shaped that way; MERGE is the upsert variant. That's genuinely most of what you need.

The core design decision in neo4j-okf is the mapping from spec constructs to graph elements. The guiding principle: preserve every governance signal as something queryable, and put properties where they semantically belong - intrinsic facts on nodes, declaration-scoped facts on relationships. Skim the table; for everything that follows, the rows that matter most are the links, the secondary labels, the sources, and the stubs.

OKF constructGraph elementWhy it matters
concept file(:Concept) + secondary label from type (Metric:Metric)MATCH (m:Metric) finds every metric directly - no property filter needed
markdown link[:LINKS_TO {section, text}]the section the link appeared in survives - prose context on the edge
broken linkstub (:Concept {stub: true})"not-yet-written knowledge" becomes a queryable authoring backlog
sources[](:Source) + [:DERIVES_FROM {usage_count, …}]intrinsic signals on the node, declaration-scoped signals on the edge
generated / verified(:Actor {kind}) + [:GENERATED_BY / :VERIFIED_BY {at}]trust tier is derived from verifier kinds, per the spec
body # sections(:Section {heading, text, embedding}) + [:HAS_SECTION], [:NEXT]OKF's conventional headings are the chunking - nearly free (only sections past ~2,400 chars get split, at paragraph boundaries)
footnote [^id](:Section)-[:CITES]->(:Source)claim-level provenance, not just document-level
log.md entries(:LogEntry {date, kind})-[:REFERENCES]->(:Concept)change history joins the graph
unknown frontmatter keyspreserved in Concept.extra_frontmatterthe spec says consumers MUST NOT reject them (§11)

Put together, the full graph schema looks like this:

The complete OKF graph schema, centered on the Concept node.

One deliberate omission: index.md files are not ingested. OKF uses them for progressive disclosure - a table of contents for agents browsing the bundle - but that's derivable from the graph. Progressive disclosure is a serving concern, not a storage concern.

Deterministic Ingestion - No LLM Required

Because OKF's structure is explicit (that's the whole point of the format), ingestion needs zero LLM calls. This is worth pausing on, because "knowledge graph construction" has become almost synonymous with "LLM entity extraction" - and here we get a rich graph with no extraction step to hallucinate and a reproducible build. For this layer, that is: the optional domain layer in the last section reintroduces both risks, and I'll label it accordingly when we get there. LLMs enter at the edges of the system, where they belong.

The pipeline: parse and ingest with no LLM, an optional embedding pass, and LLMs only at the retrieval edge.

Getting the demo running takes four commands:

docker compose up -d      # Neo4j 2025.x at bolt://localhost:7687
uv sync                   # deps + the okf-graph CLI
cp .env.example .env      # add your OPENAI_API_KEY (only needed for --embed)

# parse + ingest the sample bundle - no API key, no LLM
uv run okf-graph ingest bundles/acme_retail --reset

# with embeddings + vector/fulltext indexes
# (append --embedding-provider hash to rehearse offline with no key)
uv run okf-graph ingest bundles/acme_retail --reset --embed

Under the hood it's a two-step Python API - parse_bundle() builds an in-memory model, GraphWriter.ingest() writes it with idempotent batched MERGEs (that upsert semantic again - re-runs converge instead of duplicating):

from okf_graph import parse_bundle, GraphWriter, get_driver

pb = parse_bundle("bundles/acme_retail", "acme_retail")
writer = GraphWriter(get_driver())
writer.ingest(pb)

A detail I care about: ingest() is a sync, not an append. Re-running it clears the bundle's replaceable relationships, removes vanished sections and stubs, refreshes secondary labels, and rebuilds from the parse - so a re-run never duplicates edges, and the materialized trust tier can't drift from the derived one between runs. Sections keep their embeddings unless their text changed, with one caveat: the embedded string includes the concept title and section heading, so a rename deserves a follow-up --embed pass. And "sync" here means a batch of statements, not one transaction - fine for demos and CI; a graph serving live traffic would want the swap inside a single transaction, or a blue-green database cutover.

The Graph Pays Rent Before Any AI Shows Up

Before we get anywhere near retrieval, the graph already answers governance questions that a static wiki answers only with bespoke scripts - here, each one is a query, not a program. All of these live in okf_graph/queries.py.

Trust tiers, derived live. The spec says trust tier is derived from verification edges, not stored. In Cypher, the living definition is:

MATCH (c:Concept) WHERE NOT coalesce(c.stub, false)
OPTIONAL MATCH (c)-[v:VERIFIED_BY]->(a:Actor)
WITH c, collect(a.kind) AS verifier_kinds
RETURN CASE
         WHEN size(verifier_kinds) = 0        THEN 'unverified'
         WHEN 'human' IN verifier_kinds        THEN 'human-reviewed'
         ELSE 'machine-confirmed'
       END AS trust_tier,
       count(*) AS concepts
ORDER BY concepts DESC

What can we still serve today? Staleness is just a date comparison at query time - deprecated means "do not serve", a passed stale_after means "re-verify before serving":

MATCH (c:Concept) WHERE NOT coalesce(c.stub, false)
WITH c,
     c.status = 'deprecated'                               AS is_deprecated,
     c.stale_after IS NOT NULL AND date() >= c.stale_after AS is_stale
RETURN c.id AS concept, c.status AS status, c.trust_tier AS trust,
       CASE WHEN is_deprecated THEN 'do not serve (deprecated)'
            WHEN is_stale      THEN 're-verify before serving'
            ELSE 'servable' END AS verdict
ORDER BY is_deprecated DESC, is_stale DESC

Impact analysis. "What breaks if the revenue-recognition policy changes?"

MATCH (target:Concept {id: $concept_id})
MATCH p = (upstream:Concept)
      -[:LINKS_TO|EXECUTED_BY|DERIVES_FROM|RESOLVES_TO|HAS_SECTION|MENTIONS*1..6]->(target)
WHERE upstream <> target AND NOT coalesce(upstream.stub, false)
WITH upstream, min(length(p)) AS hops
RETURN upstream.id AS impacted, upstream.status AS status, hops
ORDER BY hops, impacted

The *1..6 is the interesting bit: "follow any of those relationship types, one to six hops out." One pattern walks every kind of dependency edge - including provenance chains that pass through a Source node. (On a real estate you'd cap the depth or reach for shortestPath - naive all-paths enumeration doesn't scale past demo size.)

The authoring backlog you didn't know you had. OKF §6.1 says broken links are fine - they're not-yet-written knowledge. Because the ingester mints stub nodes for them, the backlog is queryable, including exactly which section of which concept wants each missing doc:

MATCH (ghost:Concept {stub: true})<-[l:LINKS_TO]-(c:Concept)
RETURN ghost.id AS missing_concept,
       collect(c.id + '  (§ ' + l.section + ')') AS wanted_by

None of these needed embeddings or an LLM. The structure was in the files all along - the graph just made it addressable.

The Vector-Only RAG Trap

Now for the fun part. The acme_retail bundle contains a deliberate landmine, and it's one every real organization has: the FY2026 gross-margin definition changed, and the deprecated legacy definition is still in the bundle - kept on purpose, because the spec (correctly) says deprecated knowledge is preserved for historical reproducibility.

Embed the sections, build a vector index, and ask the question every analyst asks:

from neo4j_graphrag.retrievers import VectorRetriever

vector_retriever = VectorRetriever(driver, "section_embeddings", embedder=embedder)
hits = vector_retriever.search(
    query_text="How do we calculate gross margin, exactly?", top_k=5
)

The deprecated legacy definition lands in the top hits, right beside the current one - in the repo's recorded run it ranks 3rd and 4th; your embedder may promote it higher. Which definition wins the ranking is beside the point, though. The problem is that both arrive stripped of status, stale_after, and trust tier, so the model is choosing between a sanctioned formula and a retired one with no way to tell them apart. Hand that context to an LLM and, depending on the model's mood, you get the legacy formula, the current one, or an unhelpful blend. In Acme's story that's roughly a 4-6 percentage-point margin misstatement, served confidently and silently.

The same question through similarity-only retrieval versus retrieval that walks the graph.

Fair objection from anyone running a real vector store: metadata filtering exists. Stamp status onto every chunk, add status <> 'deprecated' to the query, and this specific trap dies. But look at what that costs and what it still can't do. It costs you denormalizing concept-level governance onto every chunk, and re-syncing all of it on every change. And a filter can only remove the wrong answer - it can't fetch the right one. The current definition is a different document, reached over LINKS_TO. The sanctioned SQL lives in an Attested Computation one or two hops away, and that file is not semantically similar to the question. Meanwhile "what changed in FY2026?" needs the deprecated text - a filter drops it; the graph serves it with a label. Those are joins, not filters.

The point isn't that vector search is bad - it's that retrieval quality, not generation quality, is the bottleneck, and a bare vector store can neither tell the model which definition is sanctioned nor hand it the one that is.

Governed GraphRAG With VectorCypherRetriever

The fix keeps the same vector index and the same anchors, but adds one thing: after the similarity lookup, a Cypher retrieval_query walks the graph from each matched :Section and assembles context that carries the governance signals. This is VectorCypherRetriever from neo4j-graphrag - the matched node and its similarity score come in as node and score, and the graph decides what the LLM sees:

MATCH (c:Concept)-[:HAS_SECTION]->(node)

// 1. lifecycle + trust of the concept that owns the matched section
OPTIONAL MATCH (c)-[v:VERIFIED_BY]->(va:Actor)
// WITH ... collect(...) — elided

// 2. the sanctioned computation: direct link only - unless the anchor is
//    deprecated, where a 2nd hop can reach the SQL through the linked
//    replacement
OPTIONAL MATCH path = (c)-[:LINKS_TO*1..2]->(ac:AttestedComputation)
WHERE coalesce(ac.status, 'stable') <> 'deprecated'
  AND (length(path) = 1 OR c.status = 'deprecated')
// WITH ... — elided, plus a match that pulls the computation's SQL section

// 3. provenance sources of the concept
OPTIONAL MATCH (c)-[:DERIVES_FROM]->(src:Source)

// 4. deprecated concepts: surface ONE deterministic replacement
OPTIONAL MATCH (c)-[:LINKS_TO]->(cand:Concept {type: c.type})
WHERE c.status = 'deprecated' AND coalesce(cand.status,'stable') = 'stable'

// ... final RETURN assembles one annotated context block per hit:
//   status (+ "SUPERSEDED BY" for deprecated), trust tier + who verified
//   and when, a freshness verdict from stale_after vs date(), provenance,
//   the matched section text, and the sanctioned SQL itself

(The full query is ~70 lines in the repo - the elided WITH/collect steps aggregate each block, fetch the computation's SQL section, and format everything into a single text block per hit.)

Wiring it up is a few lines:

from neo4j_graphrag.retrievers import VectorCypherRetriever
from neo4j_graphrag.generation import GraphRAG

graph_retriever = VectorCypherRetriever(
    driver, "section_embeddings",
    retrieval_query=queries.GOVERNED_RETRIEVAL_QUERY,
    embedder=embedder,
)

rag = GraphRAG(retriever=graph_retriever, llm=llm)
res = rag.search(query_text="How do we calculate gross margin, exactly?")

Same model, same embeddings, same top-k. The only variable is what retrieval hands the LLM - and now every hit arrives annotated: status: deprecated → SUPERSEDED BY metrics/gross-margin, trust: human-reviewed (jsmith@acme at 2026-07-01), a freshness verdict, the policy provenance, and the sanctioned SQL fetched from the Attested Computation. The demo notebook runs the baseline and the graph-aware retriever against the same question back to back, so you can watch the legacy definition win and then lose - that side-by-side is the whole demo in one cell.

The three retrievers: similarity only, similarity plus traversal, and no similarity at all.

Two honesty notes on what this does and doesn't guarantee. First, the retriever annotates deprecated hits rather than dropping them - deliberately, because "what changed in FY2026?" needs the legacy text. So the guarantee is that the model is always told which definition is sanctioned, not that it always complies. If you want a hard filter, it's one WHERE clause - and before betting real margin numbers on the labeling approach, eval your model's compliance with it. Second, that SUPERSEDED BY replacement is inferred - first stable same-type link target, by id - because the bundle declares no explicit supersedes edge. In your own bundles, declare one.

And two things a production deployment would add that the demo skips for clarity: a bundle predicate in the retrieval query - the vector index is global, so a second bundle (which the notebook happily ingests) would mix into results - and a dedupe by concept before assembling context, since several matched sections of one concept currently each repeat the same computation block.

Text2Cypher: Questions With No Similarity Anchor

Some questions have no paragraph to be similar to. "Which metrics were never human-reviewed?" is an aggregate over trust tiers with a negation - there is nothing to embed. This is where Text2CypherRetriever comes in: it hands the LLM a schema plus a few examples and compiles natural language into Cypher.

One practical finding from building this: a small, hand-written schema beats a dumped SHOW SCHEMA for LLM accuracy. The repo ships a curated ~30-line schema string with the conventions spelled out ("absent status means stable", "a concept is stale when date() >= stale_after", "ignore stubs unless asked about missing knowledge") plus five worked examples:

from neo4j_graphrag.retrievers import Text2CypherRetriever

t2c = Text2CypherRetriever(
    driver, llm,
    neo4j_schema=queries.TEXT2CYPHER_SCHEMA,
    examples=queries.TEXT2CYPHER_EXAMPLES,
)

t2c.search(query_text="Which concepts are deprecated or already stale, "
                      "and what should replace them?")

The generated Cypher comes back in the result metadata, which makes every answer auditable - the query is the receipt. But auditable isn't audited: plausible-but-wrong Cypher that quietly drops the stub filter and returns confidently wrong counts is the real failure mode, so keep a human or a test in that loop. Production notes: a read-only database user is table stakes, but also set transaction timeouts and result limits (generated variable-length patterns eventually go pathological), and inject tenant or bundle scoping server-side rather than trusting the LLM to scope itself. Promote recurring governance questions to parameterized templates - generated Cypher for exploration, canned Cypher for dashboards.

Two Construction Modes, One Graph

One last construction move before wrapping up. Everything we've built so far was deterministic structure - OKF handed it to us. But there's knowledge in the prose that no link declares: the margin-standard policy mentions payment processing fees, systems, and owning teams that never appear as markdown links. The neo4j-graphrag SimpleKGPipeline adds the complementary move - LLM entity extraction over the policy bodies, with a constrained schema:

from neo4j_graphrag.experimental.pipeline.kg_builder import SimpleKGPipeline

kg_builder = SimpleKGPipeline(
    llm=kg_llm, driver=driver, embedder=embedder, from_file=False,
    schema={
        "node_types": ["CostComponent", "System", "Team",
                       "FinancialMetric", "PolicyRule"],
        "relationship_types": ["PART_OF", "GOVERNED_BY", "STORED_IN", "OWNED_BY"],
        "patterns": [
            ("CostComponent", "PART_OF", "FinancialMetric"),
            ("FinancialMetric", "GOVERNED_BY", "PolicyRule"),
            ("CostComponent", "STORED_IN", "System"),
            ("PolicyRule", "OWNED_BY", "Team"),
        ],
    },
)

A small stitch query then links extracted entities to the OKF concepts whose sections mention them (by name match - fuzzy on purpose), and suddenly an entity like "payment processing fees" bridges policies/margin-standard, computations/gross-margin-period, and tables/orders - connective tissue the explicit link graph didn't have.

The structural layer (exact, from files) and the domain layer (fuzzy, from prose), stitched where they touch.

Here's the promised labeling, though: treat this layer the way the post has treated everything else. In OKF's own vocabulary, every extracted entity deserves status: draft, an unverified trust tier, and a GENERATED_BY edge to the extracting model - so the governance queries see the fuzzy layer for what it is. The demo stitches by substring match and skips the review step; fine for a demo, not something to ship as-is. That's the layered mental model I'd take away for knowledge graph construction in general: a structural layer you can trust completely, an extracted layer you treat as draft, each clearly labeled, stitched where they touch.

What's Next?

If you take one thing from this post, make it the recipe:

  1. Parse the OKF bundle - paths become identities, frontmatter becomes properties, links become edges.
  2. Ingest into Neo4j with idempotent, sync-not-append semantics, minting secondary labels from type and stubs from broken links.
  3. Query the governance signals directly: trust tiers, staleness verdicts, impact analysis, the authoring backlog - each a single short Cypher query.
  4. Embed the sections OKF already gave you and add vector + fulltext indexes (the fulltext side powers hybrid keyword retrieval, which I didn't cover here).
  5. Retrieve with the graph in the loop: VectorCypherRetriever for context that carries the signals, Text2CypherRetriever for questions with no similarity anchor.
  6. Optionally extract a domain layer from the prose, stitch it to the structural layer, and label it as the draft knowledge it is.

Where I'd like to take this next: multi-bundle federation (the :Bundle node is already in the model), log-driven freshness alerts, and PageRank over the link graph to find load-bearing concepts. But the one I find most interesting is agent write-back: agents authoring OKF concepts, humans verifying them, and the graph tracking both sides of that trust relationship - the spec's human: / agent: / process: actor model was clearly designed with this loop in mind. Those explorations will land in the newsletter below first.

Resources

The sample acme_retail bundle and the OKF specification are from GoogleCloudPlatform/knowledge-catalog, Apache License 2.0. neo4j-okf is a community demo and is not affiliated with Google.

Stay Updated

Get notified about new posts and videos

Recommended for You


NewsletterBlogRSS

© 2026 William Lyon. Built with Next.js and Chakra UI.