Back to "Частина GraphRAG 2: Мінімальний непрозорий GraphRAG (Необов' язкові виклики для LLM)"

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 2: Мінімальний непрозорий GraphRAG (Необов' язкові виклики для LLM)

Saturday, 27 December 2025

Вхід Частина 1Ми з'ясували, чому GraphRAG має значення. мінімально життєздатний GraphRAG, який працює без окремих викликів LLM "Прагматичний, автономний-перший, і досить дешевий, щоб працювати на ноутбуку:

  • DuckDB для уніфікованого зберігання (вводів + графік у окремому файлі)
  • IDF + структурні сигнали для видобування сутностей (без LLM на шматок)
  • BM25 + BERT- гібридний пошук за допомогою RRF- синтезу
  • Ольямаjapan. kgm для синтезу (нульові витрати API)

Навігація серією:

Код: traphRag threelucid. GraphRag на GitHub

Огляд архітектури

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

Почему ДакDB?

Microsoft' GraphRAG використовує окреме сховище для векторів (LanceDB), об' єктів (паралети), та взаємозв' язків (більше Parquet). DuckDB спрощує це:

  • Одинарна .duckdb файл для всього
  • Пошук за вектором HNSW за допомогою додатка VSS
  • SQL для обох векторних пошуків і побудови графів
  • Складність нульового розгортання

ДакБ - це не база даних графів, і в цьому суть. Це не Neo4j. Traversals неглибокі, базовані на SQL і обдумані.

Схема зберігання

Використання схеми Приєднатися до таблиць для перевірки - мы можем спросить "который кусок упоминает объект 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
    }

Вибір головного дизайну: ні VARCHAR[] для перевірки. Приєднуйтесь до таблиць?entity_mentions, relationship_mentions) увімкнути такі ефективні запити, як "знайдіть всі шматки, що згадують Докера."

Пошук векторів: Плівка HNSW

Індекс HNSW від DuckDB викликає лише з 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

Користування array_cosine_similarity не використовувати індекс На нетривіальній корпорі, це перетворює запит 5мс у секунди.

Видобування сутності

Тут ми розходимося з підходом Microsoft замість 2 LLM виклики на шматок, ми використовуємо Базоване на IDF статистичне видобуванняМета не ідеальна. стійкі, корпус- відносні сигнали для цього не потрібен LLM:

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

Чому IDF, не закодовані списки?

Наївний підхід запрограмований. HashSet<string> KnownTech = { "Docker", "Kubernetes", ... }. Переривання для:

  • Нові технології (вам потрібно оновити список)
  • Особливості доменів (різні корпуси = різні об' єкти)
  • Помилкове написання та варіації

IDF (Зворотна частота документа) Розв' язує це статистично. IDF терміна:

$$\ text}} {лог) =\log\frac $$

Де:

  • \(N\) = загальні документи (унці)
  • $df}t) $$ документи, що містять термін \(t\)

Високий IDF = рідкісний термін =, ймовірно, елемент. " Docker " з' явиться у 5/ 100 шматках має вищий IDF, ніж " IDF " з' явиться у 100/ 100.

Більше про TF-IDF і BM25, див. мій допис гібридний пошук з BM25.

Структурні сигнали

Структура розмітки показує, що є важливим:

  • Заголовок (## Docker Setup) → суттю
  • Вбудований код (`docker-compose`) → суттю
  • Посилання ([Docker](https://docker.com)) → сутність + взаємозв' язки
// 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);
    }
}

Видобування зв' язку (високі зв' язки)

Помітки посилань явний взаємозв' язки, які не вимагають LLMCEction:

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

Знешкодження через вбудовування БЕРТ

Назви сутностей на зразок " Докер Композитний ," " docker- compose " і " DockerCombose " мають бути об' єднані. Ми використовуємо Вбудовування БЕРТ для визначення семантичної подібності:

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

Це O'n2 всередині набору кандидатів, але значення кандидата обмежено фільтрацією IDF та структурними крапками } Не розміром copus. Подробиці щодо вбудовування BERT, дивіться Семантичний пошук з ONNX і BERT.

Hybrid Search: BM25 + BERT

Гибридний пошук комбінує два додаткові підходи:

Щільність (НРТ): Знаходить значення. " Контейнери " відповідають " конфігурації ." Спаз (BM25): Пошук точних термінів. " HNSW " відповідає лише " HNSW ."

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

Що таке BM25?

BM25 (найкращий збіг 25) оформляє результати документів на основі частоти терміну запиту. Формула:

$$\текст {скрип}} Д, Q) =\ sum_^\text}} {q_ i)\cdot\frac i, D) \cdot (k_ 1 + 1) } f}q_ i, D) + k_ 1\cdot (1 - b + b\cdot {drac%} $$$

Інтуїції ключа:

  • Ключ IDF: Rare words matter more ("HNSW" > "The")
  • Насиченість TF: Слово, що з'являється у 10х, не більше 10х, ніж 1х
  • Нормалізація тривалості: Довгі документи не отримують несправедливої користі

Повноцінна реалізація BM25, див. гібридний пошук і індексування.

Reciprocal Rank Fusion (RF)

Рейтинги об' єднання RRF з різних систем отримання даних. Кожна позиція у рангах отримує рахунок:

$$\ text {d) =\sum_{r\ in R} \frac{ 1}Кл + r=d)} $$

Де за k$ (зазвичай 60 доларів) можна уникнути надмірної ваги результату. обидва Рейтинги підвищуються:

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

Приклад: шапка документа # 1 у щільності і # 3 у розрідженні:

  • Щільність: \(1/⇩60+1) = 0, 0164\)
  • Спара: 1 / +60+3) = 0, 0159$
  • Комбіновано: 0. 02323 (вище, ніж будь-хто один)

Режими запиту

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

Класифікація запиту

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

Цей класифікатор спеціально простий } і його легко замінити маленькою моделлю меті пізніше. Якщо нічого не співпадає, система повертається до чистого гібридного отримання.

Використання CLI

Індексування

dotnet run --project Mostlylucid.GraphRag -- index ./test-markdown
GraphRAG Indexer
  Source: test-markdown
  Database: graphrag.duckdb
  Model: llama3.2:3b

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

Запит

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)

Стани

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

Порівняння вартості

Для 100 дописів блогу (~500 шматків):

♪ |-----------|-------------------|---------------------| дрімає 2 × 500 = 1.000 викликів LLM = 01 (необов'язкова дужка) + Перед нами - Космос (безкоштовність) Дівчата мають значення +20. | Загальний індексування 1,020- целей ~20- циклів | Вартість (gpt- 4o- mini) | ~$5-10 | ~$0.10 | | Вартість (Оллама) Пн/А' о. о. amount in units (optional, rarely needs a translation)

Від одного до двох порядків величини дешевше у структурованому технічному змісті (заголовок з заголовками, блоками коду і посиланнями).

Торгові міниunit synonyms for matching user input

♪Цього разу ♪ LLM-Heavy GraphRAG ♪ |---------------------|-------------------| щас, дешеве індексування* } Сутність сутностей} Д. д. д. д. д. д. д. д.: ♪ So-occurensions ♪ Семантичні взаємозв'язки ♪ ♪Output's API access's application

Там, де це розривається: Фіктивний або обліковий текст без структурної розмітки. Неявні взаємозв' язки без лексикального сигналу. Високо двозначні назви сутностей, які вимагають від світових знань, щоб їх розрізнити.

Код

Реалізація мінімальна - 11 файлів, ~ 1500 рядків:

Mostlylucid.GraphRag/
├── Storage/GraphRagDb.cs        # DuckDB with HNSW + provenance
├── Services/EmbeddingService.cs # ONNX BERT wrapper
├── Services/OllamaClient.cs     # LLM client
├── Extraction/EntityExtractor.cs # Heuristic + link extraction
├── 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
└── Program.cs                   # CLI

Джерело: Mostlylucid.GraphRag/

Ця реалізація ділиться своєю основною філософією з DocSummarizer: спочатку вийняти детермінатичну структуру, нехай LLM пояснює її.

Супутні повідомлення

Зовнішні ресурси

logo

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