Back to "RAG per gli implementatori: ricerca semantica in azione"

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 Alpine.js ASP.NET HTMX Hybrid Search RAG Semantic Search

RAG per gli implementatori: ricerca semantica in azione

Wednesday, 24 December 2025

Introduzione

Parte della serie RAG: Questa è la parte 4b - funzioni di ricerca e UI:

Dentro Parte 4a, abbiamo costruito la fondazione: ONNX embeddings e Qdrant vettorial storage. Ora mettiamolo a lavorare con un UI di ricerca reale - compreso il completamento automatico del tipoahead, la ricerca ibrida che combina semantico + full-text, e il filtraggio avanzato.

Questo articolo copre l'esperienza di ricerca reale gli utenti interagiscono con su questo blog.

L'esperienza di ricerca Typeahead

La casella di ricerca nella parte superiore di questo sito fornisce risultati di ricerca istantanea-come-you-type. Ecco come funziona:

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 componente "Alpine.js"

La casella di ricerca utilizza Alpine.js per interfaccia utente reattiva senza pesanti framework JavaScript. Ecco il componente:

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 = '';
        }
    }
}

Caratteristiche principali:

  1. Input debounced: 300ms di ritardo impedisce di martellare il server
  2. Lunghezza minima: Sono richiesti almeno 2 caratteri
  3. Navigazione tastiera: Tasti freccia + Invio per l'accessibilità
  4. Integrazione HTMX: I risultati utilizzano HTMX per una navigazione fluida

La casella di ricerca 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>

Perché? x-on:click.outside? Cliccando fuori dal menu a discesa si chiude - modello standard UX per il completamento automatico.

L'API di ricerca

La /api/search/{query} L'endpoint alimenta il tipoahead. Ecco il controller:

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

Importanti decisioni di progettazione:

  1. Funzionalità: semanticSearchConfig.Enabled consente di attivare la ricerca semantica
  2. Caching di output: 1 ora di cache riduce il carico del server per le query comuni
  3. Tracciamento analytics: Ogni ricerca è tracciata (aiuta a capire il comportamento dell'utente)
  4. Degradazione graziosa: Cade di nuovo a PostgreSQL se la ricerca semantica non riesce

Ricerca ibrida con fusione di Rank reciproco

La vera magia è ricerca ibrida - combinando risultati semantici e full-text. Usiamo la Reciproca Rank Fusion (RRF) per fonderli equamente.

Perché la ricerca ibrida?

Diversi approcci di ricerca hanno diversi punti di forza:

Tipo di ricerca Punti di forza Debolezze
Semantico Sinonimi, significato, concetti Può mancare frasi esatte
Testo integrale Parole chiave esatte, termini tecnici Nessuna comprensione sinonimo

Esempio: Ricerca di "dispiegamento di container"

  • Reperti semantici: "Docker tutorial," "Kubernetes guides" (concetti correlati)
  • Reperti full-text: Post contenenti esattamente "dispiegamento di container"
  • Hybrid ottiene il meglio di entrambi!

Algoritmo 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 formula: score = Σ(1 / (k + rank))

dove:

  • k = 60 (costante per evitare la dominazione dei ranghi primitivi)
  • rank = posizione nei risultati del metodo di ricerca (1-indexed)

Perché RRF funziona:

  • Risultati che appaiono in entrambi sources score higher
  • Nessuna sorgente può dominare
  • Non è richiesta alcuna messa a punto complessa

Attuazione

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

Informazioni chiave sull'attuazione:

  1. Esecuzione parallela: Entrambe le ricerche vengono eseguite simultaneamente (Task.WhenAll)
  2. Dedup insensibile al caso: Slug normalizzati con ToLowerInvariant()
  3. Accumulo punteggio: Stesso post in entrambe le fonti ottiene punteggi aggiunti
  4. I primi 15 risultati: Abbastanza per il tipoahead, non schiacciante

Full-Text Search Fallback

Quando la ricerca semantica è disabilitata o fallisce, rimandiamo alla ricerca postgreSQL full-text.

Gestione delle interrogazioni

La ricerca full-text gestisce due casi in modo diverso:

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
}

Parola sola ("docker"): Usa la ricerca del prefisso jolly docker:* Parole multiple ("contenitori docker"): Usa la sintassi di ricerca web di PostgreSQL

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

Perché? WebSearchToTsQuery? Gestisce domande di lingua naturale come Google:

  • "docker containers" → cerca entrambe le parole
  • docker OR kubernetes → Booleano O
  • docker -compose → esclude "componi"

Per ulteriori informazioni sulla ricerca full-text di PostgreSQL, vedere Ricerca di testo completo con Postgres.

La pagina di ricerca completa

Al di là del tipoahead, c'è una pagina completa dei risultati di ricerca con filtraggio avanzato:

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

SearchController

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

Opzioni dell'intervallo di date

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

Related Posts with Lazy Loading

Ogni post del blog mostra semanticamente messaggi simili in un pannello pieghevole. Questo utilizza HTMX per il caricamento pigro:

<!-- 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>

Perche' ritardare i 500m? Il contenuto principale carica prima, poi i post correlati caricano in background. Gli utenti vedono il contenuto immediatamente.

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

Cache di 2 ore: Post correlati non cambiano spesso, quindi la cache aggressiva è sicura.

Il componente dei post correlati

Un componente di collasso DaisyUI con progresso radiale che mostra punteggi di somiglianza:

@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>
}

Il progresso radiale mostra la somiglianza come percentuale (0-100%), aiutando gli utenti a capire quanto sia correlato ogni post.

Riferimento API

GET /api/search/{query}
Parametro Tipo Descrizione
query stringa (percorso) Termine di ricerca (min 2 caratteri)

Risposta: List<SearchResults>

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

Caching: 1 ora, varia a seconda della query

Ricerca semantica

GET /search/semantic?query={query}&limit={limit}
Parametro Tipo Predefinito Descrizione
query string required Search term
limit int 10 Max results

Risposta: List<SearchResult> con punteggi di somiglianza

Post correlati

GET /search/related/{slug}/{language}?limit={limit}
Parametro Tipo Predefinito Descrizione
slug String required Blog post slug
language string required Language code (en, es, ecc.)
limit int 5 Max correlated posts

Risposta: List<SearchResult> ordinati per somiglianza

Caching: 2 ore, varia per lumaca e lingua

Ricerca completa con filtri

GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}
Parametro Tipo Predefinito Descrizione
query string required Search term
page int 1 Page number
pageSize int 10 Results for page
language stringa null Filtro per lingua
dateRange Enum AllTime AllTime, LastWeek, LastWeek, LastYear, Custom
startDate DateTime null Start date (with dateRange=Custom)
endDate DateTime null End date (with dateRange=Custom)

Suggerimenti per le prestazioni

Caching Strategy

// 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" })]

Rifiuto

Debounce sempre l'ingresso dell'utente per evitare chiamate API eccessive:

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

300ms è un buon equilibrio - abbastanza veloce da sentirsi reattivo, abbastanza lento da ridurre il carico del server.

Caricamento pigro

Usa la versione di HTMX load delay: attivazione di contenuti non critici:

hx-trigger="load delay:500ms"

Questo assicura che il contenuto principale sia visibile prima che il contenuto secondario venga caricato.

Cosa c'e' dopo?

Questo articolo ha riguardato: esperienza di ricerca - come gli utenti interagiscono con la ricerca semantica. distribuzione della produzione compreso l'indicizzazione automatica e il servizio di background, continuare a:

Parte 5: Ricerca ibrida e integrazione automatica - Modelli di integrazione della produzione:

  • FileSystemWatcher per l'indicizzazione in tempo reale
  • Servizio di background per l'indicizzazione delle startup
  • Rilevamento hash dei contenuti per aggiornamenti incrementali

Risorse

Articoli correlati

Tecnologie utilizzate

Codice completo

Tutti i codici disponibili al seguente indirizzo: github.com/scottgal/mostlylucidweb

  • Mostlylucid/API/SearchApi.cs - API Typeahead
  • Mostlylucid/Controllers/SearchController.cs - Pagina di ricerca completa
  • Mostlylucid.Services/Blog/BlogSearchService.cs - Logica di ricerca ibrida
  • Mostlylucid/src/js/typeahead.js - componente Alpine.js
logo

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