Back to "GraphRAG Part 2: Minimum Viable Graph RAG"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET DuckDB GraphRAG Knowledge Graphs Machine Learning Vector Search

GraphRAG Part 2: Minimum Viable Graph RAG

Saturday, 27 December 2025

In Parte 1, abbiamo esplorato il motivo per cui GraphRAG è importante . Ora lasciate' costruire un GraphRAG minimamente viabile con tre modalità di estrazione:

Mode Calls LLM Perfetto per M
Heuristica (defaultM SK1 0 per blocco SSK4 Indicettazione veloce, Markdown strutturato
Ibrido 1 per ogni documento Balance tra velocità e qualità S
LLM

Tutti i modi usano:

  • DuckDB per l'archiviazione unificata (vectori + grafico in un singolo archivio
  • BM25 + Ricerca ibrida BERT attraverso la fusione RRF.
  • Ollama per la sintesi e la classificazione optionale di lotti (coste API zeroM SK1

Navigation delle serie:

Code: Mostlylucid.GraphRag su GitHub

Architecture Overview

flowchart LR
    subgraph Indexing
        MD[Markdown Files] --> CH[Chunker]
        CH --> EMB[BERT Embeddings]
        CH --> EXT[Entity Extractor]
        EXT --> |heuristics + links| ENT[Entities]
        ENT --> REL[Relationships]
        REL --> COM[Communities]
    end
    
    subgraph Storage
        EMB --> DB[(DuckDB)]
        ENT --> DB
        REL --> DB
        COM --> DB
    end
    
    subgraph Query
        Q[Query] --> CLASS{Classify}
        CLASS --> |local| HS[Hybrid Search]
        CLASS --> |global| CS[Community Search]
        CLASS --> |drift| BOTH[Both + Synthesis]
        HS --> LLM[Ollama]
        CS --> LLM
        BOTH --> LLM
    end
    
    DB --> HS
    DB --> CS
    
    style MD stroke:#22c55e,stroke-width:2px
    style DB stroke:#3b82f6,stroke-width:2px
    style LLM stroke:#a855f7,stroke-width:2px

Perché DuckDB?

Microsoft's GraphRAG usa un'archiviazione separata per i vettori M SK1LanceDB),entità (ParquetMSC4 e relazioni SSK5di più ParquetMST6 DuckDB semplifica questo

  • Single .duckdb file per tutto.
  • La ricerca dei vettori nativi HNSW attraverso l'estensione VSS
  • SQL per la ricerca dei vettori e la traversazione dei grafici
  • Semplicità zero di distribuzione

DuckDB non è una base di dati grafica - e cheM SK1 è il punto. Questo non è 't NeoM SK1j. I travertili sono superficiali , SQLMST4 basati MST5 e deliberatiM ST6 Questa restrizione mantiene il sistema debuggerabile e economicoMst7

Schema di Conservazione

Il schema usa Si uniscono i tavoli per la provenienza. - possiamo fare una domanda " quali frammenti menzionano l'entità X?" direttamenteM SK3

erDiagram
    documents ||--o{ chunks : contains
    chunks ||--o{ entity_mentions : has
    chunks ||--o{ relationship_mentions : has
    entities ||--o{ entity_mentions : mentioned_in
    entities ||--o{ relationships : source
    entities ||--o{ relationships : target
    relationships ||--o{ relationship_mentions : mentioned_in
    communities ||--o{ community_members : contains
    entities ||--o{ community_members : belongs_to
    
    chunks {
        varchar id PK
        varchar document_id FK
        text text
        float[] embedding
    }
    
    entities {
        varchar id PK
        varchar name
        varchar type
        int mention_count
    }
    
    entity_mentions {
        varchar entity_id FK
        varchar chunk_id FK
    }

Decisione di progettazione chiave: No. VARCHAR[] per la provenienza. Coinvolgere i tavoli (entity_mentions, relationship_mentions) permette di fare ricerche efficienti come " ottenere tutti i frammenti che menzionano Docker".

La ricerca dei vettori: L'HNSW Ci ha preso

L'indice HNSW di DuckDB' si attiva solo con array_cosine_distance + ORDER BY + LIMIT:

// GraphRagDb.cs - SearchChunksAsync
cmd.CommandText = $"""
    SELECT id, document_id, text, chunk_index, 
           array_cosine_distance(embedding, $1::FLOAT[{_dim}]) as distance
    FROM chunks 
    WHERE embedding IS NOT NULL
    ORDER BY distance
    LIMIT $2
    """;
// Convert distance to similarity: 1.0f - distance

Utilizzando array_cosine_similarity non userà l'indice. - ha vintoM SK1 non ha attivato l'indice HNSW. Su corpora non--trivialeMSC4 questo trasforma una domanda indexata di ~5ms in una scansione completa della tavola.

Extrazione delle entità

Questo è il punto in cui ci diffondiamo dall'approccio di Microsoft' LLM-per-passi d'estrazione di scarto usata in Microsoft's riferimento Pipeline GraphRAG, che usiamo Extrazione statistica basata su IDF-. L'obiettivo è non essere un'entità perfetta. stabile, corpusM SK1 segnali relativi che non richiedono un LLM per produrre. Questo corrisponde a un certo richiamo al determinismo,Auditabilità, e costi prevedibili- una scelta deliberata per le corporazioni tecniche:

flowchart TB
    subgraph "Phase 1: Signal Collection"
        TEXT[All Chunks] --> IDF[Compute IDF Scores]
        TEXT --> STRUCT[Structural Signals]
        STRUCT --> HEAD[Headings]
        STRUCT --> CODE[Inline Code]
        STRUCT --> LINKS[Links]
        IDF --> RARE[High-IDF = Rare Terms]
        RARE --> CAND[Candidates]
        HEAD --> CAND
        CODE --> CAND
        LINKS --> |explicit rels| LINKREL[Link Relationships]
    end
    
    subgraph "Phase 2: Dedup"
        CAND --> EMBED[BERT Embeddings]
        EMBED --> SIM[Similarity > 0.85]
        SIM --> MERGE[Merge Duplicates]
    end
    
    subgraph "Phase 3: Classify"
        MERGE --> LLM{LLM Available?}
        LLM --> |yes| BATCH[Single Batch Call]
        LLM --> |no| HEUR[Heuristic Types]
    end
    
    style IDF stroke:#f59e0b,stroke-width:2px
    style BATCH stroke:#a855f7,stroke-width:2px

Perché l'IDF, non contiene liste codificate

L'approccio naïvo è un metodo hardcode. HashSet<string> KnownTech = { "Docker", "Kubernetes", ... }. Questa interruzione per:

  • Nouvelle tecnologie (tu'dovreste aggiornare la listaM SK2
  • Domain- termini specifici M SK1corpo diverso =entità diverse)
  • I difetti e le variazioni

IDF (Frequenza inversa del documentoM SK1 Risolve questo statisticamente. Un termineM SK1s IDF è:

$$\text{IDFM SK2tMSC3 = \logMST6fracMSV7NMSS8dfM SV9t\MSV10

Dove:

  • $NM SK1 = totali pezzi
  • \(dfM SK1t)\) = documenti che contengono il termine SSK4t$

High IDF = termine raro = probabile che un'entitàM SK1 "Docker" apparsa in SSK4 dei frammenti 100 abbia un IDF più alto di SSK6ilSNK7 apparso in \SSK8 dei S100.

Per saperne di più su TF-IDF e BM25, consultate il mio post su ricerca ibrida con BM25.

Signali strutturali

La struttura di marcazione ci dice cosa è importante.

  • Insieme (## Docker SetupEntità )
  • Il codice inline (`docker-compose`Entità )
  • Link ([Docker](https://docker.com)) → entità + relazione
// EntityExtractor.cs - structural signal extraction
private void ExtractStructuralEntities(string chunk, string chunkId)
{
    // Headings: ## Docker Compose Setup → "Docker Compose Setup"
    foreach (Match m in Regex.Matches(chunk, @"^#{1,3}\s+(.+)$", RegexOptions.Multiline))
    {
        var heading = m.Groups[1].Value.Trim();
        AddCandidate(heading, chunkId, weight: 2.0); // Higher weight
    }
    
    // Inline code: `docker-compose` → "docker-compose"
    foreach (Match m in Regex.Matches(chunk, @"`([^`]+)`"))
    {
        AddCandidate(m.Groups[1].Value, chunkId, weight: 1.5);
    }
}

Extrazione di collegamenti (High-Relazioni di qualitàM SK2

I link Markdown forniscono Esplicito relationships that don't require LLM inference:

// EntityExtractor.cs - ExtractLinks  
foreach (Match m in Regex.Matches(chunk, @"\[([^\]]+)\]\((/blog/[^)]+)\)"))
{
    var linkText = m.Groups[1].Value;  // "semantic search"
    var slug = m.Groups[2].Value;       // "/blog/semantic-search-with-qdrant"
    yield return new Relationship(linkText, $"blog:{slug}", "references", chunkId);
}

Deduplicazione tramite i collegamenti BERT

I nomi di entità come "Docker Compose", "dockerM SK3composeMSC4 e "DokerComposeSSK6 dovrebbero essere unitiMST7 Noi usiamo Inserzioni BERT per rilevare la somiglianza semantica:

// EntityExtractor.cs - DeduplicateAsync
var embeddings = await _embedder.EmbedBatchAsync(candidates.Select(c => c.Name), ct);

for (int i = 0; i < candidates.Count; i++)
{
    for (int j = i + 1; j < candidates.Count; j++)
    {
        var similarity = CosineSimilarity(embeddings[i], embeddings[j]);
        if (similarity > 0.85)
        {
            // Merge into canonical entity (keep higher mention count)
            canonical.MentionCount += duplicate.MentionCount;
            canonical.ChunkIds.UnionWith(duplicate.ChunkIds);
        }
    }
}

Questo passo è O(nM SK1 all'interno di un gruppo di candidati limitato, ma i conteggi dei candidati sono limitati da filtri IDF e segnali strutturali - non dimensione del corpusMSC4 Per i dettagli sulle inserzioni BERTMNK5 consultate La ricerca semantica con ONNX e BERT.

Mode di Extrazione

Il CLI supporta tre modi di estrazione attraverso --extraction-mode:

Mode euristico (Default)

dotnet run --project Mostlylucid.GraphRag -- index ./Markdown --extraction-mode heuristic

Usa i segnali strutturali IDF + per la rilevazione delle entità, con classificazione opzionale di lotti LLMM SK2 Zero per le chiamate di LLM su -. - solo ~1 chiamate per 50 entità per classificare il tipo.

dotnet run --project Mostlylucid.GraphRag -- index ./Markdown --extraction-mode hybrid

La migliore di entrambi i mondi:

  1. Detezione euristica: IDF + i segnali strutturali trovano candidati all'entità.
  2. Riparazione LLM: Una chiamata per ogni Documento Valida entità e extrae relazioni semantiche.
flowchart LR
    subgraph "Per Document"
        CHUNKS[Document Chunks] --> HEUR[Heuristic Extraction]
        HEUR --> CAND[30 Candidates]
        CAND --> LLM[Single LLM Call]
        LLM --> ENT[Validated Entities]
        LLM --> REL[Semantic Relationships]
    end
    
    style HEUR stroke:#22c55e,stroke-width:2px
    style LLM stroke:#a855f7,stroke-width:2px

Per i documenti 5 con i frammenti 62, il modo ibrido fa Le chiamate 5 LLM (vs 124 per il modo completo LLMM SK2 ottenete:

  • Copertura delle entità deterministiche dalla heuristica
  • LLM-estrazione delle relazioni di qualità (semantici, non solo coM SK3occurrenzaMSC4
  • Descrizioni e tipi validi

Mode LLM (Microsoft-StyleM SK2

dotnet run --project Mostlylucid.GraphRag -- index ./Markdown --extraction-mode llm

L'approccio completo di Microsoft GraphRAG: 2 LLM chiama per blocco (estrazione di entità +estramento di relazione). più costosoM SK3 ma di migliore qualità per testi non strutturatiMSC4

Quando usare ogni?

Mode Calls LLM Perfetto per M
Heuristica
Ibrido 1 per documento Balance della copertura e della qualità S
LLM

Per la documentation tecnica, cominciate con ibrido. mode. E vi dà relazioni semantiche senza il costo per l'uno. Heuristica per velocità pura, o Llm per il testo narrativo.

La ricerca ibrida: BM25 M SK2 BERT

La ricerca ibrida combina due approcci complementari:

Denso (BERTM SK1 Capisce il significato. "Container di dockerM SK2 corrisponde "containerizzazioneMSC4 Sparse (BMM SK1 Corre i termini precisi. "HNSWM SK2 solo corrisponde "HN SWMSC4

flowchart LR
    Q[Query] --> BERT[BERT Embedding]
    Q --> BM25[BM25 Tokenize]
    
    BERT --> DENSE[Dense Search<br/>HNSW Index]
    BM25 --> SPARSE[Sparse Search<br/>TF-IDF Scoring]
    
    DENSE --> RRF[RRF Fusion]
    SPARSE --> RRF
    
    RRF --> TOP[Top K Results]
    TOP --> ENR[Enrich with<br/>Entities + Rels]
    
    style RRF stroke:#f59e0b,stroke-width:2px

Cos'è BM25?

BM25 M SK1Best Match 25) valuta i documenti sulla base della frequenza del termine di domanda. La formulaMSC4

$$\ testoM SK1 punteggio_ \textMSC4IDFMST5qMSSK6i+) ==\cdot MSSK9fracMSV10fM SV11q=MSV12i\MSV13 DMSS14 ≥MSV15cdo SSV16kM Sv17 ≤MSV18 ±MSV19f+MSV20q~MSV21i^MSV22 D\MSS23 + kMsv25 +M SV26c dot RMSV27 ++MSV28 b MMSV29 b

Principali intuizioni:

  • Termino IDF: Parole rare contano di più ("HNSW" > "theM SK5
  • La saturazione TF: Una parola che appare 10x non èM SK2t 10x più rilevante di SSK4x
  • Normalizzazione della lunghezza: Documenti lunghi non ricevono un vantaggio ingiusto.

Per la completa implementazione del BM25, see ,. La ricerca e l'indexazione ibride.

Fusione di posizione reciproca (RRFM SK1

RRF fonde classificazioni da diversi sistemi di rilevamento. Ogni posizione di classifica ottiene un punteggio:

$$\testM SK1RRF}(dMSC3 = | | 5 | somma_{r \in RM SK2 \frac{1}{k M+ rMSC6dMST7

Dove $kM SK1 ( tipicamente 60) impedisce l'eccesso di sovrapeso del risultato più alto. Documenti presenti in Entrambi. I ranking sono migliorati:

// SearchService.cs - RRF fusion
const int k = 60;

foreach (var (chunk, rank) in denseResults.Select((c, i) => (c, i)))
    scores[chunk.Id] = 1.0 / (k + rank + 1);

foreach (var (chunk, rank) in sparseResults.Select((c, i) => (c, i)))
{
    var rrfScore = 1.0 / (k + rank + 1);
    if (scores.TryGetValue(chunk.Id, out var existing))
        scores[chunk.Id] = existing + rrfScore;  // Boost for appearing in both!
    else
        scores[chunk.Id] = rrfScore;
}

Esempio: Un documento classificato M SK1 in densità e #3 in scarsa

  • Densità: $1/(60+1) | | = |
  • Sparse: M SK1 | | = |
  • Combinato: M SK1 ( più alto di uno solo )

Mode di ricerca

flowchart TB
    Q[Query] --> CLASS[Classify Query]
    
    CLASS --> |"How do I use X?"| LOCAL[Local Search]
    CLASS --> |"What are the themes?"| GLOBAL[Global Search]  
    CLASS --> |"How does X relate to Y?"| DRIFT[DRIFT Search]
    
    LOCAL --> HS[Hybrid Search] --> CTX1[Chunk + Entity Context]
    GLOBAL --> CS[Community Summaries] --> MAP[Map-Reduce]
    DRIFT --> BOTH[Local + Communities] --> SYN[Synthesize]
    
    CTX1 --> LLM[LLM Answer]
    MAP --> LLM
    SYN --> LLM
    
    style LOCAL stroke:#22c55e,stroke-width:2px
    style GLOBAL stroke:#3b82f6,stroke-width:2px
    style DRIFT stroke:#a855f7,stroke-width:2px

Classificazione della domanda

// QueryEngine.cs
private static QueryMode ClassifyQuery(string query)
{
    var q = query.ToLowerInvariant();
    if (q.Contains("main theme") || q.Contains("summarize") || q.Contains("overview"))
        return QueryMode.Global;
    if (q.Contains("relate") || q.Contains("connect") || q.Contains("compare"))
        return QueryMode.Drift;
    return QueryMode.Local;
}

Questo classificatore è deliberatamente semplice - ed è facile da sostituire con un piccolo modello di intenzione più tardi. Se nessuna entità corrisponde a , il sistema si degrada pulito per ottenere una ripresa pura ibridaM SK3

Usazione del CLI

Indice

# Heuristic mode (default) - fast, no per-chunk LLM
dotnet run --project Mostlylucid.GraphRag -- index ./test-markdown

# LLM mode - Microsoft-style classification
dotnet run --project Mostlylucid.GraphRag -- index ./test-markdown --extraction-mode llm
GraphRAG Indexer
  Source: test-markdown
  Database: graphrag.duckdb
  Model: llama3.2:3b
  Extraction: Heuristic (IDF + signals)

Initializing...
Indexing docker-development-deep-dive.md: 0%
Indexing docker-swarm-cluster-guide.md: 40%
Indexing dockercomposedevdeps.md: 80%
Indexing complete: 100%
Classifying entities...: 0%
Extracted 168 entities, 315 rels (4 LLM calls): 100%
Found 10 communities: 100%
Summarizing c_0_2 (12 entities): 20%
Summarizing c_0_8 (4 entities): 80%

────────────────── Indexing Complete ───────────────────
┌───────────────┬───────┐
│ Metric        │ Count │
├───────────────┼───────┤
│ Documents     │ 5     │
│ Chunks        │ 62    │
│ Entities      │ 168   │
│ Relationships │ 312   │
│ Communities   │ 10    │
└───────────────┴───────┘

La domanda

dotnet run --project Mostlylucid.GraphRag -- query "How do I use Docker Compose?"
──────────────────── Local Search ────────────────────

Query: How do I use Docker Compose?

╭─Answer────────────────────────────────────────────────╮
│ To run the services defined in the                    │
│ devdeps-docker-compose.yml file, you need to run the  │
│ following command in the same directory as the file:  │
│                                                       │
│ docker compose -f .\devdeps-docker-compose.yml up -d  │
│                                                       │
│ This command will start the containers in detached    │
│ mode.                                                 │
╰───────────────────────────────────────────────────────╯

Related Entities: Docker, container, services, image

Sources: 5 chunks (top score: 0.016)

Statistiche

dotnet run --project Mostlylucid.GraphRag -- stats
─────────────── GraphRAG Database Stats ────────────────
┌───────────────┬───────┐
│ Metric        │ Count │
├───────────────┼───────┤
│ Documents     │     5 │
│ Chunks        │    62 │
│ Entities      │   168 │
│ Relationships │   312 │
│ Communities   │    10 │
└───────────────┴───────┘

Database size: 7.76 MB

La comparazione dei costi

Per i post di blog 100 (~500 frammenti, | Documenti

Operazione MSFT GraphRAG Heuristice MSSK3 Hybride | LLM \
Extrazione delle entità 1,000 richiami \0 0 ♫
Riparazione dei documenti - | 4 5 6 chiamate 7 8 9
Classificazione Incluso S~4 lotto M - ~4 lotto
Esempi comuni ~20 3 4 5 6 7 8 9
Totali chiamate LLM ~1,020 ~24 ~120 ~1,024
Calità delle relazioni Semantici Co-Occurrenza Semantiche
Costo (gpt -4oM SK2mini) ~$5-10 ~$0.15 ~$0.75 ~$5-10
Costo (OllamaM SK1 NM SK1A

Il modo ibrido è il punto preferito. per la maggior parte del contenuto tecnico: si ottengono relazioni semantiche M SK1non solo co-occurrenceMSC3 a ~10% di MSFTMST5costa di .

Ordinazione ristretta-di-estimazione di grandezzaM SK2costa esatta dipende dalla dimensione del pezzo e dalla forma improvvisa

Tradeoffs

Aspetto Heuristico MSFT GraphRAG SSK5
Detezione delle entità IDF + Structure LLM per un pezzo
Relazioni Co-occurrenza LLMM SK4inferita M Co-occurenza ♫ LLSMSC8inferrata R
Calls LLM (100 docs) ~24 ~120 ♪
Calità delle relazioni Bassa alta bassa SMK4 alta
Funziona offline
Perfetto per Velocità-Critificabile Recommended Compatto di sequenza testo non strutturato

Conceptualmente, questo è lo stesso tubo di costruzione. DocSummarizer: costruire prima la struttura, poi lasciare che lo descriva un LLMM SK2

Dove questo si rompe: Un testo narrativa o di fantascienza senza marcatura strutturale. Relazioni implicite senza segnale lexico. Nomi di entità molto ambigui che richiedono conoscenza del mondo per essere disinambiguatiM SK2 Per questi casi , usare il modo LLM o MicrosoftMST4 l'approccio completoMst5

Il codice

L'implementazione è minimale - ~2,000 linee attraverso questi file:

Mostlylucid.GraphRag/
├── Storage/GraphRagDb.cs              # DuckDB with HNSW + provenance
├── Services/EmbeddingService.cs       # ONNX BERT wrapper
├── Services/OllamaClient.cs           # LLM client
├── Extraction/
│   ├── IEntityExtractor.cs            # Extractor interface
│   ├── EntityExtractor.cs             # Heuristic mode
│   ├── HybridEntityExtractor.cs       # Hybrid mode (recommended)
│   └── LlmEntityExtractor.cs          # Full LLM mode
├── Search/SearchService.cs            # BM25 + BERT hybrid
├── Graph/CommunityDetector.cs         # Leiden + summarization
├── Query/QueryEngine.cs               # Local/Global/DRIFT
├── Indexing/MarkdownIndexer.cs        # Chunking
├── GraphRagPipeline.cs                # Orchestration
├── Models.cs                          # Shared types + ExtractionMode enum
└── Program.cs                         # CLI

Source: Mostlylucid.GraphRag/

Post connessi

Ressource esterne

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.