redstring

Build a knowledge graph from documents you already have. Extract entities

and relationships with a language model, decide which of them are the same

thing, and keep a graph store in step with the result.

The name is the picture: facts pinned up from what you have read, and string

drawn between the ones that connect.


The problem this solves

You have a corpus — support tickets, filings, papers, an internal wiki — and

the questions you want to ask of it are about connections. Who else worked

on this. What changed between these two contracts. Which incidents share a

root cause. Full-text search cannot answer those, because the answer is not in

any one document; it is in the relationships between them.

Getting from documents to a graph that can answer such questions is three

problems wearing one coat, and they fail in different ways:

  1. Extraction. A model reads a document and names the entities and

relationships in it. This is the part everyone starts with, and the part

that is nearly a solved problem — one careful prompt gets you most of the

way.

  1. Consolidation. "Ada Lovelace", "Lovelace, A." and "Ada King" are one

person, and nothing in step 1 knows that, because each document was read on

its own. Skip this and your graph accumulates one node per mention — a

structure that looks like a knowledge graph and answers no question

correctly, because every entity's edges are split across its aliases.

  1. Storage that can be rebuilt. Extraction is non-deterministic and models

change. A store written to directly is a store you cannot regenerate when

a better prompt lands, and cannot audit when an edge turns out to be wrong.

redstring treats all three as first-class, and the third is what shapes the

architecture: extraction writes to no store. It emits an event describing

what was found, and a projection folds that event into the graph. So the store

is a derived value, the log is the source of truth, and "re-extract

everything with the new prompt" is a replay rather than a migration.

!!! note "What this library does not do"

It never fetches content. No crawling, no HTML cleanup, no PDF

parsing. You supply a SourceDocument; getting one is a different problem

with different failure modes, and libraries that do both tend to do

neither well.

What it looks like


from redstring import FakeLlmProvider, InMemoryGraphStore, SourceDocument, build_graph

store = InMemoryGraphStore()

report = await build_graph(
    SourceDocument(id="notes", text="Ada Lovelace worked with Charles Babbage."),
    provider=FakeLlmProvider(by_substring={"Ada": ANSWER}),
    store=store,
    tenant_id=tenant_id,
)

people = await store.find_entities(tenant_id, entity_type="Person")
neighbours = await store.neighbors(people[0].id, tenant_id)

FakeLlmProvider and InMemoryGraphStore are real implementations rather

than mocks. Swapping them for LangChainLlmProvider and Neo4jGraphStore

changes the two constructor calls and nothing else in the composition — which

is the point of the ports, and the reason the test suite can run the whole

pipeline with no infrastructure.

Your first graph walks through a complete runnable

version.

Three commitments

Almost every API here follows from one of three decisions.

Async-first. Both store ports, the LLM port and the cache port are async

protocols. There is no parallel blocking API; a synchronous caller wraps the

one coroutine it needs.

The write model is events. DocumentExtracted, EntitiesMerged and

MergeUndone are the whole schema, and both producers — extraction and

consolidation — emit rather than write.

ADR 0004 records what collapsing

that back into direct writes would cost, and

ADR 0001 records the schema

itself, which is the one decision in the library that cannot be taken back: a

log already written cannot be refactored.

Backends are ports. GraphStore and VectorStore are Protocols with an

in-memory implementation and a real one each (Neo4j, pgvector), and

src/redstring/testing/ is a shared contract suite that both run unchanged. Code

written against the interface in a test is the code that runs against the

database in production — and a contract two implementations satisfy by

accident is not a contract, which is why the in-memory adapter is never the

only one.

Multi-tenancy is not optional

Every store call takes a tenant_id, and every compliance suite asserts that

reads never cross tenants. This is not a feature to enable; it is a parameter

you cannot omit, because a knowledge graph that leaks one customer's entities

into another's answers is a data breach rather than a bug.

The public API is gated, not curated

from redstring import ... — everything in __all__ is supported, and

anything reached by a dotted path is internal and may move in a patch release.

The surface is closed, which is a stronger claim than "documented": every

type named in an exported signature is either exported too or recorded with

the package it comes from, and every RedstringError is either exported or

recorded against the capability whose export would bring it. Three tests hold

that, each blind to what the other two catch —

ADR 0006 explains why all three are

needed.

Two capabilities are deliberately not exported yet. redstring.consolidation

and redstring.temporal are both real and both tested, but neither has a

composed entry point, and exporting the classes would publish an API whose

shape is still being decided by callers it does not have. Reach them by dotted

path and expect movement.

Where to go next

Installation — the base install, the two extras, and

which one you actually need.

Your first graph — one document to a queryable

graph, with no server.

How-to guides — authoring a domain schema,

consolidating duplicates, driving projections from an event store,

rebuilding one, querying a timeline, writing a store adapter.

Reference — the events, the aggregates, the domain

value types, the schema YAML, the Neo4j store, the quality gates.

Decisions — the choices that are expensive to revisit,

each with the alternative that was rejected and why.

redstring targets Python 3.13+, ships a py.typed marker, is MIT licensed,

and is distributed on PyPI as

redstring. Source lives at

github.com/tyevans/redstring.