This document describes the knowledge layer that sits on top of the
KW Pipeline document review pipeline. The layer is additive: it
consumes already-VALIDATED SemanticDocuments and produces a
queryable knowledge graph plus a chat surface. It does not modify
the existing parser, semantic-extraction, review, or persistence
paths.
Companion ADRs:
- ADR-012 — gate placement, graph store choice, what to vendor.
- ADR-013 — Anthropic Claude only, no LangChain, why.
┌─────────────────────────────────────┐
│ KW Pipeline core │
upload ──▶ parse ─▶ extract ─▶ semantic ─▶ NEEDS_REVIEW │
│ │ │
│ ┌─────────▼──────┐ │
│ │ human reviewer │ │
│ └─────────┬──────┘ │
│ │ │
│ ┌──── REJECTED │ │
│ │ │ │
│ ┌──────▼─────┐ ┌──────▼──────┐ │
│ │ rejected │ │ VALIDATED │ │
│ └────────────┘ └──────┬──────┘ │
└────────────────────────────┼────────┘
│ side-effect
▼
┌──────────────┐
│ KNOWLEDGE │
│ LAYER │
├──────────────┤
│ • projector │
│ • extractor │
│ • graph │
│ • chat │
└──────────────┘
│
┌───────┴─────────┐
▼ ▼
Neo4j graph Anthropic SDK
(5.x Community) (Claude)
The dashed box on the right is the new layer. Everything outside it stays exactly as it is today.
New modules added under apps/api/app/services/knowledge/:
| Module | Purpose | Phase |
|---|---|---|
graph_store.py |
GraphStore Protocol + Neo4jGraphStore impl + InMemoryGraphStore test fake |
1 |
projector.py |
Projects a SemanticDocument into the graph: (:Document), (:Version), (:Section) nodes + PART_OF edges |
1 |
entity_extractor.py |
LLM-driven entity/relation extraction over validated section text; emits (:Entity) + HAS_ENTITY edges with citations |
2 |
llm_client.py |
LLMClient Protocol + AnthropicLLMClient impl + FakeLLMClient test fake |
2 |
chat_service.py |
RAG / GraphRAG / Vector chat dispatcher; calls GraphStore and LLMClient |
3 |
New schemas under apps/api/app/schemas/:
knowledge.py— Pydantic models for nodes, edges, triples, chat requests/responses. Inherits fromAPISchemaModel(introduced in PR #107 for #80) and carries an explicitschema_versionliteral per ADR-008.
New routes added in apps/api/app/routes.py:
| Route | Phase | Operation ID | Returns |
|---|---|---|---|
GET /documents/{document_id}/graph |
1 | get_document_graph |
KnowledgeGraphProjection (nodes + edges for one document) |
GET /knowledge/graph |
1 | get_knowledge_graph |
Cursor-paginated KnowledgeGraphPage (cross-document subgraph) |
GET /knowledge/search |
3 | search_knowledge_chunks |
ChunkSearchResponse (top-K chunks ranked by cosine similarity to the embedded query) |
POST /knowledge/chat |
3 | chat_with_knowledge |
ChatResponse (citation-grounded answer; mode discriminates RAG / GraphRAG / Hybrid retrieval) |
Frontend additions under apps/web/src/features/:
| Slice | Component | Phase |
|---|---|---|
graph/ |
<KnowledgeGraphView /> (wraps @neo4j-nvl/react) |
1 |
chat/ |
<ChatPanel />, <ChatModeToggle /> (RAG / Graph / Hybrid) |
3 |
The lifecycle FSM in apps/api/app/models/document.py
is unchanged. No new states. No new transitions.
Graph projection is wired as a side-effect of the existing
mark_validated call in DocumentService. The pseudo-code:
def mark_validated(self, *, document_id, version_id, reviewer_note):
self._catalog.update_version_status(...) # existing
self._semantic_outputs.record_validation(...) # existing
try:
self._knowledge.project(document_id, version_id) # new, fire-and-log
except Exception:
log.exception("graph projection failed; document still validated")Implications:
- Validation never fails because of a graph error. A Neo4j outage or an LLM hiccup leaves the SQLite catalog correct; the graph catches up later via a retry loop or a manual reproject.
- No reverse migration on the catalog. If
KnowledgeServiceis not configured (no Neo4j connection string, no Anthropic key), the call is a no-op and the existing pipeline behaves identically. - Phase 2 entity extraction follows the same pattern. It is a side-effect of validation, runs after projection, and emits its own log line on failure. The graph reflects whatever the LLM produced and what passed Pydantic validation and what carried a valid source reference. Anything else is dropped with a warning.
- Every
(:Section)node carries the originatingdocument_version_idand thesection_idfromSemanticDocument. - Every
(:Entity)node is reachable from at least one(:Section)viaHAS_ENTITY. Orphans are pruned at write time. - Every
HAS_ENTITYedge carries asource_reference_id— pointing at a row in the existingsource_referencestable — so Orbital can deep-link from a graph entity back to the exact line span in the original document. - No edge without a citation. Triples lacking a
source_reference_idare dropped at the boundary inentity_extractor.py. This is the equivalent of ADR-009'sreview_status="needs_review"policy applied to graph edges. - Schema version on every payload. Migrations follow the ADR-008 pattern.
The knowledge layer is opt-in via environment variables. All env-var
reads — both knowledge-layer settings and the older upload / CORS
guardrails — flow through app.settings.Settings (#43, a thin
pydantic_settings.BaseSettings subclass). The KW_ prefix is the
canonical name; legacy unprefixed names are accepted as
pydantic.AliasChoices so existing deployments keep working without a
config rewrite.
| Env var (canonical) | Legacy alias | Purpose | Default |
|---|---|---|---|
KW_NEO4J_URI |
— | bolt://... connection string |
unset (layer disabled) |
KW_NEO4J_USER |
— | Auth username | unset |
KW_NEO4J_PASSWORD |
— | Auth password | unset |
KW_NEO4J_DATABASE |
— | Neo4j database name | neo4j |
KW_ANTHROPIC_API_KEY |
ANTHROPIC_API_KEY |
LLM access | unset (Phases 2+ disabled) |
KW_ANTHROPIC_MODEL |
KW_LLM_MODEL |
Claude model id | SDK default (claude-sonnet-4-5) |
KW_KNOWLEDGE_LAYER_ENABLED |
— | Master kill-switch | false |
KW_MAX_UPLOAD_BYTES |
MAX_UPLOAD_BYTES |
Upload byte ceiling | 52428800 (50 MiB) |
KW_ALLOWED_CONTENT_TYPES |
ALLOWED_CONTENT_TYPES |
MIME allowlist (CSV) | text/plain |
KW_CORS_ALLOWED_ORIGINS |
CORS_ALLOWED_ORIGINS |
CORS allowlist (CSV) | empty (no cross-origin) |
When KW_KNOWLEDGE_LAYER_ENABLED=false, all five graph + chat
endpoints return 503 Service Unavailable with a detail explaining
the layer is disabled. The existing pipeline's behavior is identical
to today.
Unit tests use the in-memory fakes; no Docker required:
tests/test_graph_projector.py— given aSemanticDocument, the projector calls the expectedGraphStoreoperations (Phase 1).tests/test_entity_extractor.py— given a recordedLLMClientresponse, the extractor produces validated triples; missing-source triples are dropped (Phase 2).tests/test_chat_service.py— mode dispatch, source attribution payload shape (Phase 3).
Integration tests against a real Neo4j run behind
pytest -m integration, opt-in:
tests/integration/test_neo4j_graph_store.py— realbolt://connection; CRUD round-trip on a temporary database.
LLM integration tests against the real Anthropic API run behind
pytest -m llm_integration, opt-in and not part of default CI:
tests/integration/test_anthropic_llm_client.py— happy-path smoke; rate-limit handling.
Embedding integration tests against the real Voyage AI API + Neo4j
vector index run behind pytest -m embedding_integration, also opt-in
and not part of default CI:
tests/integration/test_voyage_neo4j_search.py— embeds three semantically-distinct chunks through real Voyage, persists their vectors in a real Neo4j HNSW index, and asserts the right chunk wins for an English-language query (Phase 3).
docker/docker-compose.yml (added in Phase 1b) defines:
neo4j— Neo4j 5.23 Community, ports 7474 (browser) + 7687 (bolt), named volumeneo4j_data, healthcheck viawgetagainst the HTTP port.api— the FastAPI app (built fromapps/api/Dockerfile) withKW_KNOWLEDGE_LAYER_ENABLED=trueandKW_NEO4J_*env vars wired to the neo4j service.depends_on: neo4j (service_healthy).
There is intentionally no web/frontend service; the Vite dev server
runs on the host (npm run dev from apps/web/).
# Just Neo4j (run the API on the host with `uvicorn`):
docker compose -f docker/docker-compose.yml up -d neo4j
# Full backend stack for end-to-end demos:
docker compose -f docker/docker-compose.yml up
# Run the integration tests once Neo4j is healthy:
cd apps/api && \
KW_NEO4J_URI=bolt://localhost:7687 \
KW_NEO4J_USER=neo4j \
KW_NEO4J_PASSWORD=test_password_change_me \
pytest -m integration --override-ini="addopts=-ra --strict-markers --strict-config -m integration"- Phase 1 — graph projection. Shipped.
KnowledgeProjectormaterialises every(:Document),(:Version),(:Section),(:Chunk),(:Topic), and structural edge from the validatedSemanticDocument. - Phase 2 — LLM entity extraction with citation enforcement. Closed 2026-05-04. All four ADR-014 acceptance items are on
main: section-level prompt with tool-use, two-gate citation enforcement, prompt-injection sanitization, ephemeral prompt caching, exponential-backoff retry on 429/5xx (§4), and per-documentinput_tokenscircuit breaker (§3, default off — opt-in viaKW_ENTITY_EXTRACTOR_MAX_INPUT_TOKENS_PER_DOCUMENT). Residual deferred follow-up: section batching to amortise cache hits (#195). - Phase 3 — vector RAG. Embedding-client scaffold (
EmbeddingClientProtocol +VoyageEmbeddingClient+FakeEmbeddingClient) and thevoyage_api_key/embedding_modelsettings live onmainper ADR-015. Implementation (Neo4j HNSW vector index + chunk-embedding write path +GET /knowledge/search) is tracked in #186 and not yet wired into the projector or any route. - Reconciliation surface. A reconciliation service exists (
apps/api/app/services/knowledge/reconciliation.py); the operator-facing route / CLI is tracked in #124.
- Embedding model and vector index implementation details. Tracked in #186; see ADR-015 for the provider commitment.
- Cypher-generation prompt design. Phase 3 ADR (chat surface).
- Prompt-caching policy and cost telemetry. ADR-014.
- 3DEXPERIENCE widget composition of graph + chat. #78.
- Multi-tenant data isolation for the graph. #91.
- Sensitive data detection before publishing to the graph. #92.