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.
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 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:
<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.
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:
semanticSearchConfig.Enabled le permite cambiar la búsqueda semánticaLa 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.
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"
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:
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:
Task.WhenAll)ToLowerInvariant()Cuando la búsqueda semántica está deshabilitada o falla, volvemos a la búsqueda de texto completo de PostgreSQL.
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
// 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 palabrasdocker OR kubernetes → Booleano ORdocker -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.
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
[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);
}
}
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
};
}
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.
[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.
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.
GET /api/search/{query}
|-----------|------|-------------|
| 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
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
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
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)
// 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" })]
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.
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.
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:
Todo el código disponible en: github.com/scottgal/mostlylucidweb
Mostlylucid/API/SearchApi.cs - Tipo de APIMostlylucid/Controllers/SearchController.cs - Página completa de búsquedaMostlylucid.Services/Blog/BlogSearchService.cs - Lógica de búsqueda híbridaMostlylucid/src/js/typeahead.js - Componente Alpine.js© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.