Back to "GraphRAG-delen 2: Minimalt hållbara GrafRAG"

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-delen 2: Minimalt hållbara GrafRAG

Saturday, 27 December 2025

I Del 1, vi undersökte varför GraphRAG är viktigt . Låt oss nu bygga minimalt hållbara GraphRAG med tre extraktionsmodi:

Modus LLM-sändningar bäst för
Heuristik (default) 3 per chunk snabb indexering
Hybrid 1 per dokument Tillsvarighet mellan hastighet och kvalitet
LLM 2 per stycke Max. entitetskvalité

Alla former använder:

  • DuckDB för enified storage (vectors + graf i en enda file
  • BM25 + BERT hybridsökning via RRF fusion
  • Ollama för syntetisering och optionell slagklassificering (null API-kostnaderM SK1

Navigationsserien:

Kod: Mostlylucid.GraphRag på GitHub

Arkitektur överblick

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

Varför DuckDB?

Microsoft's GraphRAG använder separat data lagring för vektorer (LanceDB), enheter | (Parquet |), | och relationer ♫ ( | mer Parquet | МSK6 | DuckDB förenklar det här ♫

  • Enkel .duckdb file for everything
  • Den ursprungliga HNSW-vektorsökningen via VSS-extension
  • SQL för både vektorsökning och graf traversal
  • Sollig utnyttjande komplexitet

DuckDB är inte en grafdatabas - och att ' är punkten Det här är "'", Neo , 4, j, ., Traversal är flyktiga , ,, SQL, -, baserade på , och avsiktliga . Den begränsningen gör att systemet är nedbrytbart och billigt.

Storage-schema

Schematet använder anslut till provenances tabeller - kan vi fråga " vilka chunk som nämner enhet X

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
    }

Nyckeldesign lösning: nej VARCHAR[] för ursprunget. Join tables (entity_mentions, relationship_mentions) gör det möjligt att utföra effektiva queryer som " får alla chunks som nämner Docker

Vektorsökning: HNSW fick dig

DuckDB's HNSW-index triggar bara med 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

Att använda array_cosine_similarity kommer inte använda indexet - det vann’ inte utlösade HNSW-indexet . På icke-- triviella företagen M SK4 blir detta en indexerad query i ms till en komplett tabellscan

Enhetens extraktion

Det är här vi skiljer oss från Microsofts "'" -metod, LLM-per-spända extraktionspasser som används i Microsofts referens GraphRAG Pipeline Statistisk extraktion baserat på IDF-. målet är att stabila, korpus som inte kräver en LLM för att producera

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

Varför finns det inte hardkodade listor i IDF,

Den naiva metoden är HashSet<string> KnownTech = { "Docker", "Kubernetes", ... }. Detta bryter förM SK1

  • Nya teknologier ( du ' måste uppdatera listan
  • Domain-spesifika termer | ( | olika korpus |= | flera enheter |
  • Missplånningar och variationer

IDF (Inverse dokumentfrekvens) löser det statistiskt.

$$, text, {, IDF, }(, t, ), =, | | , log, M SK6, frak, MST7, N, MSP8, df, MSV9, t och MST10

Var:

  • $NM SK1 = totala bitar
  • $df

Hög IDF = ovanlig termin = antagligen en enhetM SK1 "Docker" som visas i 5 av m100 chunks har högre IDF än "den " som syns i \100 av

För mer information om TF-IDF och BMM SK1, se min artikel på hybridsökning med BM25.

Struktursignaler

Markdown-strukturen säger oss vad

  • Rubriker (## Docker Setup) → enhet
  • Inlinekod (`docker-compose`) → enhet
  • Links ([Docker](https://docker.com)) → enhet + relation
// 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);
    }
}

Markdown- länkar erbjuder uppenbart relationer som inte kräver LLM-förklaringar

// 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);
}

Reduktion genom BERT-Embeddings

Beståndsdels namn som "Docker Compose", "dockerM SK3composeMSC4 och ♫ "DokerCompose ♫ should be merged ♫ BERT-bäddar för att upptäcka semantisk likhet:

// 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);
        }
    }
}

Det här steget är O(nM SK1 inom en begränsad kandidatgrupp, , men kandidatkolorna är begränsade av IDF-filtrering och struktursignaler. - inte korpusstorlek, . För mer information om BERT-inbäddar, semantiska sökningar med ONNX och BERT.

Extraktionsmodi

CLI supporterar tre extraktionsmodi via --extraction-mode:

Heuristisk Modus (Default

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

Användar IDF + struktursignaler för enhetens detektion , med optionell LLM-skikt klassificering . Noll per -chunk LLM-sändningar - endast ~1 uppgift per | 50 | enhet för typklassificering

Hybridmodi (Empfohlen )

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

Den bästa av båda världarna:

  1. Häuristisk detektion: IDF + struktursignaler hittar enhetens kandidater
  2. LLM-uppgradering: Ett samtal per dokument validerar enheter och extraherar semantiska relationer
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

För 5-documenter med 62 chunks, gör hybridmodiet 5 LLM-sändningar (vs 124 för full LLM-modus

  • Deterministiska entitetsbeläge från heuristik
  • LLM-kvalitetsrelations extraktion (semantiskt , inte bara ko
  • Beskrywings och validerade typer

LLM-läger (Microsoft-StyleM SK2

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

Hela Microsoft GraphRAG-metoden: 2 LLM ringer per chunk ( entitetsextraktion + relations extraktion). Den dyrasteM SK3 men den högsta kvaliteten för ostrukturerad textMSC4

När man ska använda varje

Modus LLM-sändningar bäst för
Heuristik ~1 per 50 enhet snabb indexering , bra МSK5 strukturerad nedgång
Hybrid 1 per dokument Tillsvarighet i tillämpning och kvalitet
LLM 2 per stycke ♫ ♫ ♫ Ostrukturerad prosa ♫

För teknisk dokumentation, börja med hybrida mode. Det ger dig semantiska relationer utan att behöva heuristisk för ren hastighet, eller Llm för berättelsetext.

Hybridsökning: BM25 M SK2 BERT

Hybridsökning kombinerar två komplementära tillvägagångssätt

Dense (BERTM SK1 Förstår meningen. M SK1Dockercontainer" matchar "containeriseringMSC4 Sparse (BMM SK1 Matchar exakta termer. M SK1HNSW" bara matchar "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

Vad är BM25?

BM25 M SK1Best Match 25) räknar ut dokument baserat på frågaterm frekvens

$$_ \textMSC4IDFMska5qM Ska6i+Mska7

Nyckelinsikter:

  • IDF: s term: Sällsynta ord betyder mer
  • TF tillräckning: Ett ord som dyker upp 10x är mer relevant än
  • Längsnormering: "Long documents don" ' "get unfair advantage"

För en full implementation av BM, 25 och ,, se hybridsökning och indexering.

Reciprokal rankfuusio (RRFM SK1

RRF sammanfogar betyg från olika söksystem. Varje rangposition får en poäng

$$, text, {, RRF, }(, d, M SK3, =,_{r \in RM SK2 \frac{1}{k + rMska6dMske7

Där $kM SK1 ( typiskt 60) förhindrar övervikt på toppresultatet båda rangorde blir ökande:

// 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;
}

exempel: Ett dokument som rangerade M SK1 i täthet och #3 i sparsamhet

  • Dense
  • Sparse
  • Kombinerad (högare än enstaka

Frågamodi

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

Frågaklassificering

// 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;
}

Den här klassifieren är medvetet enkel - och lätt att ersätta med ett litet syftesmodell senare . Om inga enheter matchar , så degraderar systemet rent till en ren hybrid sökning

CLI-användning

Indeksering

# 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    │
└───────────────┴───────┘

Utsökande

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)

Statistiker

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

Förvägning av kostnaderna

För 100 bloggposter

Operation MSFT GraphRAG Heuristisk МSK3 Hybrid LLM ♫
Enhetens extraktion 1,000 anslutningar МSK3 m0 M m0 M 1,000 Anslutningar
Dokumentupphöjning ♫ ♫ ♫
Klassificering Inbegript 2 3 lots 4 5 6 7 lot 8
Samhällens sammanfattningar 2 3 4 5 6 7 8
Tilltal av LLM sändningar ~1,020 ~24 ~120 ~1,024
Attitydkvalitet Semantik Ko- uppträffande Sementik ♫ ♫ ♫ Semantika ♫
kostnaderna (gpt-4o -miniM SK3 ~$5-10 ~$0.15 ~$0.75 ~$5-10
kostnaderna (OllamaM SK1 NM SK1A

Hybridmodiet är den bästa punkten för de flesta tekniska innehållet

Den grova inställningen, -, av -, storleksskattning, ;, är beroende på skalan och form.

Utbyte

Aspect Heuristiska Hybrida LLM MSFT GraphRAG
Bestämning av enheten IDF ♫ ♫ + ♫ struktur ♫
Attityder Ko - uppkomst МSK3 LLM
LLM-sändningar
Attitydkvalitet lågt ♫ ♫ ♫ högt ♫
Fungerar offline Ja Ja
bäst för hastighet Recommended Gamla kompat Ostrukturerad text

Conceptuellt, är detta samma rör som DocSummarizer: bygg konstruktion först , sedan låter en LLM berätta det

Var det bryts ner Sannings- eller berättelsetext utan strukturell markup. Itydliga relationer utan lexisk signal

Kod

Implementationen är minimal - | ~2,000 | linjer över dessa plikter

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

källa: Mostlylucid.GraphRag/

Externa resurser

logo

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