Back to "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

AI-Article ASP.NET Machine Learning ONNX Qdrant RAG Semantic Search Vector Search

RAG για Εφαρμοστές: Υβριδική αναζήτηση και αυτόματη ευρετηρίαση

Saturday, 22 November 2025

Εισαγωγή

Μέρος της σειράς RAG: Αυτό είναι το Μέρος 5 - πρότυπα ενσωμάτωσης στην παραγωγή:

Το Μέρος 4α, φτιάξαμε τα θεμέλια: ONNX embeddings και Qdrant αποθήκευσης. Μέρος 4β, καλύψαμε την αναζήτηση UI και υβριδική εφαρμογή αναζήτησης . Τώρα θα το κάνουμε έτοιμο με την παραγωγή αυτόματη ευρετηρίαση (Ενημέρωση περιεχομένου μηδενικού αφής) μέσω FileSystemWatcher.

Υβριδική Αναζήτηση: Best of Both Worlds

Σημασιολογική αναζήτηση είναι ισχυρή αλλά παραδοσιακή αναζήτηση πλήρους κειμένου εξακολουθεί να υπερέχει με ακριβείς φράσεις και τεχνικούς όρους. Η λύση; Χρησιμοποίησε και τα δύο.

Γιατί το Υβρίδιο; Διαφορετικές προσεγγίσεις έχουν διαφορετικές δυνάμεις:

  • PostgreSQL πλήρες κείμενο (Καλύπτεται εδώ.): Ακριβείς αγώνες, τεχνικοί όροι, χειριστές Boolean
  • Semantic Vector Search: Σημασία, πλαίσιο, συνώνυμα, εννοιολογικά σχετικό περιεχόμενο

Αμοιβαίες Rank Fusion (RRF)

Χρησιμοποιούμε Ανομοιόμορφη Συσσωρευμένη Συσσωρευμένη Συσσωρευμένη για να συνδυάσετε τα αποτελέσματα από πολλαπλές πηγές αναζήτησης:

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

Ο τύπος RRF: score = Σ(1 / (k + rank))

  • k = 60 (σταθερή για την πρόληψη της κυριαρχίας στις πρώτες τάξεις)
  • rank = θέση στα αποτελέσματα της εν λόγω μεθόδου αναζήτησης
  • Α p i ο τ ε λ έ σ α τα και τα δύο πηγές παίρνουν αποτελέσματα από κάθε προσθήκη μαζί

Γιατί λειτουργεί το RRF:

  • Απομίμηση: Το ίδιο αποτέλεσμα και στις δύο πηγές είναι υψηλότερο
  • Δίκαιη σχέση: Καμία μέθοδος αναζήτησης δεν κυριαρχεί άδικα
  • Ακρίβεια: Δεν απαιτείται περίπλοκος συντονισμός

Εφαρμογή

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

Σημείωση: Αυτό δείχνει σημασιολογική αναζήτηση μόνο. Στην παραγωγή, εκτελέστε την αναζήτηση πλήρους κειμένου PostgreSQL παράλληλα και περιλαμβάνουν αυτά τα αποτελέσματα στον υπολογισμό RRF.

Ενσωμάτωση

Εάν έχετε ήδη εφαρμόσει την αναζήτηση πλήρους κειμένου PostgreSQL (όπως καλύπτεται εδώ), η προσθήκη σημασιολογικής αναζήτησης είναι απλή:

// Program.cs
services.AddSemanticSearch(configuration);
services.AddSingleton<IHybridSearchService, HybridSearchService>();
[HttpGet("search/hybrid")]
public async Task<IActionResult> HybridSearch(string query, string language = "en")
{
    var results = await _hybridSearchService.SearchAsync(query, language);
    return PartialView("_SearchResults", results);
}

Αυτόματη ευρετηρίαση με τον Παρατηρητή συστήματος αρχείων

Το πιο ισχυρό χαρακτηριστικό: αυτόματη ευρετηρίαση. Αποθήκευση μιας ανάρτησης blog, είναι αμέσως αναζητήσιμο - καμία χειροκίνητη παρέμβαση.

Πώς Λειτουργεί

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

Βασική απόφαση σχεδιασμού: Μόνο αρχεία ευρετηρίου στην κεντρικός κατάλογος Markdown, όχι υποκατηγορίες (translated/, drafts/, comments/). Αυτό διατηρεί το δείκτη αναζήτησης καθαρό.

Ενσωμάτωση Παρατηρητών αρχείωνName

Το blog έχει ήδη ένα MarkdownDirectoryWatcherServiceΤο επεκτείνουμε για να ενεργοποιήσουμε σημασιολογική ευρετηρίαση:

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

Διαγραφή χειρισμού

Όταν διαγράφεται μια θέση, αφαιρέστε την από τον σημασιολογικό δείκτη:

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

Υπηρεσία υποβάθρου για αρχική ευρετηρίαση

Κατά την έναρξη της λειτουργίας της, υπάρχει ένας δείκτης υπηρεσιών υποβάθρου που δεν υπάρχει ακόμη στο Qdrant:

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

Αυτό εξασφαλίζει:

  1. Τεμπέλης φόρτωσης μοντέλου - Λήψεις κατά την πρώτη χρήση, δεν εμποδίζουν την εκκίνηση
  2. Επιφανειοδραστική ευρετηρίαση - Μόνο νέες/αλλαγμένες θέσεις που επαναδείχθηκαν (μέσω του περιεχομένου χασίς)
  3. Μόνον ο κύριος κατάλογος - Τα σχέδια και τα μεταφρασμένα αρχεία δεν μολύνουν το ευρετήριο

Τι φτιάξαμε;

Στα μέρη 4α, 4β και 5, έχουμε τώρα:

  • Φιλική με CPU σημασιολογική αναζήτηση - Δεν απαιτείται GPU
  • Ανακάλυψη σχετικών θέσεων - Σημασιολογικά παρόμοιο περιεχόμενο
  • Αναζήτηση φυσικής γλώσσας - Βρείτε με την έννοια, όχι μόνο λέξεις-κλειδιά
  • Υβριδική αναζήτηση - Το καλύτερο σημασιολογικό + πλήρες κείμενο
  • Αυτόματη ευρετηρίαση - Ενημερώσεις για το περιεχόμενο μηδενικής αφής
  • Αυτοεξυπηρετούμενος - Τα δεδομένα σας παραμένουν στον διακομιστή σας

Μελλοντικές βελτιώσεις:

  • Αναζήτηση ειδήσεων κατηγορίας - Ενισχύστε τα αποτελέσματα από συγκεκριμένες κατηγορίες
  • Πολυγλωσσικές Ενσωμάτωση - Γλωσσικά ειδικά μοντέλα προσθήκης
  • Ενσωμάτωση OpenSearch - Προσθήκη OpenSearch στο υβριδικό μείγμα (δείτε το άρθρο μου OpenSearch)

Συμπέρασμα

Αυτό ολοκληρώνει την πρακτική εφαρμογή της σημασιολογικής αναζήτησης τύπου RAG. Μέρος 4α (ιδρυση) και Μέρος 4β (search UI), έχετε όλα τα απαραίτητα για να προσθέσετε έξυπνη αναζήτηση στην εφαρμογή σας .NET - τρέχει εξ ολοκλήρου σε CPU, με μηδενικό επιπλέον κόστος.

Συνεχίστε να μαθαίνετε

Πόροι

Βάσεις δεδομένων Qdrant & Vector

Υβριδική αναζήτηση

Παρακολούθηση συστήματος αρχείων

Πλήρεςς κωδικός

Όλος ο κωδικός είναι διαθέσιμος στη διεύθυνση: github.com/scottgal/κυρίως διαυγής ιστός

  • Mostlylucid.SemanticSearch/ - Κεντρική σημασιολογική βιβλιοθήκη αναζήτησης
  • Mostlylucid/Blog/WatcherService/ - Παρατηρητής αρχείων με σημασιολογική ευρετηρίαση
logo

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