RAG para Implementadores: Búsqueda Semántica en Acción (Español (Spanish))

RAG para Implementadores: Búsqueda Semántica en Acción

Wednesday, 24 December 2025

//

17 minute read

Introducción

Parte de la serie RAG: Esta es la Parte 4b - características de búsqueda y interfaz de usuario:

In Parte 4a, construimos la base: Incrustaciones ONNX y almacenamiento de vectores Qdrant. Ahora vamos a ponerlo a trabajar con un búsqueda real IU - incluyendo autocompletado tipoahead, búsqueda híbrida que combina semántica + texto completo, y filtrado avanzado.

Este artículo cubre la experiencia de búsqueda real con los usuarios interactúan en este blog.

La experiencia de búsqueda tipoahead

El cuadro de búsqueda en la parte superior de este sitio proporciona resultados instantáneos de búsqueda como usted. Así es como funciona:

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

El componente Alpine.js Tipoahead

El cuadro de búsqueda utiliza Alpine.js para la interfaz de usuario reactiva sin marcos de JavaScript pesados. Aquí está el 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 = '';
        }
    }
}

Características principales:

  1. Entrada debatida: 300 ms de retraso evita martillar el servidor
  2. Longitud mínima: Se requieren al menos 2 caracteres
  3. Navegación del teclado: Teclas de flecha + Entrar para accesibilidad
  4. Integración HTMX: Los resultados utilizan HTMX para una navegación fluida

El código HTML de la caja de búsqueda

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

¿Por qué? x-on:click.outside? Al hacer clic fuera del menú desplegable se cierra - patrón UX estándar para autocompletado.

La API de búsqueda

Los /api/search/{query} Endpoint activa el tipo de avance. Aquí está el controlador:

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

Importantes decisiones de diseño:

  1. Bandera de características: semanticSearchConfig.Enabled le permite cambiar la búsqueda semántica
  2. Caché de salida: 1 hora de caché reduce la carga del servidor para consultas comunes
  3. Seguimiento analítico: Cada búsqueda es rastreada (ayuda a entender el comportamiento del usuario)
  4. Degradación agraciada: Vuelve a PostgreSQL si la búsqueda semántica falla

Búsqueda híbrida con fusión de rango recíproco

La verdadera magia es búsqueda híbrida - combinando resultados semánticos y de texto completo. Utilizamos Reciprocal Rank Fusion (RRF) para fusionarlos de forma justa.

¿Por qué búsqueda híbrida?

Diferentes enfoques de búsqueda tienen diferentes fortalezas:

Tipo de búsqueda Fortalezas Debilidades |------------|-----------|------------| | Semántico Sinónimos, significado, conceptos Puede faltar a frases exactas | Texto completo Palabras clave exactas, términos técnicos No hay comprensión de sinónimos

Ejemplo Buscando "desplego de contenedores"

  • Semántico encuentra: "Tutoriales Docker", "Guías Kubernetes" (conceptos relacionados)
  • Hallazgos de texto completo: Mensajes que contienen exactamente "desplego de contenedores"
  • ¡Híbrido consigue lo mejor de ambos!

El algoritmo de la RFR

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 fórmula: score = Σ(1 / (k + rank))

Donde:

  • k = 60 (constante para evitar que las primeras filas dominen)
  • rank = posición en los resultados de ese método de búsqueda (1-indexado)

Por qué RRF funciona:

  • Resultados que aparecen en ambos mayor puntuación de fuentes
  • Ninguna fuente puede dominar
  • No se requiere una afinación compleja

Aplicación

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

Principales detalles de la aplicación:

  1. Ejecución paralela: Ambas búsquedas se ejecutan simultáneamente (Task.WhenAll)
  2. Dedup no sensible a las causas: Babos normalizados con ToLowerInvariant()
  3. Acumulación de puntuaciones: Mismo post en ambas fuentes obtiene puntajes añadidos
  4. Principales 15 resultados: Suficiente para mecanografiar, no abrumador

Búsqueda de texto completo

Cuando la búsqueda semántica está deshabilitada o falla, volvemos a la búsqueda de texto completo de PostgreSQL.

Tratamiento de las consultas

La búsqueda de texto completo maneja dos casos de manera diferente:

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
}

Una sola palabra ("docker": Utiliza la búsqueda de prefijo comodín docker:* Múltiples palabras ("contenedores de contenedores": Utiliza la sintaxis de búsqueda web de PostgreSQL

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

¿Por qué? WebSearchToTsQuery? Maneja consultas de lenguaje natural como Google:

  • "docker containers" → búsquedas de ambas palabras
  • docker OR kubernetes → Booleano OR
  • docker -compose → excluye "componer"

Para obtener más información sobre la búsqueda de texto completo de PostgreSQL, consulte Búsqueda de texto completo con Postgres.

La página de búsqueda completa

Más allá del mecanografiado, hay una página completa de resultados de búsqueda con filtrado avanzado:

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

Controlador de búsqueda

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

Opciones de intervalo de fechas

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

Publicaciones relacionadas con Lazy Cargando

Cada entrada de blog muestra publicaciones semánticamente similares en un panel colapsable. Esto utiliza HTMX para la carga perezosa:

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

¿Por qué retrasar 500ms? El contenido principal se carga primero, luego los posts relacionados se cargan en el fondo. Los usuarios ven el contenido inmediatamente.

Puestos relacionados Punto 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);
}

Caché de 2 horas: Los posts relacionados no cambian a menudo, así que el caché agresivo es seguro.

Componente de puestos conexos

Un componente de colapso DaisyUI con progreso radial que muestra puntuaciones de similitud:

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

El progreso radial muestra similitud como porcentaje (0-100%), ayudando a los usuarios a entender lo relacionado que está cada post.

API Reference

Escribir una búsqueda por delante

GET /api/search/{query}

Parámetro # # Tipo # # Descripción # # Parámetro # # Tipo # # Descripción

|-----------|------|-------------| | query cadena (ruta) Término de búsqueda (mínimo 2 caracteres)

Respuesta: List<SearchResults>

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

Caché: 1 hora, varía según la consulta

Búsqueda semántica

GET /search/semantic?query={query}&limit={limit}

Parámetro Tipo Predeterminado Descripción |-----------|------|---------|-------------| | query cadena requerido Término de búsqueda | limit int 10 Max resultados

Respuesta: List<SearchResult> con puntuaciones de similitud

Puestos relacionados

GET /search/related/{slug}/{language}?limit={limit}

Parámetro Tipo Predeterminado Descripción |-----------|------|---------|-------------| | slug # Se requiere una cadena # # Mensaje de blog # # babosa # | language cadena requerido Código de idioma (en, es, etc.) | limit int 5 Max puestos relacionados

Respuesta: List<SearchResult> ordenados por similitud

Caché: 2 horas, varía según la babosa y el idioma

Búsqueda completa con filtros

GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}

Parámetro Tipo Predeterminado Descripción |-----------|------|---------|-------------| | query cadena requerido Término de búsqueda | page int 1 Número de página | pageSize int 10 Resultados por página | language cadena null Filtrar por idioma | dateRange Todos los Tiempos, Última Semana, ÚltimoMes, ÚltimoAño, Custom | startDate DateTime null Fecha de inicio (con dateRange=Custom) | endDate DateTime null Fecha final (con dateRange=Custom)

Consejos de rendimiento

Estrategia de caché

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

Debouncing

Siempre desbone la entrada del usuario para evitar llamadas excesivas de API:

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

300ms es un buen equilibrio - lo suficientemente rápido para sentirse sensible, lo suficientemente lento para reducir la carga del servidor.

Loading perezoso

Usar HTMX's load delay: activador de contenido no crítico:

hx-trigger="load delay:500ms"

Esto garantiza que el contenido principal sea visible antes de las cargas de contenido secundario.

¿Qué sigue?

Este artículo se refiere a: experiencia de búsqueda - cómo interactúan los usuarios con la búsqueda semántica. despliegue de la producción incluyendo la indexación automática y el servicio de fondo, continuar:

Parte 5: Búsqueda híbrida y auto-indexación - Modalidades de integración de la producción:

  • FileSystemWatter para la indexación en tiempo real
  • Servicio de fondo para la indexación de inicio
  • Detección de hash de contenido para actualizaciones incrementales

Recursos

Artículos relacionados

Tecnologías utilizadas

Código completo

Todo el código disponible en: github.com/scottgal/mostlylucidweb

  • Mostlylucid/API/SearchApi.cs - Tipo de API
  • Mostlylucid/Controllers/SearchController.cs - Página completa de búsqueda
  • Mostlylucid.Services/Blog/BlogSearchService.cs - Lógica de búsqueda híbrida
  • Mostlylucid/src/js/typeahead.js - Componente Alpine.js
Finding related posts...
logo

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