Modeling a 78-card symbolic dataset as a Neo4j knowledge graphCollaboration

Hi everyone,

I wanted a small dataset for experimenting with graph modeling, categorical relationships and full-text search in the same Neo4j database.

The source contains 78 tarot cards with arcana, suit, element, symbolic correspondence and several meaning fields. A table works for simple lookup, but a graph makes it easier to explore cards connected through shared categories.

The data comes from the DeckAura open tarot card dataset.

Graph model

(:Card)-[:IN_ARCANA]->(:Arcana)
(:Card)-[:IN_SUIT]->(:Suit)
(:Card)-[:HAS_ELEMENT]->(:Element)
(:Card)-[:HAS_CORRESPONDENCE]->(:Correspondence)

The longer upright, reversed, love and career meanings remain properties on the Card nodes.

Constraints

Run these before importing the CSV:

CREATE CONSTRAINT card_number IF NOT EXISTS
FOR (card:Card)
REQUIRE card.number IS UNIQUE;
CREATE CONSTRAINT arcana_name IF NOT EXISTS
FOR (arcana:Arcana)
REQUIRE arcana.name IS UNIQUE;
CREATE CONSTRAINT suit_name IF NOT EXISTS
FOR (suit:Suit)
REQUIRE suit.name IS UNIQUE;
CREATE CONSTRAINT element_name IF NOT EXISTS
FOR (element:Element)
REQUIRE element.name IS UNIQUE;
CREATE CONSTRAINT correspondence_name IF NOT EXISTS
FOR (correspondence:Correspondence)
REQUIRE correspondence.name IS UNIQUE;

Import the dataset

LOAD CSV WITH HEADERS FROM
'https://huggingface.co/datasets/Blacik/deckaura-tarot-card-meanings/resolve/main/tarot_card_meanings.csv'
AS row

MERGE (card:Card {
  number: toInteger(row.card_number)
})
SET
  card.name = row.card_name,
  card.upright = row.upright_meaning,
  card.reversed = row.reversed_meaning,
  card.love = row.love_meaning,
  card.career = row.career_meaning,
  card.yesOrNo = row.yes_or_no,
  card.guideUrl = row.guide_url

MERGE (arcana:Arcana {
  name: row.arcana
})
MERGE (card)-[:IN_ARCANA]->(arcana)

MERGE (element:Element {
  name: row.element
})
MERGE (card)-[:HAS_ELEMENT]->(element)

FOREACH (
  ignored IN
  CASE
    WHEN trim(coalesce(row.suit, '')) <> ''
    THEN [1]
    ELSE []
  END |

  MERGE (suit:Suit {
    name: row.suit
  })
  MERGE (card)-[:IN_SUIT]->(suit)
)

FOREACH (
  ignored IN
  CASE
    WHEN trim(coalesce(row.zodiac_sign, '')) <> ''
    THEN [1]
    ELSE []
  END |

  MERGE (correspondence:Correspondence {
    name: row.zodiac_sign
  })
  MERGE (card)-[:HAS_CORRESPONDENCE]->(correspondence)
);

Using MERGE makes the import safe to run again without creating duplicate nodes or relationships.

Check the imported graph

MATCH (card:Card)
RETURN count(card) AS cards;

The expected result is:

78

To inspect the category counts:

MATCH (card:Card)-[relationship]->(category)
RETURN
  type(relationship) AS relationshipType,
  labels(category)[0] AS categoryLabel,
  count(*) AS relationships
ORDER BY relationshipType;

Find cards with a shared element

MATCH
  (card:Card {name: 'The Magician'})
  -[:HAS_ELEMENT]->
  (element:Element)
  <-[:HAS_ELEMENT]-
  (related:Card)

WHERE related <> card

RETURN
  related.name AS card,
  element.name AS sharedElement,
  related.upright AS uprightMeaning

ORDER BY related.name;

Add full-text search

CREATE FULLTEXT INDEX card_meanings IF NOT EXISTS
FOR (card:Card)
ON EACH [
  card.name,
  card.upright,
  card.reversed,
  card.love,
  card.career
]
OPTIONS {
  indexConfig: {
    `fulltext.analyzer`: 'english'
  }
};

The index may briefly remain in the POPULATING state. Its status can be checked with:

SHOW FULLTEXT INDEXES;

Once it is online:

CALL db.index.fulltext.queryNodes(
  'card_meanings',
  'change transformation'
)
YIELD node AS card, score

OPTIONAL MATCH
  (card)-[:HAS_ELEMENT]->(element:Element)

RETURN
  card.name AS card,
  element.name AS element,
  card.upright AS uprightMeaning,
  score

ORDER BY score DESC
LIMIT 10;

This combines Lucene full-text scoring with graph traversal without duplicating category names inside every document.

The modeling question I am still considering is whether recurring concepts such as transformation, balance and conflict should become their own nodes. That would enable concept traversal, but extracting those concepts consistently requires more processing than the current categorical model.

Would you keep meaning concepts as text properties, or promote selected concepts into nodes and relationships?

1 Like