Getting Started with AstraeaDB

A Metadata Graph Over a Messy Data Lake

On this page

Describe your files in a graph, then ask the graph which of them can answer your question.

Every graph so far has described things written in prose. This one describes files. The problem it solves is one you have probably met: a folder with years of exports in it, where the same idea is called cust_id in one file, customer_id in the next, and CustomerID in a third, and nobody remembers which files still matter.

You cannot fix that by renaming things, because the files are already written. You can describe it, once, in a graph, and then let the graph answer "where does customer revenue live" instead of asking a colleague who has left.

A small, realistic mess

Three files, standing in for three years of a system that changed underneath you:

import csv
import json
import os
import pathlib

LAKE = pathlib.Path("/tmp/lake")
LAKE.mkdir(exist_ok=True)

# 2023: the original export, from a system since retired.
with (LAKE / "sales_2023.csv").open("w", newline="") as fh:
    w = csv.writer(fh)
    w.writerow(["cust_id", "amt", "dt"])
    w.writerows([["C001", "120.50", "2023-03-04"],
                 ["C002", "89.00", "2023-07-19"],
                 ["C001", "310.25", "2023-11-02"]])

# 2024: same data, new platform, different column names.
with (LAKE / "sales_2024.csv").open("w", newline="") as fh:
    w = csv.writer(fh)
    w.writerow(["customer_id", "amount", "order_date"])
    w.writerows([["C001", "205.00", "2024-02-11"],
                 ["C003", "45.75", "2024-06-30"]])

# A customer list, in a different format again.
(LAKE / "customers.json").write_text(json.dumps([
    {"CustomerID": "C001", "name": "Acme Ltd", "region": "north"},
    {"CustomerID": "C002", "name": "Borden Co", "region": "south"},
    {"CustomerID": "C003", "name": "Crane plc", "region": "north"},
]))

print(sorted(p.name for p in LAKE.iterdir()))

Three files, three spellings of a customer identifier, two spellings of a money column. Nothing about the files themselves says these are related.

Describing the files as a graph

The metadata graph uses three kinds of node. A DataSource is a file. A Field is a column in one. A Concept is the business idea a field represents, and it is the piece that does the real work, because it is the only thing that knows cust_id and CustomerID mean the same thing.

from astraeadb import AstraeaClient

OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
import urllib.request


def embed(texts):
    body = json.dumps({"model": "embeddinggemma", "input": texts}).encode()
    req = urllib.request.Request(f"{OLLAMA_URL}/api/embed", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=60) as resp:
        return json.load(resp)["embeddings"]


client = AstraeaClient(host="127.0.0.1", port=7687)
client.connect()

CONCEPTS = {
    "Customer":     "The organisation that placed an order.",
    "Order amount": "The monetary value of a single order.",
    "Order date":   "The calendar date an order was placed.",
}
concepts = {}
for (name, desc), vec in zip(CONCEPTS.items(), embed(list(CONCEPTS.values()))):
    concepts[name] = client.create_node(
        ["Concept"], {"name": name, "description": desc}, embedding=vec)

SOURCES = {
    "sales_2023.csv": ("csv", [("cust_id", "Customer"), ("amt", "Order amount"),
                               ("dt", "Order date")]),
    "sales_2024.csv": ("csv", [("customer_id", "Customer"), ("amount", "Order amount"),
                               ("order_date", "Order date")]),
    "customers.json": ("json", [("CustomerID", "Customer")]),
}

sources, fields = {}, {}
for fname, (fmt, cols) in SOURCES.items():
    sources[fname] = client.create_node(
        ["DataSource"], {"name": fname, "format": fmt, "path": str(LAKE / fname)})
    for col, concept in cols:
        fid = client.create_node(["Field"], {"name": col, "source": fname})
        fields[(fname, col)] = fid
        client.create_edge(sources[fname], fid, "HAS_FIELD")
        client.create_edge(fid, concepts[concept], "MAPS_TO_CONCEPT")

print(len(sources), "sources,", len(fields), "fields,", len(concepts), "concepts")

Only the Concept nodes carry embeddings here. Fields and sources are found by walking edges, not by similarity, so there is nothing to gain from embedding them.

Two edges that record what people remember

A schema registry would stop at the structure above. The part that usually lives in somebody's head is the history, and it fits naturally as two more edges.

SUCCEEDED_BY says one source replaced another. SAME_ENTITY_AS says two differently named fields hold the same real-world identifier, so a value from one can be joined to a value from the other:

client.create_edge(sources["sales_2023.csv"], sources["sales_2024.csv"], "SUCCEEDED_BY")

for a, b in [(("sales_2023.csv", "cust_id"), ("sales_2024.csv", "customer_id")),
             (("sales_2024.csv", "customer_id"), ("customers.json", "CustomerID"))]:
    client.create_edge(fields[a], fields[b], "SAME_ENTITY_AS")
    client.create_edge(fields[b], fields[a], "SAME_ENTITY_AS")

print("history and identity recorded")

Note the two calls in that loop. Sameness runs both ways, and the previous post is the reason to be careful here: traversal follows edge direction, so a one-way SAME_ENTITY_AS would only be findable from one end.

Asking the graph which files can answer a question

Now the payoff. A question arrives in ordinary words. Find the concept it is about, then walk back to the fields, then to the files:

QUESTION = "How much has each customer spent in total?"

for hit in client.vector_search(embed([QUESTION])[0], k=3):
    name = client.get_node(hit["node_id"])["properties"]["name"]
    print(f'{hit["distance"]:.3f}  {name}')

Look at those three distances before going further, because they carry a warning. They come out close together, and Customer is likely to be last, even though the question says the word "customer" outright. Concept descriptions are one short sentence each, so there is little for the model to tell apart, and the ranking is nearly a tie.

The practical consequence is that a tight k will silently drop a concept you needed. Take a generous number of concepts and let the graph structure do the filtering, rather than trusting the embedding to be precise about a handful of near-identical sentences:

hits = client.vector_search(embed([QUESTION])[0], k=len(CONCEPTS))

plan = {}
for h in hits:
    for f in client.neighbors(h["node_id"], direction="incoming",
                              edge_type="MAPS_TO_CONCEPT"):
        field = client.get_node(f["node_id"])["properties"]
        plan.setdefault(field["source"], []).append(field["name"])

for source, cols in sorted(plan.items()):
    print(f"  {source}: {sorted(cols)}")

The graph has now told you which files are relevant and which column in each one carries the idea you asked about, without you knowing any of their names. That is the whole point of the metadata layer: the question was about customers and spending, not about amt and cust_id.

Running the query it planned

DuckDB reads comma-separated and JSON files directly, so the plan turns into a query without loading anything first:

import duckdb

# Ask the graph which column in each file carries which concept, rather than
# hard-coding names the whole lesson has been arguing you should not need.
def column_for(source, concept):
    for f in client.neighbors(concepts[concept], direction="incoming",
                              edge_type="MAPS_TO_CONCEPT"):
        props = client.get_node(f["node_id"])["properties"]
        if props["source"] == source:
            return props["name"]
    return None


parts = []
for source in sorted(s for s in plan if s.startswith("sales_")):
    cust = column_for(source, "Customer")
    amt = column_for(source, "Order amount")
    parts.append(f"SELECT {cust} AS customer, {amt} AS amount "
                 f"FROM read_csv_auto('{LAKE / source}')")

sql = ("SELECT customer, ROUND(SUM(amount), 2) AS total "
       f"FROM ({' UNION ALL '.join(parts)}) GROUP BY customer ORDER BY customer")
print(duckdb.sql(sql))

Both years are included, with their different column names reconciled, because the graph said the two files describe the same concepts and their identifier fields are the same entity. Nobody had to remember that amt became amount.

Where this stops being a toy

Three files fit on a page. A real lake has hundreds, and two things change.

The concept mapping has to be produced rather than typed. You would use the same technique as the previous posts: embed each column name together with a few sample values, and match it against the concept descriptions. That works well and it is wrong often enough that the mapping needs review, which is why the MAPS_TO_CONCEPT edge is worth storing rather than recomputing.

And the query planning gets handed to a model. The graph output above is small, structured, and factual, which makes it good context: you hand a model the relevant sources, their fields, and the concepts, and ask for SQL. The data_lake_demo repository does exactly that, over a larger and messier lake, with an agent that plans DuckDB queries from this same graph shape.

client.close()

What's next

You have used a graph to describe data rather than to hold it, which is a different use of the same tool and often the more valuable one.

The next two posts are shorter, and introduce two projects built on AstraeaDB. In Eunomia: A Semantic Cache in Front of Your Graph, you will see what happens when the same question gets asked twice.