Looking for the RagLeap platform? This page documents the open-source
pip install ragleap-graph library. For the hosted business platform, see docs.ragleap.com instead.
108 tests total, 92 passing without live credentials, 16 skipped (Neo4j/GEMINI_API_KEY)
ragleap-graph
Knowledge-graph retrieval for ragleap-rag — LLM entity extraction, deduplication, typed relations, and hybrid vector+graph search over Neo4j.
0.9.0
PyPI version
79
Tests passing
5
Real features shipped this session
v0.1→v0.5
Version arc, single session
Install
Requires a running Neo4j instance. Works standalone with regex extraction (zero LLM dependency), or with method="llm" for higher-quality extraction.
pip install ragleap-graph # or, with uv uv add ragleap-graph
Quick start
from ragleap_graph import GraphConfig, GraphIndex, ExtractionConfig
graph = GraphIndex(
config=GraphConfig(uri="bolt://localhost:7687", user="neo4j", password="..."),
extraction=ExtractionConfig(method="llm", provider=my_provider_config),
)
graph.upsert_document(document_id="doc-1", title="Q3 report", chunks=chunks)
related = graph.search_related_entities(["Acme Corp"], max_depth=2)
Version history
Every release this session was published to PyPI and live-tested before moving to the next — real Gemini calls, real Neo4j reads/writes, never mocked at the boundary that matters.
| Version | What shipped | Real live test performed |
|---|---|---|
| v0.1.0 | GraphConfig, GraphIndex, regex entity extraction, co-occurrence graphs | Real Neo4j roundtrip |
| v0.2.0 | ExtractionConfig, LLMEntityExtractor, EntityDeduplicator | Real Gemini call + real isolated Neo4j write |
| v0.3.0 | GraphRetriever, GraphRetrievalConfig — hybrid vector+graph retrieval | Real Postgres + real Neo4j |
| v0.3.1 | PyPI metadata fix only | N/A — no code change |
| v0.4.0 | LLMRelationExtractor, ExtractedRelation, find_relations(), RELATES_AS edges | Real Gemini call correctly identified REPORTED / PARTNERED_WITH, real Neo4j write + read-back |
| v0.5.0 | entity_types=, entity_type on graph nodes, find_entities_by_type() | Real Gemini call with entity-type guidance, real Neo4j write + read-back |
| v0.5.1 | PyPI metadata fix — added missing Documentation URL | N/A — no code change |
| v0.5.2 | Fixed sentence-initial stopword bug in regex entity extraction | Reproduced live, regression test written first (confirmed failing), fix applied, same test confirmed passing, full suite re-run |
| v0.5.3 | Added direction= to find_relations() — closes reverse-lookup limitation | Real Neo4j writes, regression test confirmed failing first, fix applied, same test confirmed passing, full suite re-run |
| v0.5.4 | Fixed CONTAINS weight-doubling and stale-entity bugs in upsert_document() | Both bugs reproduced live, regression test written, fix applied, full suite re-run |
| v0.6.0 | Per-document contribution tracking for CO_OCCURS_WITH/RELATES_AS — new PairWeight/RelationWeight node types | Three behaviors verified live: idempotent re-upsert, real cross-document aggregation, correct removal on content change. RELATES_AS tested with real Ollama call |
| v0.6.1 | GraphRetriever.retrieve() now flags (not removes) overlap between vector-chunk results and graph-derived related_documents via already_in_vector_results | Test-first with existing FakeRag/FakeGraph doubles, no live services needed |
| v0.6.2 | find_entities_by_type() is now case-insensitive — "customer" now finds a stored "Customer" | Reproduced live (0 results before fix), regression test confirmed failing then passing |
| v0.6.3 | entity_types= is now enforced, not just guidance — an out-of-vocabulary type is coerced to "UNKNOWN" | Test-first with fake GenerationService, no live LLM call needed |
| v0.6.4 | Added find_lineage(entity_a, entity_b, relation_type=None, namespace=None, limit=25) — exposes per-document PairWeight/RelationWeight contributions, previously unreachable via any public method | Live-verified against real Neo4j: multi-document aggregation, direction-agnostic lookup, relation_type filtering, never-linked-pair empty case. Full suite: 85 passed, 1 skipped |
| v0.6.5 | Added user_id= to upsert_document() and all read methods for per-user data isolation (split-identity model, matching namespace=) — plus backfill_user_id_defaults(), a one-time migration for pre-upgrade data | Live-verified against real Neo4j across all 6 methods. Two real bugs caught and fixed before release: an operator-precedence error in find_lineage()'s WHERE clause, and an inconsistent matching approach in find_relations() rewritten for consistency. Full suite: 86 passed, 1 skipped |
| v0.6.6 | Added Postgres-backed audit logging — audit=AuditConfig(database_url=...) on GraphIndex, fully opt-in. Records upsert_document() and all 6 read methods via a new ragleap_graph._audit module | Live-verified against real Postgres and real Neo4j together. Graceful degradation confirmed (missing psycopg2, unreachable database never block the real operation). Full regression test confirms all 7 methods produce correctly-ordered audit rows. Full suite: 87 passed, 1 skipped |
| v0.6.7 | Fixed a real, confirmed concurrency bug (closes #183): concurrent upsert_document() calls with identical document_id/user_id/namespace could create duplicate Document/Entity/PairWeight/RelationWeight nodes, since MERGE on multiple plain properties is not atomic without a uniqueness constraint (Neo4j Community Edition only supports single-property constraints). Fixed via a hashed composite_key property backed by a single-property uniqueness constraint, plus retry-with-backoff for Neo4j's documented-retryable transient deadlocks, plus backfill_composite_key() for pre-existing data | Reproduced live before fixing: 8 duplicate Document nodes from 6 "successful" concurrent upserts. Fixed and re-verified via a live regression test (8-16 concurrent threads against real Neo4j): real deadlocks directly observed and transparently recovered, zero duplicates across dozens of runs. Migration live-verified end-to-end against a real legacy-style node. Full suite: 88 passed, 1 skipped |
| v0.9.0 | Implemented ontology cross-validation (#152): an opt-in ExtractionConfig.relation_ontology field constrains which relation_type values are valid between which entity_type pairs (requires entity_types= to also be set, or raises ValueError at config time). LLMRelationExtractor.extract() gained an entity_types= parameter threaded from the existing entity_type_map built during the per-chunk loop - the type information already existed in scope, it just never reached relation extraction before this. A relation violating the ontology is dropped (with a WARNING logged) rather than written to Neo4j; relation_types not listed in the ontology remain fully unconstrained. Applies to both the per-chunk pass and the v0.8.0 cross-chunk pass. | 4 new offline tests (config validation, kept/dropped/unconstrained cases). 1 new live end-to-end test, gated on Gemini for the same reason as the #154 cross-chunk test (ontology validation depends on accurate relation_type/entity_type extraction, unreliable on small local models) - confirms a real Gemini-extracted ontology-valid relation is kept AND a real ontology-violating relation the model actually proposed is genuinely dropped, not just mocked output. Full suite: 92 passed, 16 skipped (zero regressions) |
| v0.8.0 | Implemented cross-chunk relation extraction (#154): an opt-in ExtractionConfig.cross_chunk_relations flag adds one additional relation-extraction pass over the full document text and all accumulated entities after the per-chunk loop, recovering relations whose subject and object were established in different chunks (e.g. a pronoun in a later chunk referring back to an entity named earlier). Feeds the existing relation_counter/RelationWeight/RELATES_AS pipeline unmodified - no new Neo4j write path. LLMRelationExtractor.extract() gained an optional resolve_references parameter (off by default, only set by the new pass) that asks the model to resolve pronouns/vague references to a known entity name. | Live-verified working correctly with Gemini (gemini-3.5-flash), which correctly resolved a cross-chunk pronoun reference. Documented limitation: small local models were found, via live testing, to produce an incorrect relation rather than none on this task - qwen2.5:0.5b is not recommended for this feature without independent verification of its output. Full suite: 88 passed, 15 skipped (zero regressions to existing per-chunk extraction) |
| v0.7.0 | Implemented the schema migration framework proposed in docs/design/schema-migrations.md: a Migration base class, a MigrationRunner (discovers pending migrations, applies in order, records each as a :_Migration node, fails loud and stops on the first error, supports dry_run=True and .status()), and both existing bespoke migrations registered in ALL_MIGRATIONS. Also accepted the backup/restore ADR and documented it in the README's new Operations section. backfill_user_id_defaults() and backfill_composite_key() were refactored to share session-level logic with the new framework, zero behavior change | 10 new tests: 9 offline using fake driver/session doubles covering the runner's full logic (sort order, dry-run, skip-if-applied, apply-and-record, fail-fast-no-continue, status reporting), plus 1 live end-to-end test confirming dry-run/apply/idempotent-rerun against real Neo4j. Full suite: 98 passed, 4 skipped (with live credentials) |
| v0.6.9 | Fixed the RELATES_AS relationship-MERGE race, the previously-undocumented directed-relationship counterpart to v0.6.8's CO_OCCURS_WITH fix. MERGE (es)-[r:RELATES_AS {relation_type: $relation_type}]->(eo) matched only the two Entity endpoints plus relation_type - not atomic against concurrent writers. Fixed via the same composite_key pattern (namespace/user_id/subject/relation_type/object), backed by a relationship-level uniqueness constraint. RELATES_AS is directed, so no pair-canonicalization was needed, unlike CO_OCCURS_WITH | Live concurrency test using real Ollama (qwen2.5:0.5b) extraction: 4 concurrent upsert_document() calls, 4 different document_ids, same real relation-bearing sentence. Asserts no two relationships share a composite_key (not an exact count, since real LLM extraction can legitimately vary relation_type strings). Passed against real Neo4j + real Ollama. Full suite: 79 passed, 13 skipped |
| v0.6.8 | Fixed the CO_OCCURS_WITH relationship-MERGE race, noticed but deliberately left open in the v0.6.7 fix (that release closed the four NODE-level races for Document/Entity/PairWeight/RelationWeight; this closes the equivalent race at the relationship level). MERGE (ea)-[r:CO_OCCURS_WITH]-(eb) matched only on the two Entity endpoints and relationship type - not atomic against concurrent writers. Fixed via the same composite_key pattern, backed by a relationship-level uniqueness constraint, plus backfill_co_occurs_with_composite_key() for pre-existing relationships. Since CO_OCCURS_WITH is undirected and entity-pair order wasn't canonicalized anywhere in the existing code, the composite_key is computed from a sorted entity pair | Live concurrency test (8 concurrent upsert_document() calls, 8 different document_ids, all mentioning the same two entities) against real Neo4j: exactly 1 CO_OCCURS_WITH relationship resulted, with composite_key correctly set. Full suite: 79 passed, 12 skipped |
Architecture
flowchart TD
classDef write fill:#1e3a5f,stroke:#4a90d9,color:#fff
classDef read fill:#1a4d3a,stroke:#22c55e,color:#fff
classDef hybrid fill:#3d2645,stroke:#a855f7,color:#fff
classDef store fill:#4d3319,stroke:#f59e0b,color:#fff
subgraph Write["Write path"]
Doc["graph.upsert_document(...)"]:::write --> Extract["Entity/relation extraction
regex default, LLM via method='llm'"]:::write
Extract --> Dedup["EntityDeduplicator (optional)
dedup_enabled=True"]:::write
end
Dedup --> Neo4j["Neo4j graph
Entity, Document,
PairWeight, RelationWeight"]:::store
Neo4j --> Edges["CONTAINS, CO_OCCURS_WITH,
RELATES_AS edges"]:::store
subgraph Read["Read path"]
FindDocs["find_documents_by_entities()"]:::read
SearchRel["search_related_entities()"]:::read
FindRel["find_relations()"]:::read
FindType["find_entities_by_type()"]:::read
Lineage["find_lineage()
per-document contribution lookup"]:::read
end
Neo4j --> FindDocs
Neo4j --> SearchRel
Neo4j --> FindRel
Neo4j --> FindType
Neo4j --> Lineage
subgraph HybridRet["Hybrid retrieval"]
Retriever["GraphRetriever(graph, rag)"]:::hybrid --> VectorChunks["Vector chunks (ragleap-rag)"]:::hybrid
Retriever --> GraphContext["Graph context
related_documents, related_entities"]:::hybrid
VectorChunks --> Combined["Combined result
already_in_vector_results flag"]:::hybrid
GraphContext --> Combined
end
Full public API
from ragleap_graph import (
GraphConfig, GraphIndex,
ExtractionConfig, ExtractedEntity, EntityDeduplicator, LLMEntityExtractor,
ExtractedRelation, LLMRelationExtractor,
GraphRetriever, GraphRetrievalConfig,
)
graph = GraphIndex(
config=GraphConfig(uri="bolt://localhost:7687", user="neo4j", password="..."),
extraction=ExtractionConfig(
method="regex" | "llm", # default "regex", zero deps
provider=ProviderConfig(...), # required if method="llm"
dedup_enabled=False, # v0.2.0
dedup_threshold=0.92, # empirically tuned
extract_relations=False, # v0.4.0, requires method="llm"
entity_types=None, # v0.5.0, e.g. ["Customer","Product"]
),
)
Core (v0.1.0)
graph.upsert_document(document_id, title, chunks, namespace=None, domain_terms=None)Extract entities, build/update the graph for a document.
graph.find_documents_by_entities(entity_names, namespace=None, limit=25)Find documents mentioning given entities.
graph.search_related_entities(entity_names, namespace=None, max_depth=2, limit=10)Graph-walk from entities to related entities.
graph.document_entities(document_id, namespace=None)List entities extracted from a document.
graph.extract_query_entities(query, max_entities=10, domain_terms=None)Extract entities from a query string.
graph.health_check()Verify Neo4j connectivity.
Relations (v0.4.0)
graph.find_relations(entity_name, relation_type=None, namespace=None, limit=25, direction="outgoing")direction="outgoing" (default), "incoming", or "both" — reverse lookup added in v0.5.3.
Entity typing (v0.5.0)
graph.find_entities_by_type(entity_type, namespace=None, limit=25)Case-insensitive type lookup (v0.6.2) — "customer" matches a stored "Customer" or "CUSTOMER".
graph.find_lineage(entity_a, entity_b, relation_type=None, namespace=None, limit=25)Per-document contributions to the edge(s) between two entities (v0.6.4) — surfaces the PairWeight/RelationWeight tracking nodes added in v0.6.0. Entity order does not matter.
GraphRetriever (v0.3.0)
Separate from GraphIndex — composes a GraphIndex with an existing RagLeap instance via ragleap-rag's retrieve() method.
retriever = GraphRetriever(graph=graph, rag=rag, config=GraphRetrievalConfig(mode="hybrid"|"graph_only"))
result = retriever.retrieve(query, top_k=5, namespace=None, metadata_filter=None)
# -> {"chunks":[...], "query_entities":[...], "graph_context":{...}, "citations":[...], "retrieval_method":...}
# graph_context.related_documents entries include already_in_vector_results (v0.6.1) -
# flags rather than removes documents also present in chunks, so graph-relationship
# context (matched entities, graph score) is never silently discarded.
Real bugs & design decisions this session
Not fabricated wins — each of these was caught by a real test failure or a real live-verification step.
- ✓ fixedDedup threshold false-positive (v0.2.0). Default 0.85 incorrectly merged "Neo4j" and "Neo 4j." Tested against 0.85/0.90/0.92/0.95 — 0.92 clears the false positive without breaking true positives ("T.C. Antony" / "TC Antony").
- ✓ fixedTest-harness bug (v0.2.0). Mock cache-busting was keyed to the wrong module path — five LLM-extraction tests were silently making real network calls instead of using the mock. Fixed, with a canary assertion added so this can't recur silently.
- documented, not hiddenRegex extraction fragments "ACME Corp." into disconnected candidates ("ACME" + "Corp") alongside the correct "Acme Corp" elsewhere. Dedup correctly declines to merge these (similarity ~0.67, below 0.92) — a real side-by-side test confirms
method="llm"avoids this at the source. - ✓ shippedGraphRetriever design decision. Built on
RagLeap.retrieve()(new public method added toragleap-ragspecifically for this) rather than reaching intoRagLeap's private internals. - ✓ shippedRelation hallucination filtering (v0.4.0).
LLMRelationExtractordefensively drops any relation referencing an entity outside the caller-suppliedknown_entitieslist, and drops self-relations, even though the prompt already constrains this. - ✓ fixedEntity typing gap closed (v0.5.0).
LLMEntityExtractorcomputed a per-entitytypefield since v0.2.0, butGraphIndexdiscarded it before it reached Neo4j. Now actually used. - ✓ shippedRe-upsert type-downgrade guard (v0.5.0). A later regex-only upsert of the same document won't overwrite an already-recorded LLM-derived
entity_typewith "UNKNOWN," via acoalesce(NULLIF(...))pattern in the write query. - ✓ fixedSentence-initial stopword bug (v0.5.2). Regex extraction picked up capitalized sentence-initial words ("What," "Who," "The") as spurious entities, since English capitalizes a sentence's first word regardless of whether it's a proper noun. "What did Acme Corp launch?" previously returned
['What', 'Acme Corp']instead of just['Acme Corp']. Fixed with a narrow stopword filter applied only to exact single-word matches — multi-word entities unaffected. Reproduced live before fixing, regression test written and confirmed failing first. - ✓ fixedfind_relations() reverse-lookup gap (v0.5.3). Only ever searched outgoing relations — searching from the object side silently returned
[]even when the entity was clearly involved in a real relation. Addeddirection=parameter ("outgoing" default, "incoming", "both"). Zero prior test coverage existed for this method at all; backfilled alongside the fix. Reproduced live, regression test confirmed failing first. - ✓ fixedupsert_document() CONTAINS idempotency bugs (v0.5.4). Docstring claimed writes were "idempotent — safe to re-run," which was false: re-upserting identical content doubled CONTAINS weight every call, and re-upserting changed content left stale entity links in place forever. Fixed by deleting a document's CONTAINS edges before rewriting them per upsert. Both bugs reproduced live before any code changed.
- ✓ fixedCO_OCCURS_WITH/RELATES_AS idempotency, the harder half (v0.6.0). These edges deliberately aggregate weight across multiple documents by design, so the simple CONTAINS-style fix would have broken that aggregation. Added
PairWeight/RelationWeightnode types to track each document's contribution separately, recomputing the shared edge's weight as a sum whenever it changes. Three behaviors verified live: identical re-upsert doesn't double weight, a genuinely different document sharing the pair correctly aggregates, and removing a document's contribution correctly drops the total. - ✓ fixedGraphRetriever vector/graph overlap gap (v0.6.1). A document could surface through both vector search and graph traversal with no way for the caller to know — real overlap information silently discarded. Added
already_in_vector_resultsto eachrelated_documentsentry rather than removing overlaps outright, so graph-relationship context is never lost. Test-first with the existing fake-object test doubles, no live services needed. - ✓ fixedfind_entities_by_type() case-sensitivity (v0.6.2). Did exact-string-match only — "Customer" and "customer" were treated as different types even though entity_type is stored exactly as the model produced it with no casing normalization at write time. Now case-insensitive. Reproduced live before fixing, regression test confirmed failing first.
- ✓ fixedentity_types= enforcement (v0.6.3). Was guidance only — a model returning a type outside the caller-supplied list was accepted as-is. Now enforced: any out-of-vocabulary type is coerced to "UNKNOWN", the same value regex-extracted entities already use. Only applies when entity_types= is actually set; without it, behavior is unchanged.
- ✓ fixedCO_OCCURS_WITH relationship-MERGE race (v0.6.8). The v0.6.7 fix closed four node-level concurrency races (Document/Entity/PairWeight/RelationWeight) but deliberately left this one open, noted as a separate known risk at the time.
MERGE (ea)-[r:CO_OCCURS_WITH]-(eb)matched only the two Entity endpoints, not atomic against concurrent writers. Fixed via the same composite_key pattern used for the four node types, with entity-pair order canonicalized (sorted) since the relationship is undirected. Live-verified via an 8-thread concurrency test using 8 different documents sharing the same entity pair — exactly 1 relationship resulted.
Known limitations
From the package's own CHANGELOG.md, current as of v0.6.7.
- No relationship-type ontology constraints — relation types and entity types aren't cross-validated against each other.
- Relation extraction runs once per chunk — relations spanning multiple chunks of the same document aren't identified.