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
RAG for Implementers: Recherche sémantique en action
Tuesday, 25 November 2025
Introduction
📖 Partie de la série RAG Il s'agit de la partie 4b - des fonctions de recherche et de l'interface
- Partie 1: Origines et fondements du RAG - Quels sont les emplacements, pourquoi ils sont importants
- Partie 2: Architecture et éléments internes du RAG Bases de données vectorielles
- Partie 3: RAG dans la pratique - Établissement de systèmes RAG complets
- Partie 4a: ONNX & Mise en œuvre de la garantie - CPU- fondation de recherche sémantique conviviale
- Partie 4b: Recherche sémantique en action (le présent articleM SK1 - TypeaheadMSC3 recherche hybride, et composants de l'interface
- Pièce 5: Recherche hybride & AutoM SK2Indexage - Modèles d'intégration de la production
- Partie 6: GraphRAG - Graphiques de connaissances pour le corpus
À 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:
- Input débloqué: 300ms de retard permet d'empêcher le marquage du serveur
- Durée minimale: Il faut au moins 2 caractères
- Navigation de la clavier: Pièces d'arrondissement + Inscrire pour l'accessibilité
- 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:
- Fabrication d'un drapeau:
semanticSearchConfig.Enabledpermet de régler la recherche sémantique - Cache de sortie: 1-heure de cache réduit la charge du serveur pour les demandes courantes
- Surveillance de l'analyse: Chaque recherche est suivie.
- 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 dominerrank= 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:
- Exécution parallèle: Les deux recherches se déroulent simultanément (
Task.WhenAll) - Cas-Déduction insensible: Les boues normalisées avec
ToLowerInvariant() - Accumulation des notes: Le même post dans les deux sources reçoit des points additionnels
- 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 motsdocker OR kubernetes→ Boolean ORdocker -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
- Partie 4a: ONNX & Mise en œuvre de la garantie - Le fondement de cet article repose sur
- Recherche de texte complet avec PostgreSQL - PostgreSQL complet-configuration de la recherche texte
- Recherche de texte complet - Typeahead - Mise en œuvre initiale de typeahead
Technologies utilisées
- Alpine.js - Lightweight reactive JavaScript
- HTMX - HTML sur le câble
- DaisyUI - TailwindCSS libraire de composants
- Fusion de rang réciproque - Document de l'algorithme RRF
Code complet
Tous les codes disponibles à: github.comM SK1scottgal/mostlylucidweb
Mostlylucid/API/SearchApi.cs- Typeahead APIMostlylucid/Controllers/SearchController.cs- Page de recherche complèteMostlylucid.Services/Blog/BlogSearchService.cs- Logique de recherche hybrideMostlylucid/src/js/typeahead.js- AlpineM SK1js composant