RAG for Implementers: Recherche sémantique en action (Français (French))

RAG for Implementers: Recherche sémantique en action

Tuesday, 25 November 2025

//

17 minute read

Introduction

📖 Partie de la série RAG Il s'agit de la partie 4b - des fonctions de recherche et de l'interface

À l'intérieur Partie 4a, nous avons construit la base : embeddings ONNX et stockage de vecteurs Qdrant UI de recherche réelle - incluant le typeahead autocomplete, recherche hybride combinant la sémantique + pleineM SK3 texteMSC4 et filtration avancéeMNK5

Cet article traite des expériences de recherche réelles avec lesquelles les utilisateurs interagissent sur ce blog.

L'expérience de la recherche typeahead

La case de recherche située en haut de ce site fournit une recherche instantanée-as-youM SK2type results . VoiciMSC4la façon dont il fonctionne~:

sequenceDiagram
    participant U as User
    participant A as Alpine.js
    participant API as SearchApi
    participant H as HybridSearch
    participant S as Semantic Search
    participant P as PostgreSQL

    U->>A: Types "docker"
    A->>A: Debounce 300ms
    A->>API: GET /api/search/docker
    API->>H: HybridSearchAsync("docker")
    par Parallel Search
        H->>S: SearchAsync("docker", 20)
        H->>P: GetSearchResultForComplete("docker")
    end
    S-->>H: Semantic results (by meaning)
    P-->>H: Full-text results (by keywords)
    H->>H: Apply RRF scoring
    H-->>API: Combined results
    API-->>A: JSON results
    A->>U: Display dropdown

La composante Alpine.js Typeahead

La boîte de recherche utilise Alpine.js pour l'interface réactive sans cadres JavaScript lourds. iciM SK1 est le composant:

export function typeahead() {
    return {
        query: '',
        results: [],
        highlightedIndex: -1, // Tracks keyboard navigation

        search() {
            // Minimum 2 characters to trigger search
            if (this.query.length < 2) {
                this.results = [];
                this.highlightedIndex = -1;
                return;
            }

            fetch(`/api/search/${encodeURIComponent(this.query)}`, {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' }
            })
            .then(response => {
                if (response.ok) return response.json();
                return Promise.reject(response);
            })
            .then(data => {
                this.results = data;
                this.highlightedIndex = -1;
                // Process HTMX attributes in results
                this.$nextTick(() => {
                    htmx.process(document.getElementById('searchresults'));
                });
            })
            .catch((response) => {
                console.log("Error fetching search results");
            });
        },

        // Keyboard navigation
        moveDown() {
            if (this.highlightedIndex < this.results.length - 1) {
                this.highlightedIndex++;
            }
        },

        moveUp() {
            if (this.highlightedIndex > 0) {
                this.highlightedIndex--;
            }
        },

        selectHighlighted() {
            if (this.highlightedIndex >= 0 && this.highlightedIndex < this.results.length) {
                this.selectResult(this.highlightedIndex);
            }
        },

        selectResult(selectedIndex) {
            // Click the HTMX link to navigate
            let links = document.querySelectorAll('#searchresults a');
            links[selectedIndex].click();
            this.results = [];
            this.highlightedIndex = -1;
            this.query = '';
        }
    }
}

Principales caractéristiques:

  1. Input débloqué: 300ms de retard permet d'empêcher le marquage du serveur
  2. Durée minimale: Il faut au moins 2 caractères
  3. Navigation de la clavier: Pièces d'arrondissement + Inscrire pour l'accessibilité
  4. Intégration HTMX: Les résultats utilisent HTMX pour une navigation uniforme

La boîte de recherche HTML

<div x-data="window.mostlylucid.typeahead()"
     class="relative"
     x-on:click.outside="results = []">

    <label class="input input-sm bg-white dark:bg-custom-dark-bg input-bordered flex items-center gap-2">
        <input
            type="text"
            x-model="query"
            x-on:input.debounce.300ms="search"
            x-on:keydown.down.prevent="moveDown"
            x-on:keydown.up.prevent="moveUp"
            x-on:keydown.enter.prevent="selectHighlighted"
            placeholder="Search..."
            class="border-0 grow input-sm text-black dark:text-white bg-transparent w-full"/>
        <i class="bx bx-search"></i>
    </label>

    <!-- Dropdown Results -->
    <ul x-show="results.length > 0"
        id="searchresults"
        class="absolute z-10 my-2 w-full bg-white dark:bg-custom-dark-bg border rounded-lg shadow-lg">
        <template x-for="(result, index) in results" :key="result.slug">
            <li :class="{'bg-blue-light dark:bg-blue-dark': index === highlightedIndex}"
                class="cursor-pointer text-sm p-2 m-2 hover:bg-blue-light dark:hover:bg-blue-dark">
                <a hx-boost="true"
                   hx-target="#contentcontainer"
                   hx-swap="innerHTML show:window:top"
                   :href="result.url"
                   x-text="result.title"></a>
            </li>
        </template>
    </ul>
</div>

Pourquoi? x-on:click.outside? Cliquer à l'extérieur de la fenêtre d'affichage le ferme - modèle UX standard pour autocomplété .

L'API de recherche

L'article 5 de la Loi sur l'immigration et le statut des réfugiés /api/search/{query} point final de l'alimentation du typeahead. Là'est le contrôleurM SK2

[ApiController]
[Route("api")]
public class SearchApi(
    BlogSearchService searchService,
    UmamiBackgroundSender umamiBackgroundSender,
    ISemanticSearchService semanticSearchService,
    SemanticSearchConfig semanticSearchConfig) : ControllerBase
{
    private const int RrfConstant = 60; // Reciprocal Rank Fusion constant

    [HttpGet]
    [Route("search/{query}")]
    [OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query" })]
    public async Task<Results<JsonHttpResult<List<SearchResults>>, BadRequest<string>>> Search(string query)
    {
        using var activity = Log.Logger.StartActivity("Search {query}", query);
        try
        {
            var host = Request.Host.Value;
            List<SearchResults> output;

            // Use hybrid search if semantic search is enabled
            if (semanticSearchConfig.Enabled)
            {
                output = await HybridSearchAsync(query, host);
            }
            else
            {
                // Fallback to full-text search only
                output = await FullTextSearchAsync(query, host);
            }

            // Track search event for analytics
            var encodedQuery = HttpUtility.UrlEncode(query);
            await umamiBackgroundSender.Track("searchEvent", new UmamiEventData { { "query", encodedQuery } });

            return TypedResults.Json(output);
        }
        catch (Exception e)
        {
            Log.Error(e, "Error in search");
            return TypedResults.BadRequest("Error in search");
        }
    }
}

Décisions importantes en matière de conception:

  1. Fabrication d'un drapeau: semanticSearchConfig.Enabled permet de régler la recherche sémantique
  2. Cache de sortie: 1-heure de cache réduit la charge du serveur pour les demandes courantes
  3. Surveillance de l'analyse: Chaque recherche est suivie.
  4. Dégradation gracieuse: Retourne à PostgreSQL si la recherche sémantique échoue

Recherche hybride avec fusion de rang réciproque

La vraie magie est Recherche hybride - combinaison sémantique et complète-résultats textuelsM SK2 Nous utilisons la fusion de rangs réciproques RRFMSC4 pour les fusionner équitablement

Pourquoi recherche hybride?

Les différentes approches de recherche ont des forces différentes:

Type de recherche
Sémantique Synonymes
Texte intégral Mots clés exactsM SK1 termes techniques Aucune compréhension du synonyme

Exemple: Recherche pour "déploiement de conteneurs"

  • Trousses sémantiques: M SK1Tutoriaux de docker", | | "Guide des Kubernetes
  • Entrée intégrale-texte trouves : Postes contenant exactement "déploiement de conteneursM SK3
  • Hybride obtient le meilleur des deux !

L'algorithme RRF

flowchart TB
    subgraph Semantic[Semantic Results]
        S1[Docker Containers - 0.92]
        S2[Kubernetes Basics - 0.87]
        S3[Container Security - 0.81]
    end

    subgraph FullText[Full-Text Results]
        F1[Container Security - rank 1]
        F2[Docker Containers - rank 2]
        F3[CI/CD Pipelines - rank 3]
    end

    subgraph RRF[RRF Scores]
        R1[Container Security = 0.0327]
        R2[Docker Containers = 0.0325]
        R3[Kubernetes Basics = 0.0161]
        R4[CI/CD Pipelines = 0.0159]
    end

    subgraph Final[Final Ranking]
        O1[Container Security]
        O2[Docker Containers]
        O3[Kubernetes Basics]
        O4[CI/CD Pipelines]
    end

    S1 --> R2
    S2 --> R3
    S3 --> R1
    F1 --> R1
    F2 --> R2
    F3 --> R4
    R1 --> O1
    R2 --> O2
    R3 --> O3
    R4 --> O4

La formule: score = Σ(1 / (k + rank))

Où:

  • k = 60 (constant pour empêcher les premiers rangs de dominer
  • rank = position dans cette méthode de rechercheM SK1 résultats (1-indexé)

Pourquoi le RRF fonctionne:

  • Résultats apparaissant dans les deux Sources score plus élevé
  • Aucune source unique ne peut dominer
  • Aucun ajustement complexe n'est requis

Mise en œuvre

private async Task<List<SearchResults>> HybridSearchAsync(string query, string host)
{
    // Run both searches in parallel
    var fullTextTask = GetFullTextResultsAsync(query);
    var semanticTask = semanticSearchService.SearchAsync(query, limit: 20);

    await Task.WhenAll(fullTextTask, semanticTask);

    var fullTextResults = await fullTextTask;
    var semanticResults = await semanticTask;

    // Apply Reciprocal Rank Fusion to combine results
    var rrfScores = new Dictionary<string, (double Score, string Title, string Slug)>();

    // Score full-text results
    for (int i = 0; i < fullTextResults.Count; i++)
    {
        var (title, slug) = fullTextResults[i];
        var key = slug.ToLowerInvariant();
        var rrfScore = 1.0 / (RrfConstant + i + 1);

        if (rrfScores.TryGetValue(key, out var existing))
        {
            rrfScores[key] = (existing.Score + rrfScore, title, slug);
        }
        else
        {
            rrfScores[key] = (rrfScore, title, slug);
        }
    }

    // Score semantic results
    for (int i = 0; i < semanticResults.Count; i++)
    {
        var result = semanticResults[i];
        var key = result.Slug.ToLowerInvariant();
        var rrfScore = 1.0 / (RrfConstant + i + 1);

        if (rrfScores.TryGetValue(key, out var existing))
        {
            rrfScores[key] = (existing.Score + rrfScore, existing.Title, existing.Slug);
        }
        else
        {
            rrfScores[key] = (rrfScore, result.Title, result.Slug);
        }
    }

    // Sort by combined RRF score and return top results
    return rrfScores.Values
        .OrderByDescending(x => x.Score)
        .Take(15)
        .Select(x => new SearchResults(
            x.Title.Trim(),
            x.Slug,
            Url.ActionLink("Show", "Blog", new { x.Slug }, "https", host)))
        .ToList();
}

Principaux détails de la mise en œuvre:

  1. Exécution parallèle: Les deux recherches se déroulent simultanément (Task.WhenAll)
  2. Cas-Déduction insensible: Les boues normalisées avec ToLowerInvariant()
  3. Accumulation des notes: Le même post dans les deux sources reçoit des points additionnels
  4. Haut 15 Résultats: Enough for typeahead , non surprenant

Retour à la recherche de texte -

Lorsque la recherche sémantique est désactivée ou échoue, nous retournons à PostgreSQL complet-recherche de texteM SK2

Traitement des demandes

La recherche de texte intégrale- traite deux cas différemment

private async Task<List<(string Title, string Slug)>> GetFullTextResultsAsync(string query)
{
    if (!query.Contains(' '))
        return await searchService.GetSearchResultForComplete(query);  // Wildcard
    else
        return await searchService.GetSearchResultForQuery(query);     // Web search
}

Mot unique ("docker"): Utilise la recherche de préfixes de cartes sauvages docker:* Mots multiples ("docker containersM SK1 Utilise la syntaxe de recherche Web PostgreSQL'

Questions PostgreSQL

// Single word with wildcard
private IQueryable<BlogPostEntity> QueryForWildCard(string query)
{
    return context.BlogPosts
        .Include(x => x.Categories)
        .Include(x => x.LanguageEntity)
        .AsNoTracking()
        .Where(x =>
            !x.IsHidden
            && (x.ScheduledPublishDate == null || x.ScheduledPublishDate <= now)
            && (x.SearchVector.Matches(EF.Functions.ToTsQuery("english", query + ":*"))
                || x.Categories.Any(c =>
                    EF.Functions.ToTsVector("english", c.Name)
                        .Matches(EF.Functions.ToTsQuery("english", query + ":*"))))
            && x.LanguageEntity.Name == "en")
        .OrderByDescending(x =>
            x.SearchVector.Rank(EF.Functions.ToTsQuery("english", query + ":*")));
}

// Multiple words with web search
private IQueryable<BlogPostEntity> QueryForSpaces(string processedQuery)
{
    return context.BlogPosts
        .Where(x =>
            x.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery))
            || x.Categories.Any(c =>
                EF.Functions.ToTsVector("english", c.Name)
                    .Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery))))
        .OrderByDescending(x =>
            x.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english", processedQuery)));
}

Pourquoi? WebSearchToTsQuery? Il gère les demandes de langue naturelle comme Google:

  • "docker containers" → recherche les deux mots
  • docker OR kubernetes → Boolean OR
  • docker -compose → exclut "compose

Pour de plus amples renseignements sur PostgreSQL, voir Recherche de texte complet avec Postgres.

La page de recherche complète

Au-delà de typeahead,, il existe une page complète des résultats de recherche avec filtration avancée:

flowchart LR
    subgraph SearchPage[Search Page]
        A[Query Input] --> B{Filters}
        B --> C[Language Filter]
        B --> D[Date Range Filter]
        C --> E[Search Results]
        D --> E
        E --> F[Paginated List]
    end

    style A stroke:#10b981,stroke-width:2px
    style B stroke:#6366f1,stroke-width:2px
    style E stroke:#ec4899,stroke-width:2px
    style F stroke:#8b5cf6,stroke-width:2px

Contrôleur de recherche

[Route("search")]
public class SearchController(
    BaseControllerService baseControllerService,
    BlogSearchService searchService,
    ISemanticSearchService semanticSearchService,
    ILogger<SearchController> logger)
    : BaseController(baseControllerService, logger)
{
    [HttpGet]
    [Route("")]
    [OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query", "page", "pageSize", "language", "dateRange", "startDate", "endDate" })]
    public async Task<IActionResult> Search(
        string? query,
        int page = 1,
        int pageSize = 10,
        string? language = null,
        DateRangeOption dateRange = DateRangeOption.AllTime,
        DateTime? startDate = null,
        DateTime? endDate = null,
        [FromHeader] bool pagerequest = false)
    {
        // Calculate date range based on option
        var (calculatedStartDate, calculatedEndDate) = CalculateDateRange(dateRange, startDate, endDate);

        // Get available languages for the filter dropdown
        var availableLanguages = await searchService.GetAvailableLanguagesAsync();

        if (string.IsNullOrEmpty(query?.Trim()))
        {
            var emptyModel = new SearchResultsModel { /* ... */ };
            if (Request.IsHtmx()) return PartialView("SearchResults", emptyModel);
            return View("SearchResults", emptyModel);
        }

        var searchResults = await searchService.HybridSearchWithPagingAsync(
            query,
            language,
            calculatedStartDate,
            calculatedEndDate,
            page,
            pageSize);

        // Build response model...
        if (pagerequest && Request.IsHtmx())
            return PartialView("_SearchResultsPartial", searchModel.SearchResults);
        if (Request.IsHtmx())
            return PartialView("SearchResults", searchModel);
        return View("SearchResults", searchModel);
    }
}

Options relatives à la portée des dates

public enum DateRangeOption
{
    AllTime,
    LastWeek,
    LastMonth,
    LastYear,
    Custom
}

private static (DateTime? StartDate, DateTime? EndDate) CalculateDateRange(
    DateRangeOption dateRange, DateTime? startDate, DateTime? endDate)
{
    var now = DateTime.UtcNow;
    return dateRange switch
    {
        DateRangeOption.LastWeek => (now.AddDays(-7), now),
        DateRangeOption.LastMonth => (now.AddMonths(-1), now),
        DateRangeOption.LastYear => (now.AddYears(-1), now),
        DateRangeOption.Custom => (startDate, endDate),
        _ => (null, null) // AllTime - no date filter
    };
}

Postes connexes avec Lazy Loading

Chaque post de blog montre des articles sémantiquement similaires dans un panneau collapsible. Cela utilise HTMX pour charger lentement:

<!-- In blog post view -->
<div class="print:hidden"
     hx-get="/search/related/@Model.Slug/@Model.Language"
     hx-trigger="load delay:500ms"
     hx-swap="innerHTML">
    <!-- Loading placeholder -->
    <div class="mt-8 mb-8 text-center opacity-50">
        <span class="loading loading-spinner loading-md"></span>
        <p class="text-sm mt-2">Finding related posts...</p>
    </div>
</div>

Pourquoi retarder 500msM SK1 Le contenu principal est chargé d’abord, puis les articles connexes sont chargés en arrière-planM SK1 Les utilisateurs voient le contenu immédiatement.

Postes connexes Point final

[HttpGet]
[Route("related/{slug}/{language}")]
[OutputCache(Duration = 7200, VaryByRouteValueNames = new[] {"slug", "language"})]
public async Task<IActionResult> RelatedPosts(string slug, string language, int limit = 5)
{
    var results = await semanticSearchService.GetRelatedPostsAsync(slug, language, limit);

    if (Request.IsHtmx())
    {
        return PartialView("_RelatedPosts", results);
    }

    return Json(results);
}

2- cache horaire: Postes connexes ne changent pas de façon fréquenteM SK1 , c’est pourquoi le caching agressif est sûr

Component des postes connexes

Un composant de collapse DaisyUI avec un progrès radial indiquant des cotes de similitude:

@model List<SearchResult>

@if (Model != null && Model.Any())
{
    <div class="mt-8 mb-8">
        <div class="collapse collapse-arrow bg-base-200">
            <input type="checkbox" class="peer" />
            <div class="collapse-title text-xl font-medium">
                <i class='bx bx-brain text-2xl mr-2'></i>
                Related Posts
                <span class="badge badge-secondary badge-sm ml-2">@Model.Count</span>
            </div>
            <div class="collapse-content">
                <div class="divider mt-0"></div>
                <div class="space-y-2">
                    @foreach (var post in Model)
                    {
                        <div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
                            <div class="card-body p-4">
                                <div class="flex items-start justify-between">
                                    <div class="flex-1">
                                        <a hx-boost="true"
                                           hx-target="#contentcontainer"
                                           asp-action="Show"
                                           asp-controller="Blog"
                                           asp-route-slug="@post.Slug"
                                           asp-route-language="@post.Language"
                                           class="card-title text-base hover:text-secondary">
                                            @post.Title
                                        </a>

                                        @if (post.Categories?.Any() == true)
                                        {
                                            <div class="flex flex-wrap gap-1 mt-2">
                                                @foreach (var category in post.Categories.Take(3))
                                                {
                                                    <span class="badge badge-outline badge-sm">@category</span>
                                                }
                                            </div>
                                        }

                                        <div class="flex items-center gap-3 mt-2 text-sm opacity-70">
                                            <span>
                                                <i class='bx bx-calendar'></i>
                                                @post.PublishedDate.ToString("MMM dd, yyyy")
                                            </span>
                                            <span>
                                                <i class='bx bx-planet'></i>
                                                @post.Language.ToUpper()
                                            </span>
                                        </div>
                                    </div>

                                    <!-- Similarity Score -->
                                    <div class="flex flex-col items-end ml-4">
                                        <div class="radial-progress text-primary text-xs"
                                             style="--value:@(post.Score * 100); --size:3rem; --thickness:3px;"
                                             role="progressbar">
                                            @((post.Score * 100).ToString("F0"))%
                                        </div>
                                        <span class="text-xs opacity-60 mt-1">similarity</span>
                                    </div>
                                </div>
                            </div>
                        </div>
                    }
                </div>
            </div>
        </div>
    </div>
}

Le progrès radial montre la similitude en pourcentage (0-100%), aide les utilisateurs à comprendre le lien entre chaque poste

Référence API

Recherche de typeahead

GET /api/search/{query}
Paramètre Type MSC2 Description MSC3
query chaîne ( chemin d'accès) terme de recherche MSC4min MSC5 caractères

Réponse: List<SearchResults>

[
  {
    "title": "Docker Containers Explained",
    "slug": "docker-containers",
    "url": "https://example.com/blog/docker-containers"
  }
]

Caching: 1 heureM SK1 varie selon la demande

Recherche sémantique

GET /search/semantic?query={query}&limit={limit}
Paramètre Type MSC2 Défault MSC3 Description SMC4
query chaîne requis terme de recherche S
limit Résultats maximums

Réponse: List<SearchResult> avec des notes de similitude

Postes connexes

GET /search/related/{slug}/{language}?limit={limit}
Paramètre Type MSC2 Défault MSC3 Description SMC4
slug string requis Blog post slug S
language chaîne requis code linguistique en, esMSC5 etc.
limit int

Réponse: List<SearchResult> classés par similitude

Caching: 2 heuresM SK1 varie selon la langue et l’échantillonnage

Recherche complète avec les filtres

GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}
Paramètre Type MSC2 Défault MSC3 Description SMC4
query chaîne requis terme de recherche S
page int MSC1 MSC2 3 Numéro de la page SMC4
pageSize Résultats par page
language chaîne nulle S Filtre par langue M
dateRange enum
startDate DateTime nulle MSC2 Date de début MSC3avec dateRange MSc4Custom MScs5 SMcs6
endDate DateTime nulle MSC2 Date de clôture MSC3avec dateRange MSc4Custom MScs5 SMcs6

Conseils sur la performance

Stratégie de stockage

// Typeahead - 1 hour (queries are repeated often)
[OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query" })]

// Related posts - 2 hours (rarely change)
[OutputCache(Duration = 7200, VaryByRouteValueNames = new[] {"slug", "language"})]

// Full search - 1 hour (many filter combinations)
[OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query", "page", "pageSize", "language", "dateRange", "startDate", "endDate" })]

Débouchement

Always debounce user input to prevent excessive API calls:

x-on:input.debounce.300ms="search"

300ms est un bon équilibre - assez rapide pour se sentir réceptif

Chargement lent

Utiliser HTMX's load delay: déclencheur pour les contenus non -critiques:

hx-trigger="load delay:500ms"

Cela garantit que le contenu principal est visible avant la charge du contenu secondaire.

Quoi' Suivant

Cet article a porté sur les expérience de recherche - comment les utilisateurs interagissent avec la recherche sémantique Déploiement de production incluant l’indexation automatique et le service de référence, poursuivre àM SK1

Pièce 5: Recherche hybride & AutoM SK2Indexage - Modèles d'intégration de la productionM SK1

  • FileSystemWatcher pour l'indexation du temps réel-
  • Service de base pour l'indexation du démarrage
  • Détection du hash de contenu pour les mises à jour incrémentales

Ressources

Articles connexes

Technologies utilisées

Code complet

Tous les codes disponibles à: github.comM SK1scottgal/mostlylucidweb

  • Mostlylucid/API/SearchApi.cs - Typeahead API
  • Mostlylucid/Controllers/SearchController.cs - Page de recherche complète
  • Mostlylucid.Services/Blog/BlogSearchService.cs - Logique de recherche hybride
  • Mostlylucid/src/js/typeahead.js - AlpineM SK1js composant
Finding related posts...
logo

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