RAG für Implementer: :-Semantische Suche in Aktion
Einleitung
Teil der RAG-Serie Dies ist Teil der Suchfunktionen von 4-b und UI-:-
- RAG-Ursprünge und Grundlagen Was Einbettungen sind, warum sie wichtig sind
- RAG-Architektur und Innenarchitektur Datenverarbeitungsdatenbanken
- RAG in der Praxis Gebäude komplette RAG-Systeme
- Teil Umsetzung des Qdrant-Systems Bequeme semantische Suchstiftung
- Teil - Semantische Suche in Aktion Dieser Artikel ist mit den Komponenten , und UI zu versehen.
- Teil-Hybridsuche Produktionsintegrationsmuster
- Teil 6:-GraphRAG Wissensgraphen für das Verständnis auf corpus-Ebene
Im TeilWir bauten die Stiftung, ONNX-Embeddings und Qdrant-Vektorspeicher. Echte Suche UI -, einschließlich autokompletter Typahead-Hybridsuche zur Kombination von semantischer Semantik, vollwertigem -, Text, , und fortgeschrittenem Filtern
Dieser Artikel deckt die tatsächliche Suche Erfahrung Benutzer interagieren mit auf diesem Blog .
Die Sucherfahrung des Typeahead
Das Suchfeld an der Spitze dieser Website bietet sofortige Suche.-.as.-.syou.-. Type results...s hier.'.s wie es funktioniert.
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
Der Typ der Alpine-.-Js-Baugruppe
Das Suchfeld verwendet Alpen-.-js für reaktive Benutzeroberfläche ohne schwere JavaScript-Frameworks...... Hier ist die Komponente....
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 = '';
}
}
}
Wesentliche Merkmale
- Entblößte EingabeVerzögerung verhindert Hämmern des Servers
- MindestlängeBenötigte Zeichen
- TastaturnavigationPfeiltasten für die Zugänglichkeit eingeben
- HTMX-IntegrationErgebnisse verwenden HTMX für eine reibungslose Navigation
Das Suchfeld 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>
Warum? x-on:click.outside? Klicken außerhalb der Dropdown schließt es Standard UX-Muster für Autocomplete
Die Such-API
Das /api/search/{query} Der Endpunkt treibt den Typahead an... Hier ist der 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");
}
}
}
Wichtige Design-Entscheidungen
- Merkmalskennzeichen:
semanticSearchConfig.Enabledkönnen Sie semantische Suche umschalten - Ausgabe-CachingDer Cache des 1-hour reduziert die Serverlast für häufige Abfragen
- AnalyseverfolgungJede Suche wird verfolgt hilft Benutzerverhalten zu verstehen
- Anmutige ErniedrigungFallt zurück zu PostgreSQL, wenn semantische Suche fehlschlägt
Hybride Suche mit reziproker Rang Fusion
Die wahre Magie ist hybride Suche Wir verwenden reziproke Rank Fusion, um sie fair zu verschmelzen.
Warum hybride Suche?
Unterschiedliche Suchansätze haben unterschiedliche Stärken
Suchtyp Stärke Stärke Schwäche |------------|-----------|------------| | Semantisch Synonyme Bezeichnungen ,, was bedeutet: Konzepte | Kann genaue Phrasen vermissen | Vollständiger Text Nicht synonym verstehend
Beispiel: Auf der Suche nach einem Container-Deployment
- Semantische Funde:-Docker Tutorials",-Gubernetes-Führer" - verwandte Konzepte)-Docken
- Vollständiger --Text findet ,: , Beiträge mit genau ," ,Container-Deployment , " ,
- Hybrid bekommt das Beste von beidem
Der RRF-Algorithmus
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
Die Formel „:" score = Σ(1 / (k + rank))
Worum es geht
k = 60Um zu verhindern, dass frühe Ränge dominierenrankPosition in dieser Suchmethode Ergebnisse von '.
Warum RRF funktioniert
- Ergebnisse in beides Quellen Score höher
- Keine einzige Quelle kann dominieren
- Kein komplexes Tuning erforderlich
Durchführung
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();
}
Wichtige Durchführungsdetails
- Parallele AusführungBeide Suchanfragen laufen gleichzeitig.
Task.WhenAll) - Fall: Nicht empfindliches DedupSlugs normalisiert mit
ToLowerInvariant() - WerteakkumulationGleicher Beitrag in beiden Quellen erhält Punkte hinzugefügt
- Top ErgebnisseGenug für Typahead, ,, nicht überwältigend
Volltext Suche Zurück
Wenn semantische Suche deaktiviert ist oder fehlschlägt, fallen wir zurück zu PostgreSQL full--.
Abfragebearbeitung
Die Volltextsuche behandelt zwei unterschiedliche Fälle
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
}
Einheitliches Wort Verwendet Platzhalter-Präfixsuche docker:*
Mehrere Wörter ("-Docker-Container verwendet PostgreSQL-'-Websuchsyntax
PostgreSQL-Abfragen
// 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)));
}
Warum? WebSearchToTsQuery? Es behandelt natürliche Sprachabfragen wie Google .: .
"docker containers"Suche nach beiden Wörterndocker OR kubernetesBESCHÄFTIGUNG UND UMWELTdocker -composeNicht enthalten
Für weitere Informationen über PostgreSQL Full---Textsuche lesen Sie bitte Volltextsuche mit Postgres.
Die vollständige Suche
Jenseits von typeahead-,'s eine vollständige Suchresultate Seite mit erweitertem Filtern
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
Suchcontroller
[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);
}
}
Datumsbereich-Optionen
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
};
}
Verwandte Beiträge mit Lazy Laden
Jeder Blog-Post zeigt semantisch ähnliche Beiträge in einem zusammenklappbaren Panel, das HTMX für faules Laden verwendet.
<!-- 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>
Warum Verzögern Sie 500-ms-?- Der Hauptinhalt lädt zuerst ,, , dann verwandte Pfosten laden im Hintergrund , . , Benutzer sehen Inhalt sofort
Related Posts Endpoint
[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);
}
Zwischenspeicher der 2--StundeVerknüpfte Beiträge don-'nicht häufig ändern , so aggressives Caching ist sicher , .
Die Komponente Related Posts
Eine DaisyUI-Komponente mit radialem Fortschritt, der Ähnlichkeitsergebnisse zeigt
@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>
}
Der radiale Fortschritt zeigt Ähnlichkeit wie ein Prozentsatz an, der Benutzern hilft zu verstehen, wie verwandt jeder Beitrag ist.
API-Referenz
Art der Suche
GET /api/search/{query}
| Beschreibung des Parameters | , Typ | , |
|---|---|---|
query Zeichenkette (Ausgangszeichen)Auszugszeichen |
Austrittswörter (Ausstiegszeichen 2Aushangszeichen |
Ansprechrate List<SearchResults>
[
{
"title": "Docker Containers Explained",
"slug": "docker-containers",
"url": "https://example.com/blog/docker-containers"
}
]
Caching-: 1, Stunden,, variiert nach Abfragen
Semantische Suche
GET /search/semantic?query={query}&limit={limit}
Parameter Typ Typ Typ Standard Beschreibung Beschreibung
|-----------|------|---------|-------------|
| query Bitte geben Sie die Zeichenfolge an, in der die Zeichenkette angegeben ist.
| limit Maximale Ergebnisse
Ansprechrate List<SearchResult> mit Ähnlichkeitswerten
Verwandte Beiträge
GET /search/related/{slug}/{language}?limit={limit}
Parameter Typ Typ Typ Standard Beschreibung Beschreibung
|-----------|------|---------|-------------|
| slug Benötigte Zeichenfolge Bewünschte Zeichenkette Befragte Zeichenkette
| language Bitte geben Sie an, ob dies der Fall ist.
| limit Max verwandte Beiträge
Ansprechrate List<SearchResult> nach Ähnlichkeit sortiert
Caching-: 2, Stunden,, variiert je nach Schnecke und Sprache
Volle Suche mit Filtern
GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}
Parameter Typ Typ Typ Standard Beschreibung Beschreibung
|-----------|------|---------|-------------|
| query Bitte geben Sie die Zeichenfolge an, in der die Zeichenkette angegeben ist.
| page Anzahl der Seiten
| pageSize Ergebnisse pro Seite
| language Filtern nach Sprache
| dateRange Letzte Woche, Letzte Monat, Letztes Jahr,Benutzerdefinierte
| startDate DatumZeitpunkt Zeitpunkt null Beginndatum Zeitpunkt mit DatumBereich =Custom-)
| endDate DatumZeitpunkt Zeitpunkt null Enddatum Zeitpunkt mit Datumsbereich=Custom-)
Leistungstipps
Caching-Strategie
// 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" })]
Entschuldung
Immer debounce Benutzereingabe, um übermäßige API-Aufrufe zu verhindern
x-on:input.debounce.300ms="search"
300-ms ist eine gute Balance schnell genug, um sich responsiv zu fühlen, langsam genug, Serverlast zu reduzieren
Faules Laden
Verwendung von HTMX-' load delay: Trigger für nicht-kritische Inhalte
hx-trigger="load delay:500ms"
Dies stellt sicher, dass der Hauptinhalt vor sekundären Inhalt Lasten sichtbar ist.
Was kommt als nächstes?
Dieser Artikel behandelte die Sucherfahrung Wie Benutzer mit semantischer Suche interagieren Produktionseinsatz inklusive automatischer Indexierung und dem Hintergrunddienst ,, , weiter zu ,: ,
Teil-Hybridsuche Produktionsintegrationsmuster
- FileSystemWatcher für die Echtzeit-Indizierung
- Hintergrundservice für Start-Indizierung
- Hash-Erkennung von Inhalten für inkrementelle Updates
Aufkommen
Verwandte Artikel
- Teil Umsetzung des Qdrant-Systems Die Grundlage dieses Artikels baut auf
- Volltextsuche mit PostgreSQL PostgreSQL Volltextsuche
- Volltextsuche - Der Typahead Ursprüngliche Typahead-Implementierung
Verwendete Technologien
- Alpen-.-js Leichte reaktive JavaScript
- HTMX HTML über den Draht
- DaisyUI Bibliothek der ZugwindCSS-Komponenten
- Gegenseitigkeitsrang Fusion Das RRF-Algorithmuspapier
Vollständiger Code
Alle verfügbaren Codes unter: github-.-com./-scottgal./-mostlylucidweb
Mostlylucid/API/SearchApi.csTypeahead APIMostlylucid/Controllers/SearchController.csVollständige SuchseiteMostlylucid.Services/Blog/BlogSearchService.csHybride SuchlogikMostlylucid/src/js/typeahead.jsKomponente „Alpine-."