# RAG voor Implementers: Hybrid Search en Automatische Indexering

<datetime class="hidden">2025-11-22T12:00</datetime>

<!-- category -- ASP.NET, Semantic Search, ONNX, Qdrant, Machine Learning, Vector Search, RAG, AI-Article -->
# Inleiding

**Deel van de RAG-serie:** Dit is deel 5 - productie-integratiepatronen:

- [Deel 1: RAG's en fundamentele beginselen](/blog/rag-primer) - Wat inbeddingen zijn, waarom ze belangrijk zijn.
- [Deel 2: RAG-architectuur en interne markt](/blog/rag-architecture) - Chunking, tokensization, vector databases
- [Deel 3: RAG in de praktijk](/blog/rag-practical-applications) - Complete RAG-systemen bouwen
- [Deel 4a: ONNX- en Qdrant-implementatie](/blog/semantic-search-with-onnx-and-qdrant) - CPU-vriendelijke semantische zoekstichting
- [Deel 4b: Semantisch zoeken in actie](/blog/semantic-search-in-action) - Typeahead, hybride zoekopdracht, en UI-componenten
- **Deel 5: Hybrid Search & Auto-Indexing** (dit artikel) - Productie-integratiepatronen
- [Deel 6: GraphRAG](/blog/graphrag-knowledge-graphs-for-rag) - Kennisgrafieken voor corpus-level begrip

In [Deel 4 bis](/blog/semantic-search-with-onnx-and-qdrant), bouwden we de stichting: ONNX inbeddingen en Qdrant opslag. [Deel 4b](/blog/semantic-search-in-action), we hebben de zoekopdracht UI en hybride zoekimplementatie. Nu maken we het productie-klaar met **automatische indexering** (nul-touch content updates) via FileSystemWatcher.

[TOC]

# Hybrid Search: Best of Both Worlds

Semantische zoekopdracht is krachtig maar traditionele full-text zoekopdracht blinkt nog steeds uit in exacte zinnen en technische termen. **De oplossing?** Gebruik beide.

**Waarom Hybrid?** Verschillende benaderingen hebben verschillende sterke punten:

- **PostgreSQL volledige tekst** ([Hieronder vallen](/blog/textsearchingpt1)): Exacte overeenkomsten, technische termen, Booleaanse exploitanten
- **Semantic Vector Zoeken**: Betekenis, context, synoniemen, conceptueel gerelateerde inhoud

## Wederzijdse fusie van rangen (RRF)

We gebruiken **Wederzijdse fusie van rangen** om resultaten van meerdere zoekbronnen te combineren:

```mermaid
flowchart TB
    A[User Query: 'docker containers'] --> B[PostgreSQL Full-Text Search]
    A --> C[Semantic Vector Search]

    B --> D["Results:<br/>1. 'Docker Basics' (rank 1)<br/>2. 'Containerizing Apps' (rank 2)<br/>3. 'Docker Compose' (rank 3)"]
    C --> E["Results:<br/>1. 'Containerizing Apps' (rank 1)<br/>2. 'Kubernetes Guide' (rank 2)<br/>3. 'Docker Basics' (rank 3)"]

    D --> F[RRF Algorithm]
    E --> F

    F --> G["Combined Results:<br/>1. 'Containerizing Apps'<br/>   (1/61 + 1/62 = 0.0328)<br/>2. 'Docker Basics'<br/>   (1/61 + 1/63 = 0.0322)<br/>3. 'Docker Compose'<br/>   (1/63 = 0.0159)"]

    style A stroke:#10b981,stroke-width:2px
    style B stroke:#3b82f6,stroke-width:2px
    style C stroke:#6366f1,stroke-width:2px
    style D stroke:#3b82f6,stroke-width:2px
    style E stroke:#6366f1,stroke-width:2px
    style F stroke:#ec4899,stroke-width:4px
    style G stroke:#8b5cf6,stroke-width:2px
```

**De RRF-formule:** `score = Σ(1 / (k + rank))`

- `k` = 60 (constant om te voorkomen dat vroege rangen domineren)
- `rank` = positie in de resultaten van die zoekmethode
- Resultaten in **beide** bronnen krijgen scores van elk toegevoegd samen

**Waarom RRF werkt:**

- **Deduplicatie**: Hetzelfde resultaat in beide bronnen scoort hoger
- **Eerlijkheid**: Geen zoekmethode domineert oneerlijk
- **Eenvoud**: Geen complexe afstemming vereist

## Uitvoering

```csharp
public class HybridSearchService : IHybridSearchService
{
    private readonly ISemanticSearchService _semanticSearchService;
    private const int RrfConstant = 60;

    public async Task<List<SearchResult>> SearchAsync(
        string query,
        string language = "en",
        int limit = 10,
        CancellationToken cancellationToken = default)
    {
        // Execute both searches in parallel
        var semanticResults = await _semanticSearchService.SearchAsync(
            query, limit * 2, cancellationToken);

        // Filter by language and apply RRF
        var filteredResults = semanticResults
            .Where(r => r.Language == language)
            .ToList();

        return ApplyReciprocalRankFusion(filteredResults)
            .Take(limit)
            .ToList();
    }

    private List<SearchResult> ApplyReciprocalRankFusion(List<SearchResult> results)
    {
        var rrfScores = new Dictionary<string, RrfScore>();

        for (int i = 0; i < results.Count; i++)
        {
            var result = results[i];
            var key = $"{result.Slug}_{result.Language}";

            if (!rrfScores.ContainsKey(key))
                rrfScores[key] = new RrfScore { Result = result };

            // RRF formula: 1 / (k + rank)
            rrfScores[key].Score += 1.0 / (RrfConstant + i + 1);
        }

        return rrfScores.Values
            .OrderByDescending(x => x.Score)
            .Select(x => x.Result)
            .ToList();
    }
}
```

> **Opmerking:** Dit toont semantische zoekopdracht alleen. Voer in productie PostgreSQL full-text search parallel uit en neem deze resultaten in de RRF berekening op.

## Integratie

Als je PostgreSQL full-text search al hebt geïmplementeerd ([zoals hier behandeld](/blog/textsearchingpt1)), het toevoegen van semantische zoekopdracht is eenvoudig:

```csharp
// Program.cs
services.AddSemanticSearch(configuration);
services.AddSingleton<IHybridSearchService, HybridSearchService>();
```

```csharp
[HttpGet("search/hybrid")]
public async Task<IActionResult> HybridSearch(string query, string language = "en")
{
    var results = await _hybridSearchService.SearchAsync(query, language);
    return PartialView("_SearchResults", results);
}
```

# Automatisch indexeren met File System Watcher

De meest krachtige eigenschap: **automatische indexering**. Sla een blogpost op, het is direct doorzoekbaar - geen handmatige interventie.

## Hoe het werkt

```mermaid
flowchart TB
    A[Save Markdown File] --> B[FileSystemWatcher Detects Change]
    B --> C{File in Main Directory?}
    C -->|Yes| D[Save to Database]
    C -->|No| E[Save to Database Only]
    D --> F[Create BlogPostDocument]
    F --> G[Generate Embedding via ONNX]
    G --> H[Store in Qdrant]
    H --> I[Post Searchable Immediately]

    style A stroke:#10b981,stroke-width:2px
    style B stroke:#f59e0b,stroke-width:2px
    style C stroke:#ec4899,stroke-width:3px
    style D stroke:#3b82f6,stroke-width:2px
    style E stroke:#6b7280,stroke-width:2px
    style F stroke:#8b5cf6,stroke-width:2px
    style G stroke:#6366f1,stroke-width:3px
    style H stroke:#ef4444,stroke-width:2px
    style I stroke:#10b981,stroke-width:2px
```

**Belangrijkste ontwerpbeschikking:** Alleen indexbestanden in de **hoofdmap markdown**, niet subdirectories (`translated/`, `drafts/`, `comments/`Dit houdt de zoekindex schoon.

## File Watcher-integratie

De blog heeft al een `MarkdownDirectoryWatcherService`We breiden het uit om semantische indexering te activeren:

```csharp
// In MarkdownDirectoryWatcherService.cs
private async Task OnChangedAsync(WaitForChangedResult e)
{
    if (e.Name == null) return;

    await retryPolicy.ExecuteAsync(async () =>
    {
        var savedModel = await blogService.SavePost(slug, language, markdown);

        // Index ONLY if file is in main directory (no path separators in name)
        if (!e.Name.Contains(Path.DirectorySeparatorChar) &&
            !e.Name.Contains(Path.AltDirectorySeparatorChar))
        {
            await IndexPostForSemanticSearchAsync(scope, savedModel, language);
        }
    });
}

private async Task IndexPostForSemanticSearchAsync(
    IServiceScope scope,
    BlogPostDto post,
    string language)
{
    var semanticSearchService = scope.ServiceProvider.GetService<ISemanticSearchService>();
    if (semanticSearchService == null) return; // Not configured

    var document = new BlogPostDocument
    {
        Id = $"{post.Slug}_{language}",
        Slug = post.Slug,
        Title = post.Title,
        Content = post.PlainTextContent,
        Language = language,
        Categories = post.Categories?.ToList() ?? new List<string>(),
        PublishedDate = post.PublishedDate
    };

    await semanticSearchService.IndexPostAsync(document);
    _logger.LogInformation("Indexed {Slug} ({Language}) in semantic search", post.Slug, language);
}
```

## Verwijderingen verwerken

Als een bericht wordt verwijderd, verwijder het dan uit de semantische index:

```csharp
private async Task OnDeletedAsync(WaitForChangedResult e)
{
    await blogService.Delete(slug, language);

    // Delete from semantic search ONLY if file was in main directory
    if (!e.Name.Contains(Path.DirectorySeparatorChar) &&
        !e.Name.Contains(Path.AltDirectorySeparatorChar))
    {
        var semanticSearchService = scope.ServiceProvider.GetService<ISemanticSearchService>();
        await semanticSearchService?.DeletePostAsync(slug, language);
    }
}
```

# Achtergronddienst voor initiële indexering

Bij het opstarten, een background service indexeert bestaande berichten nog niet in Qdrant:

```mermaid
flowchart TB
    A[Application Starts] --> B[Wait 10 seconds]
    B --> C[Initialize Semantic Search]
    C --> D{Model Exists?}
    D -->|No| E[Download from Hugging Face]
    D -->|Yes| F[Load ONNX Model]
    E --> F
    F --> G[Scan Main Markdown Directory]
    G --> H{For Each .md File}
    H --> I[Compute Content Hash]
    I --> J{Hash Changed?}
    J -->|Yes| K[Generate Embedding]
    J -->|No| L[Skip - Already Indexed]
    K --> M[Store in Qdrant]
    M --> H
    L --> H
    H -->|Done| N[Indexing Complete]

    style A stroke:#10b981,stroke-width:2px
    style C stroke:#6366f1,stroke-width:2px
    style E stroke:#f59e0b,stroke-width:2px
    style F stroke:#6366f1,stroke-width:3px
    style G stroke:#8b5cf6,stroke-width:2px
    style J stroke:#ec4899,stroke-width:3px
    style K stroke:#6366f1,stroke-width:2px
    style M stroke:#ef4444,stroke-width:2px
    style N stroke:#10b981,stroke-width:2px
```

```csharp
public class SemanticIndexingBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Wait for app to be ready
        await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);

        // Initialize (downloads model if needed)
        await _semanticSearchService.InitializeAsync(stoppingToken);

        // Get all posts from main directory only
        var markdownFiles = Directory.GetFiles(
            _markdownConfig.MarkdownPath,
            "*.md",
            SearchOption.TopDirectoryOnly);  // NOT subdirectories

        foreach (var file in markdownFiles)
        {
            var needsIndexing = await _semanticSearchService.NeedsReindexingAsync(
                slug, language, contentHash, stoppingToken);

            if (needsIndexing)
                await _semanticSearchService.IndexPostAsync(document, stoppingToken);
        }
    }
}
```

**Dit garandeert:**

1. **Lazy model loading** - Downloads bij eerste gebruik, niet blokkeren opstarten
2. **Incrementele indexering** - Alleen nieuwe/veranderde berichten geherindexeerd (via content hash)
3. **Alleen hoofdmap** - Concepten en vertaalde bestanden vervuilen de index niet

# Wat we gebouwd hebben

In de delen 4a, 4b en 5 hebben we nu:

- ✅ **CPU-vriendelijke semantische zoekopdracht** - Geen GPU nodig
- ✅ **Gerelateerde berichten ontdekking** - Semantisch vergelijkbare inhoud
- ✅ **Natuurlijke taal zoeken** - Zoek door betekenis, niet alleen trefwoorden
- ✅ **Hybride zoekopdracht** - Best of semantic + full-text
- ✅ **Automatische indexering** - Zero-touch content updates
- ✅ **Zelfgastheer** - Uw gegevens blijven op uw server

**Toekomstverbeteringen:**

- **Category-Aware Search** - Boost resultaten van specifieke categorieën
- **Meertalige inbeddingen** - Taalspecifieke inbeddingsmodellen
- **OpenZoekintegratie** - OpenSearch toevoegen aan de hybride mix ([zie mijn OpenSearch-artikel](/blog/textsearchingpt3))

# Conclusie

Dit completeert de praktische implementatie van RAG-stijl semantisch zoeken. [Deel 4 bis](/blog/semantic-search-with-onnx-and-qdrant) (stichting) en [Deel 4b](/blog/semantic-search-in-action) (search UI), je hebt alles wat nodig is om intelligente zoekopdracht toe te voegen aan uw .NET applicatie - volledig draait op CPU, tegen nul extra kosten.

## Doorgaan met leren

- **[RAG Deel 1: Oorsprongen en Fundamentelen](/blog/rag-primer)** - De theorie achter inbeddingen
- **[RAG Deel 2: Architectuur en Interne Zaken](/blog/rag-architecture)** - Diepe duik in RAG systemen
- **[RAG Deel 3: Praktische toepassingen](/blog/rag-practical-applications)** - Complete RAG met LLM integratie
- **[Deel 4a: ONNX- en Qdrant-implementatie](/blog/semantic-search-with-onnx-and-qdrant)** - Stichting: inbeddingen en vectoropslag
- **[Deel 4b: Semantisch zoeken in actie](/blog/semantic-search-in-action)** - Typeahead, hybride zoekopdracht, en UI
- **[Volledige tekst zoeken met PostgreSQL](/blog/textsearchingpt1)** - De full-text kant van hybride zoekopdracht

## Middelen

### Qdrant- en vectordatabases

- [Self-Hosted Vector Databanken met Qdrant](/blog/self-hosted-vector-databases-qdrant) - Diepe duik in Qdrant concepten, HNSW indexeren, filteren, en C# client
- [Qdrant Hybride Search](https://qdrant.tech/documentation/concepts/hybrid-queries/) - Qdrant's inheemse hybride ondersteuning

### Hybride zoekopdracht

- [Diversificatieklasse Fusion Paper](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) - Het RRF algoritme

### Bestandssysteem aan het bekijken

- [BestandssysteemWatcher-klasse](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) - .NET documentatie
- [AchtergrondService-klasse](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.backgroundservice) - Hosted services in ASP.NET Core

### Voltooide code

Alle code is beschikbaar op: [github.com/scottgal/meestallucidweb](https://github.com/scottgal/mostlylucidweb)

- `Mostlylucid.SemanticSearch/` - Semantische zoekbibliotheek
- `Mostlylucid/Blog/WatcherService/` - Filewatcher met semantische indexering