RAG para Implementadores: Búsqueda Híbrida e Indización Automática (Español (Spanish))

RAG para Implementadores: Búsqueda Híbrida e Indización Automática

Saturday, 22 November 2025

//

9 minute read

Introducción

Parte de la serie RAG: Esta es la quinta parte - pautas de integración de la producción:

In Parte 4a, construimos la base: Incrustaciones ONNX y almacenamiento Qdrant. En Parte 4b, cubrimos la búsqueda de la interfaz de usuario y la implementación de búsqueda híbrida. Ahora vamos a hacer que la producción-listo con indexación automática (actualizaciones de contenido de cero toques) a través de FileSystemWatcher.

Búsqueda híbrida: Lo mejor de ambos mundos

La búsqueda semántica es poderosa, pero la búsqueda tradicional de texto completo sigue sobresaliendo en frases exactas y términos técnicos. ¿La solución? Usa ambos.

¿Por qué Hybrid? Los diferentes enfoques tienen diferentes puntos fuertes:

  • Texto completo de PostgreSQL (cubierto aquí): Coincidencias exactas, términos técnicos, operadores booleanos
  • Búsqueda de vectores semánticos: Significado, contexto, sinónimos, contenido conceptualmente relacionado

Fusión Recíproca de Rango (RFR)

Usamos Fusión de rango recíproco para combinar resultados de múltiples fuentes de búsqueda:

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

La fórmula RRF: score = Σ(1 / (k + rank))

  • k = 60 (constante para evitar que las primeras filas dominen)
  • rank = posición en los resultados de ese método de búsqueda
  • Resultados en ambos fuentes obtienen puntuaciones de cada sumada

Por qué RRF funciona:

  • Deduplicación: El mismo resultado en ambas fuentes puntúa más alto
  • Equidad: Ningún método de búsqueda domina injustamente
  • Simplicidad: No se requiere afinación compleja

Aplicación

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

Nota: Esto muestra sólo búsqueda semántica. En la producción, ejecutar PostgreSQL búsqueda de texto completo en paralelo e incluir esos resultados en el cálculo de RFF.

Integración

Si ya ha implementado la búsqueda de texto completo de PostgreSQL (como se cubre aquí), añadir búsqueda semántica es sencillo:

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

Indización automática con el monitor del sistema de archivos

La característica más poderosa: indexación automática. Guardar una entrada de blog, es inmediatamente buscable - sin intervención manual.

Cómo funciona

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

Decisión de diseño clave: Sólo los archivos de índice en el directorio Markdown principal, no subdirectorios (translated/, drafts/, comments/). Esto mantiene el índice de búsqueda limpio.

Integración del visor de archivos

El blog ya tiene un MarkdownDirectoryWatcherService. Lo ampliamos para activar la indexación semántica:

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

Manejo de las supresiones

Cuando se elimina un post, retírela del índice semántico:

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

Servicio de Antecedentes para la Indización Inicial

En el inicio, un servicio de fondo indexa los puestos existentes que aún no se encuentran en 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);
        }
    }
}

Esto garantiza:

  1. Carga de modelos perezosos - Descargas en el primer uso, sin bloquear el inicio
  2. Indización incremental - Sólo los puestos nuevos/cambiados re-indexados (a través del contenido hash)
  3. Sólo directorio principal - Los borradores y los archivos traducidos no contaminan el índice

Lo que hemos construido

A través de las partes 4a, 4b y 5, ahora tenemos:

  • ✅ Búsqueda semántica amigable con la CPU - No se requiere GPU
  • ✅ Descubrimiento de publicaciones relacionadas - Contenido semánticamente similar
  • ✅ Búsqueda del lenguaje natural - Encontrar por significado, no sólo palabras clave
  • ✅ Búsqueda híbrida - Lo mejor de la semántica + texto completo
  • ✅ Indización automática - Actualizaciones de contenido de Zero-touch
  • ✅ Self-hosted - Sus datos permanecen en su servidor

Mejoras futuras:

  • Búsqueda con conocimiento de categoría - Impulsar los resultados de categorías específicas
  • Incrustaciones multilingües - Modelos de incrustación específicos del lenguaje
  • Integración de OpenSearch - Añadir OpenSearch a la mezcla híbrida (ver mi artículo de OpenSearch)

Conclusión

Esto completa la implementación práctica de la búsqueda semántica al estilo RAG. Parte 4a (fundación) y Parte 4b (búsqueda de interfaz de usuario), tiene todo lo necesario para añadir búsqueda inteligente a su aplicación .NET - que se ejecuta por completo en la CPU, con un costo adicional cero.

Continuar aprendiendo

Recursos

Bases de datos de Qdrant y Vectores

Búsqueda híbrida

Control del sistema de archivos

Código completo

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

  • Mostlylucid.SemanticSearch/ - Biblioteca de búsqueda semántica básica
  • Mostlylucid/Blog/WatcherService/ - Visor de archivos con indexación semántica
Finding related posts...
logo

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