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?