Ein Ordner voller gescrapter Unternehmensseiten sagt Ihnen, was jede Website über sich selbst erzählt. Eine Frage über diese Seiten hinweg zu beantworten braucht einen Schritt mehr: erkennen, dass dieselben Personen, Tools und Städte auf mehreren Sites auftauchen, und sie mit den Unternehmen verbinden, die sie erwähnen. Welche Unternehmen teilen sich eine Integration? Wer wird auf mehr als einer Site genannt? Wo überschneiden sich ihre Standorte?

Dieser Beitrag baut eine Python-Pipeline, die mit der Crawling API die relevanten Seiten einer Unternehmenssite entdeckt, sie als Markdown abruft, ein LLM Entitäten und Beziehungen in ein festes Schema extrahieren lässt, prüft, ob jedes Belegzitat wirklich auf der Seite steht, Namen normalisiert und das Ergebnis in Neo4j lädt, und zwar mit MERGE. Cypher beantwortet dann die unternehmensübergreifenden Fragen, und eine kleine GraphRAG-Schicht macht aus einer Frage in natürlicher Sprache eine Query und eine Antwort, die ihre Quellseiten zitiert.

Der Code liegt in ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j: neun Walkthrough-Skripte unter steps/, eines pro Transformation, sowie die paketierte CLI mit Caching, Docker-Konfiguration und Tests unter final/. Die Zahlen unten stammen aus aufgezeichneten Läufen gegen Basecamp, HEY, Pipedrive und Zendesk am 29. Oktober 2026.

Die Kurzfassung
  • Jede Beziehung im Graphen behält die Seiten-URL, von der gespeicherten Seite die rid, den Extraktionszeitstempel und das Zitat, das sie belegt, sodass sich jede Kante bis zum Markdown zurückverfolgen lässt, aus dem sie entstanden ist.
  • Das LLM schlägt vor; die Anwendung entscheidet. Ein Zitat, das nicht auf der erfassten Seite steht, verwirft die Entität, Namen werden pro Label normalisiert, und MERGE macht ein zweites Laden derselben Daten zu einem No-op.
  • Weil das Markdown in Cloud Storage bleibt, läuft ein neues Schema oder ein neuer Prompt ohne neuen Crawl gegen dieselben Captures.
Jede Stufe hat ihre eigene Evidenzgrenze. Crawlbase holt die Seiten und bewahrt sie auf; die Anwendung interpretiert sie und besitzt den Graphen.

Modellieren Sie die Beziehungen, die Sie abfragen wollen

Ein Knowledge Graph speichert Entitäten als Knoten und verbindet sie über typisierte Beziehungen. Hier steht eine Person über WORKS_AT in Beziehung zu einer Company, und eine Company über INTEGRATES_WITH zu einer Integration. Relationale Tabellen können dieselben Fakten halten; der Grund für Neo4j ist, dass eine Traversierung zu einem sichtbaren Muster wird. (a:Company)-[:INTEGRATES_WITH]->(i)<-[:INTEGRATES_WITH]-(b:Company) liest sich als „zwei Unternehmen, die mit derselben Integration verbunden sind“, und bleibt lesbar, wenn die Fragen weitere Hops bekommen.

Das Vokabular ist bewusst klein: fünf Labels (Company, Person, Product, Integration, Location) und fünf Beziehungstypen (WORKS_AT, OFFERS, INTEGRATES_WITH, PARTNERS_WITH, LOCATED_IN). Ein kleines Vokabular lässt sich bei der Extraktion leichter validieren und später leichter eingrenzen, wenn ein Modell Cypher schreibt.

Walkthrough und Datenbank vorbereiten

Sie brauchen Python 3.11+, Docker mit Compose, ein Crawlbase-Konto mit seinem Normal token und JavaScript token sowie einen OpenAI-API-Key. Das Repository verwendet gpt-4o-mini mit Pydantic Structured Outputs und betreibt Neo4j 5 Community lokal; die Versionen der Abhängigkeiten sind gepinnt in steps/requirements.txt. Klonen Sie es, legen Sie eine virtuelle Umgebung an und kopieren Sie die beiden Umgebungsvorlagen:

bash
git clone https://github.com/ScraperHub/build-a-knowledge-graph-from-crawled-websites-with-neo4j.git
cd build-a-knowledge-graph-from-crawled-websites-with-neo4j
python -m venv .venv
source .venv/bin/activate
cp steps/.env.example steps/.env
cp final/.env.example final/.env

Tragen Sie in beide Dateien namens .env dasselbe Neo4j-Passwort ein, bevor Sie die Datenbank starten. Die Walkthrough-Skripte lesen steps/.env; Docker Compose und die paketierte CLI lesen final/.env. Beide verwenden dieselben Schlüssel:

text
CRAWLBASE_TOKEN=YOUR_CRAWLBASE_TOKEN
CRAWLBASE_JS_TOKEN=YOUR_CRAWLBASE_JS_TOKEN
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
OPENAI_MODEL=gpt-4o-mini
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=YOUR_NEO4J_PASSWORD

Installieren Sie dann die Walkthrough-Abhängigkeiten und starten Sie Neo4j:

bash
pip install -r steps/requirements.txt
cd final && docker compose up -d --wait

Führen Sie die Skripte in numerischer Reihenfolge aus dem Ordner steps/. Jedes Skript schreibt seine Ausgabe als JSON unter steps/.cache/, und die späteren lesen, was die früheren geschrieben haben. Verwenden Sie eine eigene Tutorial-Datenbank: 06_load_into_neo4j.py löscht alle vorhandenen Graphdaten, bevor es zwei aufeinanderfolgende Ladevorgänge demonstriert. Der Neo4j Browser ist erreichbar unter http://localhost:7474.

Die Discovery fordert jede Homepage an mit scraper=generic-extractor, einem der Crawlbase-Scraper, und liest die zurückgegebenen Links. Eine Erkenntnis aus den aufgezeichneten Läufen sollten Sie kennen, bevor Sie Code gegen die Doku schreiben: Das dokumentierte Schema nennt Felder wie main_content und headings, während die Live-Antworten unter anderem diese Felder enthielten: title, meta, content, images und links. Das SDK stellt die geparste Seite bereit als response["json"]. Über fünf aufgezeichnete Homepages hinweg waren 422 von 424 Links bereits absolute HTTP(S)-URLs; die anderen beiden waren mailto: und tel:. Der Code löst relative Links trotzdem mit urljoin auf und verwirft Schemata, die keine Seiten sind, damit er weiter funktioniert, falls sich die Form ändert.

Das erste Skript behält Links desselben Hosts, deren letztes Pfadsegment auf ein Unternehmensseiten-Muster passt:

python
PAGE_TYPES = {
    "about": r"^(about|company|our-story)",
    "team": r"^(team|our-team|leadership|people)",
    "partners": r"^partner",
    "integrations": r"^(integration|marketplace)",
    "careers": r"^(career|jobs)",
}
MAX_PAGES = 8


def host(url: str) -> str:
    return (urlsplit(url).hostname or "").lower().removeprefix("www.")


def page_type(url: str) -> str | None:
    segments = [s for s in urlsplit(url).path.lower().split("/") if s]
    for name, pattern in PAGE_TYPES.items():
        if segments and re.match(pattern, segments[-1]):
            return name
    return None

Der Abgleich auf das letzte Segment bedeutet, dass /company/legal nicht schon deshalb eine About-Seite ist, weil darüber ein Ordner liegt namens company. Das Limit gilt für entdeckte Seiten; die Homepage wird separat abgerufen. Das ist eine begrenzte Discovery über Homepage-Links: Seiten hinter Paginierung, tieferer Navigation oder auf einer Careers-Subdomain brauchen zusätzliche Regeln.

Der SDK-Aufruf übergibt sowohl die Seed-Homepage als auch die URL, auf der die Antwort tatsächlich gelandet ist, an die Discovery, und beide erfüllen unterschiedliche Zwecke. In einer aufgezeichneten Zendesk-Anfrage landete die Site per Redirect auf zendesk.de und ihre Navigation verwies weiterhin auf zendesk.com; ein Filter auf den finalen Host verwarf jeden relevanten Link, ein Filter auf den Seed-Host behielt sie, und die finale URL blieb die Basis zum Auflösen relativer Pfade. Crawlbase wählt das Ausgangsland für Sie, behandeln Sie Redirects also explizit, statt den Landing-Host vorauszusetzen. Der Walkthrough lieferte:

text
Basecamp: 52 links, 2 company pages (landed on https://basecamp.com/)
  about         https://basecamp.com/about
  integrations  https://basecamp.com/integrations
HEY: 54 links, 0 company pages (landed on https://www.hey.com/)
Pipedrive: 103 links, 4 company pages (landed on https://www.pipedrive.com/)
  integrations  https://www.pipedrive.com/en/marketplace
  about         https://www.pipedrive.com/en/about
  careers       https://www.pipedrive.com/en/jobs
  partners      https://www.pipedrive.com/en/partner
Zendesk: 125 links, 4 company pages (landed on https://www.zendesk.com/)
  integrations  https://www.zendesk.com/marketplace
  ...

Die Homepage von HEY passte in diesem Lauf auf keine Unternehmensseiten-URL, also ging nur ihre Homepage weiter. Das beschreibt die Abdeckung der Regel, nicht die Site. Das paketierte Discovery-Modul sortiert zudem flache Pfade zuerst und verteilt seine Auswahl über Seitentypen.

Markdown abrufen und den Input hinter jedem Fakt behalten

Die Extraktion braucht die eigentlichen Seiten, einschließlich der Team-Raster und Integrationsverzeichnisse, die eine Homepage-Zusammenfassung auslässt. Die Fetch-Stufe stellt Anfragen mit format=md und store=true, und behält neben dem Text selbst auch die rid als Schlüssel, unter dem Cloud Storage das Markdown vorhält.

Die Readability-Filterung kürzt Navigation und Boilerplate, aber die aufgezeichneten Aufrufe zeigen, dass sie auch genau die strukturierten Abschnitte entfernen kann, die dieser Graph braucht:

Seite md_readability=true Reines format=md
close.com/about 469 Zeichen, Leadership-Raster fehlt 13.722 Zeichen
pipedrive.com/en/about 504 Zeichen 11.187 Zeichen
close.com/integrations 260 Zeichen 41.290 Zeichen

Deshalb versucht der Fetch-Schritt zuerst Readability, fällt auf reines Markdown zurück und zuletzt auf den JavaScript token:

python
def fetch_page(url: str) -> dict:
    """Readability first, plain Markdown if that drops the content, JS token if the page renders in the browser."""
    for token, readability, min_chars in ((CRAWLBASE_TOKEN, True, 1500),
                                          (CRAWLBASE_TOKEN, False, 300),
                                          (CRAWLBASE_JS_TOKEN, False, 0)):
        response = fetch(url, token, readability)
        if len(response.text) >= min_chars:
            break
    rid = httpx.URL(response.headers.get("storage_url", "")).params.get("rid", "")
    return {"url": url, "markdown": response.text, "rid": rid, "readability": readability,
            "js": token == CRAWLBASE_JS_TOKEN}

Die Schwellen von 1.500 und 300 Zeichen sind Anwendungsheuristiken: Eine lange Navigationshülle kann sie ohne die gewünschten Daten passieren, und eine kurze legitime Seite kann einen unnötigen Retry auslösen. Für Ziele, von denen Sie bereits wissen, dass sie im Browser rendern, akzeptiert die paketierte CLI use_js=true in der Seed-CSV und beginnt mit dem JavaScript token. Die aufgezeichneten Vergleiche zeigen, warum Rendering zielspezifisch sein sollte: Die Markdown-Länge war bei Linear, Monday und Figma mit beiden Tokens identisch und bei Notion nahezu gleich, die Integrationsseite von Linear lieferte so oder so 24.218 Zeichen, während quotes.toscrape.com/js mit dem Normal token 42 Zeichen lieferte und mit dem JavaScript token 887. Moderne Unternehmenssites rendern meist auf dem Server; das beweist aber nicht, dass jede Site das tut.

Zwei Details der Antwort sind für die Provenienz wichtig. Stellen Sie eine Anfrage mit format=md und store=true, dann kommt die rid innerhalb des Response-Headers storage_url an statt als eigener Header, und diese URL enthält das Konto-Token, weshalb der Code nur die rid behält und die URL nie loggt. Er verwendet außerdem httpx für diese Abrufe, weil das gepinnte Crawlbase Python SDK aus einer Antwort nur original_status, cb_status und url behält, ohne storage_url. Jeder Fallback ist eine separate Anfrage mit eigener rid, die behaltene rid muss also zu dem Markdown gehören, aus dem Sie tatsächlich extrahieren:

text
Basecamp     3253 chars  readability  rid=abac8926164450fab0456a27  https://basecamp.com/about
Basecamp    16676 chars  plain        rid=86c17c449811ab0e0f861dd3  https://basecamp.com/integrations
Pipedrive   17742 chars  plain        rid=d2ef101d7ac8d30bf3a26a5a  https://www.pipedrive.com/en/marketplace
Pipedrive   11187 chars  plain        rid=d8d8e67054e10a90c81fa2cb  https://www.pipedrive.com/en/about
Zendesk      8267 chars  plain        rid=78b15c345cde1256878b45bd  https://www.zendesk.com/company

Eine aufgezeichnete Anfrage lieferte HTTP 520 mit cb_status: 0 und einem Body, der besagte, dass der Aufruf nicht berechnet wurde; ein Retry war erfolgreich. Der paketierte Crawl-Client ergänzt Transport-Retries und Request-Pacing, aber ein langer Fehler-Body ist kein Seiteninhalt. Prüfen Sie den Status, bevor Sie extrahieren.

Crawlbase Crawling API

Rufen Sie jede öffentliche Seite als Markdown ab, bei Bedarf im Browser gerendert, und bewahren Sie die Kopie, aus der Sie extrahiert haben, in Cloud Storage unter einer Request-ID auf. Fehlgeschlagene Anfragen werden nicht berechnet. Starten Sie mit bis zu 5.000 kostenlosen Anfragen, ohne Karte.

Den Extraktionsvertrag definieren, bevor Sie das Modell aufrufen

Damit der Graph abfragbar ist, braucht die Ausgabe des Modells ein festes Vokabular und vorhersehbare Felder. Es füllt ein Pydantic-Schema, das eins zu eins auf die Labels und Beziehungstypen abbildet, und jede Entität und jede Beziehung trägt ein source_quote für die anschließende Prüfung:

python
class Evidence(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str = Field(min_length=1)
    source_quote: str = Field(min_length=3, description="Exact words copied from the page.")


class Company(Evidence): pass
class Product(Evidence): pass
class Integration(Evidence): pass


class Person(Evidence):
    title: str | None
    employer: str | None


class Location(Evidence):
    country: str | None


class Relationship(BaseModel):
    model_config = ConfigDict(extra="forbid")
    source: str
    type: Literal["WORKS_AT", "OFFERS", "INTEGRATES_WITH", "PARTNERS_WITH", "LOCATED_IN"]
    target: str
    source_quote: str = Field(min_length=3)

Das vollständige Schemamodul definiert auch die Listen auf oberster Ebene und demonstriert den Vertrag, indem es eine gültige Payload parst und eine ungültige ablehnt:

text
Good output parses: Paulo Cunha
Bad output rejected with 2 errors:
   people.0.source_quote -> Field required
   relationships.0.type -> Input should be 'WORKS_AT', 'OFFERS', 'INTEGRATES_WITH', 'PARTNERS_WITH' or 'LOCATED_IN'

Structured Outputs erzwingen die Form, mehr nicht. Sie belegen nicht, dass eine Person beim Seed-Unternehmen arbeitet oder dass ein genanntes Tool eine Integration ist. Diese Regeln liefert der Extraktions-Prompt: den Namen des Seed-Unternehmens verwenden, Kunden-Testimonials ausschließen, benannte Tools statt Zählungen wie „500+ Integrationen“ extrahieren und Belegtext ohne Umformulierung kopieren. Das Modell erhält höchstens 40.000 Zeichen jeder Seite, was den Input begrenzt, aber Einträge am Ende eines langen Verzeichnisses verlieren kann; Chunking mit Überlappung und ein Deduplizierungsdurchlauf würden die Abdeckung erweitern, diese Implementierung schneidet ab.

Zitate prüfen, bevor Beziehungen entstehen

Nach der Schemavalidierung prüft der Quote Guard, ob jedes Belegzitat im erfassten Markdown vorkommt. Er normalisiert Unicode, Markdown-Markup und Whitespace und verwirft dann Entitäten, deren Zitat fehlt. Der Ausschnitt unten ist der Teil des Walkthrough-Guards, der Entitäten filtert; die vollständige Funktion prüft anschließend Arbeitgeber und Beziehungsendpunkte:

python
def guard(result, page: str, company: str):
    """Keep entities whose quote is on the page and relationships whose endpoints survived."""
    page_text = normalize(page)
    found = lambda quote: len(quote) > 2 and normalize(quote).rstrip(".,;:!?") in page_text  # noqa: E731
    dropped = []
    for field in ("companies", "people", "products", "integrations", "locations"):
        items = getattr(result, field)
        dropped += [f"{field}: {e.name}" for e in items if not found(e.source_quote)]
        setattr(result, field, [e for e in items if found(e.source_quote)])

Die Arbeitgeberfilterung entfernt Personen, die das Modell einem klar anderen Unternehmen zugeordnet hat; Personen ohne Arbeitgeberwert bleiben. Das paketierte Extraktionsmodul prüft zusätzlich Endpunkt-Labels und vereinheitlicht Integrationsnamen wie „Stripe for Pipedrive“. Die aufgezeichnete Ausgabe zeigt sowohl nützliche Ablehnungen als auch verlorenen Recall:

text
https://basecamp.com/integrations: 19 relationships, 4 dropped
   dropped integrations: Trello
   dropped locations: Chicago
   dropped relationship: Basecamp INTEGRATES_WITH Trello
   dropped relationship: Basecamp LOCATED_IN Chicago
https://www.pipedrive.com/en/partner: 1 relationships, 1 dropped
   dropped relationship: Pipedrive PARTNERS_WITH Zimple

In diesen Zeilen verstecken sich drei verschiedene Fehler. Das Chicago-Zitat fehlte auf der Seite. Trello stand auf der Seite, aber das Modell hatte sein Zitat umformuliert, also schlug der Abgleich fehl. Die Zimple-Beziehung verlor ihren Unternehmensendpunkt, bevor die Prüfung lief. Alle drei stoppen eine Beziehung; nur einer davon war eine Halluzination.

Ein passendes Zitat beweist, dass der Text erfasst wurde, nicht, dass die Aussage stimmt. Abgeleitete Verknüpfungen sind Anwendungslogik und sollten als solche festgehalten werden.

Diese Grenze sollten Sie im Kopf behalten. Ein Zitattreffer beweist, dass die Wörter auf der Seite standen; er beweist nicht die Interpretation, und ein unzusammenhängender Satz kann durchgehen und dabei die falsche Entität, den falschen Titel oder das falsche Land stützen. Die Pipeline fügt außerdem Beziehungen hinzu, die das Modell nicht ausgesagt hat: Behaltene Personen bekommen WORKS_AT zum Seed-Unternehmen, und das Unternehmen bekommt Kanten vom Typ OFFERS, INTEGRATES_WITH und LOCATED_IN zu den anderen behaltenen Entitäten, jeweils mit dem Zitat der Entität. Das sind abgeleitete Verknüpfungen, und sie können eine Beziehung neu erzeugen, deren eigenes Zitat gerade durchgefallen ist. Prüfen Sie bei allem, was Folgen hat, die vollständige Subjekt-Prädikat-Objekt-Aussage und halten Sie abgeleitete Kanten getrennt fest.

Namen normalisieren, dann mit MERGE laden

Selbst belegte Extraktionen benennen dasselbe Tool auf verschiedene Arten. Vor dem Laden entfernt der Normalisierungsschritt einige Domain- und Rechtsform-Suffixe, schreibt klein und entfernt Satzzeichen, dann lässt er RapidFuzz Namen mit einem Score von 90 oder mehr zusammenführen, wobei Seed-Unternehmensnamen als Repräsentant Vorrang haben:

python
def key(name: str) -> str:
    """"Stripe, Inc." and "stripe.com" both become "stripe"."""
    name = re.sub(r"\.(com|io|ai|co|app)$", "", name.lower().strip())
    return " ".join(t for t in re.sub(r"[^\w\s&+]", " ", name).split() if t not in SUFFIXES)
text
'Stripe' vs 'Stripe, Inc.': score 100, merged=True
'Salesforce' vs 'Salesforce.com': score 100, merged=True
'Notion' vs 'Motion': score 83, merged=False

Das ist ungefähres Namens-Matching, keine Identitätsauflösung. Zwei Personen können denselben Namen tragen, ähnlich benannte Unternehmen können nichts miteinander zu tun haben, und eine Stadt braucht ein Land. Die paketierte Implementierung gleicht nur innerhalb eines Labels ab, sodass eine Company und eine Integration mit demselben Namen getrennte Knoten bleiben. Unternehmensdomains, Personenkennungen und Stadt-Land-Schlüssel würden einem größeren Graphen stärkere Identitäten geben.

Der Loader verwendet dann MERGE und nutzt passende Knoten und Beziehungen so wieder, statt sie zu duplizieren:

python
NODE = """
UNWIND $rows AS row
MERGE (n:{label} {{name: row.name}})
SET n += row.props,
    n.source_urls = CASE WHEN row.url IN coalesce(n.source_urls, []) THEN n.source_urls
                    ELSE coalesce(n.source_urls, []) + row.url END,
    n.rids = CASE WHEN row.rid IN coalesce(n.rids, []) THEN n.rids ELSE coalesce(n.rids, []) + row.rid END
"""

REL = """
UNWIND $rows AS row
MATCH (a:{source} {{name: row.source}}), (b:{target} {{name: row.target}})
MERGE (a)-[r:{type} {{source_url: row.url}}]->(b)
SET r.rid = row.rid, r.extracted_at = row.extracted_at, r.source_quote = row.source_quote
"""

Jedes Label hat einen Uniqueness-Constraint auf name. Die Identität einer Beziehung besteht aus ihren Endpunkten, ihrem Typ und ihrer source_url, sodass dieselbe Verbindung, belegt durch zwei Seiten, zu zwei Kanten wird, jede mit eigenem Zitat, eigener rid und eigenem Zeitstempel. Zweimaliges Laden desselben extrahierten Datensatzes zeigt die Idempotenz:

text
After load 1: 90 nodes, 119 relationships
After load 2: 90 nodes, 119 relationships
MERGE macht ein erneutes Laden zu einem No-op. Es zieht keinen Fakt zurück, den eine Seite inzwischen nicht mehr macht.

Öffnen Sie den Neo4j Browser und führen Sie MATCH p=(c:Company)-[]->() RETURN p LIMIT 100 aus, um das Ergebnis zu sehen. Wiederholtes Laden macht daraus aber keinen historisierten oder synchronisierten Graphen. Wenn eine Seite eine Integration nicht mehr erwähnt, bleibt die alte Kante, bis etwas sie entfernt, und ein späteres Laden derselben Kante überschreibt ihr Zitat, ihre rid und ihren Zeitstempel an Ort und Stelle. Versionierte Beobachtungen oder ein Abgleichsschritt pro Seite sind das, was Historie bewahrt oder veraltete Aussagen zurückzieht.

Die Verbindungen über Unternehmenssites hinweg abfragen

Mit dem geladenen Graphen fragen drei Cypher-Queries, welche Unternehmen Integrationen teilen, welche Personen auf der Site von mehr als einem Unternehmen erscheinen und welche Länder Standorte von mehr als einem Unternehmen enthalten:

python
QUERIES = {
    "Shared integrations": """
        MATCH (a:Company)-[:INTEGRATES_WITH]->(i:Integration)<-[:INTEGRATES_WITH]-(b:Company)
        WHERE a.name < b.name
        RETURN a.name AS company_a, b.name AS company_b,
               count(DISTINCT i) AS shared, collect(DISTINCT i.name)[..6] AS integrations
        ORDER BY shared DESC LIMIT 5""",
    "People on two companies' sites": """
        MATCH (p:Person)-[r:WORKS_AT]->(c:Company)
        WITH p, collect(DISTINCT c.name) AS companies, collect(DISTINCT r.source_url) AS pages
        WHERE size(companies) >= 2
        RETURN p.name AS person, companies, pages""",
    "Geographic clusters": """
        MATCH (c:Company)-[:LOCATED_IN]->(l:Location)
        WITH l.country AS country, collect(DISTINCT c.name) AS companies, collect(DISTINCT l.name) AS cities
        WHERE size(companies) >= 2
        RETURN country, companies, cities ORDER BY size(companies) DESC""",
}

a.name < b.name liefert jedes Unternehmenspaar einmal, und DISTINCT verhindert, dass mehrere Belegseiten die Integrationszahl aufblähen. Der Walkthrough lieferte:

text
Shared integrations:
   {"company_a": "Basecamp", "company_b": "Pipedrive", "shared": 1, "integrations": ["Zapier"]}

People on two companies' sites:
   {"person": "Jason Fried", "companies": ["HEY", "Basecamp"], "pages": ["https://www.hey.com/", "https://basecamp.com/", "https://basecamp.com/about"]}

Geographic clusters:
   {"country": "Germany", "companies": ["Pipedrive", "Zendesk"], "cities": ["Berlin"]}
   {"country": "Portugal", "companies": ["Pipedrive", "Zendesk"], "cities": ["Lisbon"]}

Lesen Sie die Zeilen mit dem Modell im Hinterkopf. Jason Fried erscheint sowohl auf der Basecamp- als auch auf der HEY-Site, aber beide Marken gehören zu 37signals, also bedeuten zwei Knoten mit dem Label Company nicht zwei Arbeitgeber. Die geografische Query gruppiert nach Land und sammelt Städte über die getroffenen Unternehmen hinweg; sie sagt nicht, dass jedes Unternehmen in jeder gelisteten Stadt ein Büro hat. Der aufgezeichnete Lauf der vollständigen Pipeline im Repository fand für dasselbe Paar vier gemeinsame Integrationen, Asana, Stripe, Trello und Zapier, und die Ergebnisse unterscheiden sich zwischen Läufen, weil Seiten, Modellausgaben und die beiden Implementierungen sich unterscheiden.

GraphRAG für Fragen in natürlicher Sprache ergänzen

Cypher macht den Graphen inspizierbar; eine Frage ist freundlicher. Das GraphRAG-Skript nutzt Text-to-Cypher: Das Modell erhält das Schema und die Frage, schreibt eine Query und antwortet dann aus den Zeilen. Embeddings und eine Vektordatenbank sind nicht beteiligt. Bevor irgendetwas läuft, lehnt eine Keyword-Prüfung Schreibklauseln und mehrere Statements ab:

python
WRITE_WORDS = re.compile(r"\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|LOAD|FOREACH|CALL)\b", re.I)


class Query(BaseModel):
    cypher: str


def check_read_only(cypher: str) -> str:
    """Reject write clauses. String literals are blanked first so names like 'Merge Labs' pass."""
    code = re.sub(r"'[^']*'|\"[^\"]*\"", "''", cypher).strip().rstrip(";")
    if ";" in code or WRITE_WORDS.search(code) or not re.match(r"(?i)\s*(MATCH|OPTIONAL|WITH|UNWIND)", code):
        raise ValueError(f"Blocked query: {cypher}")
    return cypher.strip().rstrip(";")

Die Query läuft dann innerhalb von execute_read():

python
with GraphDatabase.driver(NEO4J_URI, auth=NEO4J_AUTH) as driver, driver.session() as session:
    rows = session.execute_read(lambda tx: [r.data() for r in tx.run(cypher)])

Keine der beiden Zeilen ist eine Sicherheitsgrenze. Ein regulärer Ausdruck ist kein Cypher-Parser, und die Dokumentation des Neo4j-Treibers sagt klar, dass execute_read() nicht zwangsläufig Zugriffskontrolle bedeutet. Ein produktiver Fragedienst sollte Nur-Lese-Zugriff an der Datenbank- oder Deployment-Grenze erzwingen, und die Standard-Zugangsdaten neo4j des Tutorials, die auch den Graphen laden, sind keine eingeschränkte Abfrageidentität.

Die paketierte Version schickt Fehler, leere Ergebnisse und fehlende Provenienz für bis zu drei Versuche an das Modell zurück. Der Regex und die Lesetransaktion sind Hygiene, keine Autorisierung.

Die aufgezeichnete Frage zeigt den Retrieval- und Zitierpfad von Anfang bis Ende:

text
Safety check works: Blocked query: MATCH (n) DETACH DELETE n

Cypher:
MATCH (a:Company)-[r1:INTEGRATES_WITH]->(i)<-[r2:INTEGRATES_WITH]-(b:Company)
WHERE toLower(a.name) = toLower('Basecamp') AND toLower(b.name) = toLower('Pipedrive')
UNWIND [r1, r2] AS r
RETURN i.name AS integration, startNode(r).name AS company, r.source_url AS source_url, r.rid AS rid LIMIT 25

Rows: 2

Answer:
Basecamp and Pipedrive have the integration "Zapier" in common. This is supported by the following sources: [https://basecamp.com/integrations, rid: 86c17c449811ab0e0f861dd3] and [https://www.pipedrive.com/en/marketplace, rid: d2ef101d7ac8d30bf3a26a5a].

Die Query liefert eine Provenienzzeile pro Verbindung eines Unternehmens zu Zapier, sodass die Antwort beide Seiten und beide rid-Werte zitieren kann. Diese Referenzen machen die Evidenz inspizierbar, solange die gespeicherten Objekte existieren; sie prüfen die Formulierung des Modells nicht Aussage für Aussage. Das paketierte Modul ergänzt eine breitere Keyword-Filterung, bis zu drei Generierungsversuche und einen Ausschnitt von 50 Zeilen, der in Python angewendet wird, nachdem die Ergebnisse materialisiert sind, weshalb datenbankseitige Limits und Timeouts auf einem großen Graphen weiterhin wichtig sind.

Gespeicherte Seiten ohne neuen Crawl erneut extrahieren

Schema und Prompt werden sich ändern. Vielleicht ergänzen Sie Investoren, trennen Produkte von den Unternehmen, die sie herstellen, oder verschärfen die Beziehungsprüfungen, und Sie wollen jede Änderung gegen dieselben erfassten Seiten bewerten statt gegen einen neuen Schnappschuss des Webs. Das Refresh-Skript holt eine gespeicherte Antwort per rid aus Cloud Storage:

python
def get_stored(rid: str) -> str:
    response = httpx.get(STORAGE, params={"token": CRAWLBASE_TOKEN, "rid": rid}, timeout=60)
    response.raise_for_status()
    print(f"  rid={rid} stored_at={response.headers.get('stored_at')} url={response.headers.get('url')}")
    return response.text

Bei der aufgezeichneten Anfrage mit format=md entsprach der abgerufene Body exakt dem ursprünglichen Markdown, und eine gespeicherte Antwort von generic-extractor kam als das geparste JSON zurück, das sie gewesen war, nicht als rohes HTML. Speichern und holen Sie die Repräsentation, die Ihre Extraktion verarbeitet, statt anzunehmen, dass jedes gespeicherte Objekt eine Seite ist. Der Walkthrough wählt bis zu drei Seiten aus, stellt sicher, dass jeder gespeicherte Body dem gesicherten Crawl entspricht, und extrahiert erneut:

text
rid=abac8926164450fab0456a27 stored_at=2026-10-29T10:16:17+01:00 url=https://basecamp.com/about
re-extracted 2 relationships, dropped 0
rid=86c17c449811ab0e0f861dd3 stored_at=2026-10-29T10:16:33+01:00 url=https://basecamp.com/integrations
re-extracted 20 relationships, dropped 2

Erneutes Extrahieren ändert die Interpretation eines Schnappschusses; es holt keine aktuellen Inhalte. Eine Live-Aktualisierung ist ein frischer Crawl, dann Extraktion, dann Laden. Gespeicherte Seiten werden 30 Tage aufbewahrt; das Speichern kostet einen halben Credit, das Abrufen nichts. Bewahren Sie eigene Schnappschüsse auf, wo langfristige Reproduzierbarkeit zählt, und prüfen Sie die Aufbewahrungsrichtlinie von Cloud Storage für die aktuellen Bedingungen.

Die vollständige Pipeline ausführen

Die paketierte CLI fasst die neun Transformationen zu Befehlen zusammen. Wechseln Sie nach final/, installieren Sie dort ihre Abhängigkeiten und das Paket, und lassen Sie dann die Seed-Liste mit vier Unternehmen laufen:

bash
python -m pip install -r requirements.txt
python -m pip install -e .
python -m kg_pipeline run-all --seeds data/seed_small.csv

Der aufgezeichnete Lauf der vollständigen Pipeline meldete:

text
Fetched 14 pages. Saved to .../final/output/pages.json
Extracted 14 pages: kept 223 items, dropped 11 by the quote guard.
Merged 0 near-duplicate names.
Graph now has 130 nodes and 159 relationships.

Ein zweiter Lauf gegen dieselbe Datenbank behielt diese Zahlen. Das ist ein eigener Lauf, getrennt vom Walkthrough-Graphen mit 90 Knoten und 119 Beziehungen, und die Zahl kept wird am Quote Guard gezählt, vor Bereinigung und abgeleiteten Verknüpfungen, sie ist also nicht die endgültige Graphgröße. Eine Anmerkung zur Provenienz: Die Fixtures des Repositorys reproduzieren die Discovery-Ausgabe und die 223 behaltenen Elemente exakt; die übrige in diesem Beitrag zitierte Konsolenausgabe ist der aufgezeichnete Lauf der Autoren und nicht als Fixture eingecheckt. Die CLI bietet außerdem crawl, extract, load, query, ask und refresh:

bash
python -m kg_pipeline ask "Which integrations do Basecamp and Pipedrive have in common?"
python -m kg_pipeline refresh
python -m kg_pipeline load --seeds data/seed_small.csv

Gewöhnliche Crawl- und Extraktionsläufe verwenden gecachte Ergebnisse wieder: Die Discovery cacht nach Token-Modus und URL, Markdown nach URL, die Extraktion nach Modell, Prompt-Version, Unternehmen und Hash des Seiteninhalts, und nichts läuft von selbst ab. Leeren Sie den Crawl-Cache vor einem geplanten Recrawl oder nach geänderten Rendering-Einstellungen. refresh macht immer frische Extraktionsaufrufe und ask wird nie gecacht, beide kosten also jedes Mal Modellnutzung. Der Client verwendet ein Timeout von 90 Sekunden, bis zu drei Versuche mit exponentiellem Backoff bei transienten Status und mindestens eine Sekunde zwischen Anfragestarts: sequenzielles Pacing, keine Aussage über die Concurrency Ihres Kontos.

Die Testsuite umfasst 112 Offline-Tests und 16 Live-Integrationstests. Die Offline-Tests decken Discovery, Schemavalidierung, Zitatprüfungen, Normalisierung, Query-Filterung sowie gemocktes Crawl- und Cache-Verhalten auf aufgezeichneten Fixtures ab; die Live-Tests brauchen Zugangsdaten und ein Neo4j, und einige leeren die Datenbank. Im Verzeichnis final/:

bash
python -m pytest -m "not integration"

Die Datei final/tests/fixtures/FINDINGS.md des Repositorys hält die zugrunde liegenden Crawlbase-Beobachtungen fest: Antwortformen, den Readability-Vergleich, den Token-Vergleich, das Storage-Verhalten und den Redirect-Fall. data/seed_companies.csv listet 50 Seed-Sites, um das Experiment zu erweitern.

Fazit

Ein nützlicher Knowledge Graph verbindet jede extrahierte Aussage mit dem Inhalt, aus dem sie stammt. Diese Pipeline nutzt die Crawling API, um Unternehmensseiten zu entdecken und zu erfassen, strukturierte LLM-Extraktion, um Entitäten und Beziehungen vorzuschlagen, und Neo4j, um ihre Verbindungen abzufragen, mit einer Seiten-URL, einem Zitat und der rid der gespeicherten Seite auf jeder Kante.

Ihre Grenzen sind bewusst explizit. Das Vorhandensein eines Zitats ist schwächer als semantische Verifikation, Namens-Matching ist schwächer als Identitätsauflösung, und idempotentes Laden ist nicht dasselbe wie das Zurückziehen veralteter Fakten. Jeder dieser Punkte ist eine Stelle, in die Sie investieren können, wenn der Graph von einem Experiment mit vier Unternehmen zu einem Recherchewerkzeug wächst. Klonen Sie das Repository, erstellen Sie ein Crawlbase-Konto für die Erfassungsstufe, und beginnen Sie mit einer Seed-Liste, die klein genug ist, dass Sie jede erzeugte Beziehung lesen können.

Häufig gestellte Fragen (FAQs)

Wann sollte ich einen Knowledge Graph statt relationaler Tabellen verwenden?

Beide können diese Beziehungen speichern. Ein Graph zahlt sich aus, wenn Ihre Fragen wiederholt Verbindungen zwischen Unternehmen, Personen, Integrationen und Standorten traversieren. Reporting und Aggregation mit festem Schema bleiben in Tabellen einfacher.

Braucht jede Unternehmenswebsite den JavaScript token?

Nein. Mehrere aufgezeichnete Seiten lieferten ihren Inhalt mit dem Normal token, und der JavaScript token kostet pro Anfrage doppelt so viel. Verwenden Sie ihn, wenn eine Seite ihren Inhalt im Browser aufbaut, prüfen Sie den Inhalt, statt einer Längenschwelle zu vertrauen, und leeren Sie den Markdown-Cache, wenn Sie die Einstellung für einen Seed ändern.

Garantiert ein passendes Quellzitat eine korrekte Beziehung?

Nein. Es bestätigt, dass das normalisierte Zitat auf der erfassten Seite vorkommt. Das Zitat kann trotzdem nichts mit der Aussage zu tun haben, und die Pipeline fügt manche Beziehungen per Ableitung hinzu. Stärkere Verifikation prüft die Entitätsidentitäten, die Bedeutung der Beziehung und den umgebenden Kontext zusammen.

Kann ich die Extraktion verbessern, ohne die Websites erneut zu crawlen?

Ja. Holen Sie das gespeicherte Markdown per rid und extrahieren Sie es mit dem überarbeiteten Prompt oder Schema erneut, solange das Capture noch in seinem Aufbewahrungsfenster liegt. In der paketierten Pipeline ist das refresh gefolgt von load. Das verarbeitet vorhandene Evidenz erneut; aktuelle Informationen brauchen einen frischen Crawl.

Jetzt loslegen

Crawlen Sie jede Website im großen Maßstab, ohne gegen die Infrastruktur zu kämpfen.

Crawlbase übernimmt Proxys, Fingerprints und CAPTCHAs, damit Ihr Team Daten-Pipelines liefert, statt Crawl-Infrastruktur zu warten. Bis zu 5.000 Requests kostenlos, keine Karte erforderlich.

Self-Service · Kein Verkaufsgespräch erforderlich · Enterprise-Crawl-Volumen verfügbar