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.
- 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
MERGEmacht 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.
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:
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:
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:
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.
Unternehmensseiten über Homepage-Links entdecken
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:
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:
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:
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:
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.
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:
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:
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:
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:
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.
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:
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)
'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:
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:
After load 1: 90 nodes, 119 relationships After load 2: 90 nodes, 119 relationships
Ö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:
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:
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:
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():
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 aufgezeichnete Frage zeigt den Retrieval- und Zitierpfad von Anfang bis Ende:
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:
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:
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:
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:
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:
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/:
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.
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.

