RagLeap Packages
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.

VersionWhat shippedReal live test performed
v0.1.0GraphConfig, GraphIndex, regex entity extraction, co-occurrence graphsReal Neo4j roundtrip
v0.2.0ExtractionConfig, LLMEntityExtractor, EntityDeduplicatorReal Gemini call + real isolated Neo4j write
v0.3.0GraphRetriever, GraphRetrievalConfig — hybrid vector+graph retrievalReal Postgres + real Neo4j
v0.3.1PyPI metadata fix onlyN/A — no code change
v0.4.0LLMRelationExtractor, ExtractedRelation, find_relations(), RELATES_AS edgesReal Gemini call correctly identified REPORTED / PARTNERED_WITH, real Neo4j write + read-back
v0.5.0entity_types=, entity_type on graph nodes, find_entities_by_type()Real Gemini call with entity-type guidance, real Neo4j write + read-back
v0.5.1PyPI metadata fix — added missing Documentation URLN/A — no code change
v0.5.2Fixed sentence-initial stopword bug in regex entity extractionReproduced live, regression test written first (confirmed failing), fix applied, same test confirmed passing, full suite re-run
v0.5.3Added direction= to find_relations() — closes reverse-lookup limitationReal Neo4j writes, regression test confirmed failing first, fix applied, same test confirmed passing, full suite re-run
v0.5.4Fixed 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.0Per-document contribution tracking for CO_OCCURS_WITH/RELATES_AS — new PairWeight/RelationWeight node typesThree behaviors verified live: idempotent re-upsert, real cross-document aggregation, correct removal on content change. RELATES_AS tested with real Ollama call
v0.6.1GraphRetriever.retrieve() now flags (not removes) overlap between vector-chunk results and graph-derived related_documents via already_in_vector_resultsTest-first with existing FakeRag/FakeGraph doubles, no live services needed
v0.6.2find_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.3entity_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.4Added find_lineage(entity_a, entity_b, relation_type=None, namespace=None, limit=25) — exposes per-document PairWeight/RelationWeight contributions, previously unreachable via any public methodLive-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.5Added 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 dataLive-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.6Added 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 moduleLive-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.7Fixed 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 dataReproduced 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.0Implemented 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.0Implemented 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.0Implemented 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 change10 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.9Fixed 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_WITHLive 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.8Fixed 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 pairLive 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.

  • ✓ fixed
    Dedup 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").
  • ✓ fixed
    Test-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 hidden
    Regex 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.
  • ✓ shipped
    GraphRetriever design decision. Built on RagLeap.retrieve() (new public method added to ragleap-rag specifically for this) rather than reaching into RagLeap's private internals.
  • ✓ shipped
    Relation hallucination filtering (v0.4.0). LLMRelationExtractor defensively drops any relation referencing an entity outside the caller-supplied known_entities list, and drops self-relations, even though the prompt already constrains this.
  • ✓ fixed
    Entity typing gap closed (v0.5.0). LLMEntityExtractor computed a per-entity type field since v0.2.0, but GraphIndex discarded it before it reached Neo4j. Now actually used.
  • ✓ shipped
    Re-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_type with "UNKNOWN," via a coalesce(NULLIF(...)) pattern in the write query.
  • ✓ fixed
    Sentence-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.
  • ✓ fixed
    find_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. Added direction= 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.
  • ✓ fixed
    upsert_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.
  • ✓ fixed
    CO_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/RelationWeight node 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.
  • ✓ fixed
    GraphRetriever 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_results to each related_documents entry 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.
  • ✓ fixed
    find_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.
  • ✓ fixed
    entity_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.
  • ✓ fixed
    CO_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.