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
Wednesday, 24 December 2025
Μέρος της σειράς RAG: Αυτό είναι το Μέρος 4β - χαρακτηριστικά αναζήτησης και UI:
Το Μέρος 4α, φτιάξαμε τα θεμέλια: ONNX embeddings και Qdrant διανυσματική αποθήκευση. Τώρα ας το θέσουμε σε λειτουργία με ένα πραγματική αναζήτηση UI - συμπεριλαμβανομένης της αυτόματης συμπλήρωσης του προγεύματος τύπου, υβριδική αναζήτηση που συνδυάζει σημασιολογικό + πλήρες κείμενο, και προηγμένο φιλτράρισμα.
Αυτό το άρθρο καλύπτει την πραγματική εμπειρία αναζήτησης οι χρήστες αλληλεπιδρούν με αυτό το blog.
Το πλαίσιο αναζήτησης στην κορυφή αυτού του ιστότοπου παρέχει άμεση αναζήτηση-όπως-σας-τύπου αποτελέσματα.
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
Το πλαίσιο αναζήτησης χρησιμοποιεί Alpine.js για αντιδραστικό UI χωρίς βαριά πλαίσια JavaScript. Εδώ είναι το συστατικό:
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 = '';
}
}
}
Βασικά χαρακτηριστικά:
<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>
Γιατί; x-on:click.outside? Κάνοντας κλικ έξω από την πτώση κλείνει - πρότυπο UX μοτίβο για autocomplete.
Η /api/search/{query} Εδώ είναι το χειριστήριο:
[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");
}
}
}
Σημαντικές αποφάσεις σχεδιασμού:
semanticSearchConfig.Enabled Σας επιτρέπει να ενεργοποιήσετε σημασιολογική αναζήτησηΗ πραγματική μαγεία είναι υβριδική αναζήτηση Χρησιμοποιούμε αμοιβαία Rank Fusion (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
Ο τύπος: score = Σ(1 / (k + rank))
Όπου:
k = 60 (Αρκετά για να αποτρέψει τις πρώτες τάξεις από την κυριαρχία)rank = θέση στα αποτελέσματα της εν λόγω μεθόδου αναζήτησης (1-δείκτης)Γιατί λειτουργεί το RRF:
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();
}
Βασικά στοιχεία εφαρμογής:
Task.WhenAll)ToLowerInvariant()Όταν η σημασιολογική αναζήτηση είναι απενεργοποιημένη ή αποτυγχάνει, επιστρέφουμε στην αναζήτηση πλήρους κειμένου PostgreSQL.
Η αναζήτηση πλήρους κειμένου χειρίζεται δύο περιπτώσεις διαφορετικά:
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
}
Μία λέξη ("docker"): Χρήση αναζήτησης προθεμάτων Wildcard docker:*
Πολλαπλές λέξεις ("docker containers"): Χρησιμοποιεί τη σύνταξη αναζήτησης ιστού του 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)));
}
Γιατί; WebSearchToTsQuery? Χειρίζεται ερωτήματα φυσικής γλώσσας όπως το Google:
"docker containers" → αναζητήσεις και για τις δύο λέξειςdocker OR kubernetes → Boolean ORdocker -compose → αποκλείει το "σύνθετο"Για περισσότερες πληροφορίες σχετικά με Αναζήτηση πλήρους κειμένου PostgreSQL, δείτε Πλήρης αναζήτηση κειμένου με Postgres.
Πέρα από το typenaward, υπάρχει μια πλήρης σελίδα αποτελεσμάτων αναζήτησης με προηγμένο φιλτράρισμα:
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
};
}
Κάθε δημοσίευση στο blog δείχνει σημασιολογικά παρόμοιες θέσεις σε ένα colapsible πάνελ. Αυτό χρησιμοποιεί HTMX για τεμπέλικη φόρτωση:
<!-- 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>
Γιατί να καθυστερήσουμε 500μ; Το κύριο περιεχόμενο φορτώνει πρώτα, στη συνέχεια σχετικές θέσεις φορτίο στο παρασκήνιο. Οι χρήστες δείτε το περιεχόμενο αμέσως.
[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 ωρών: Οι σχετικές θέσεις δεν αλλάζουν συχνά, οπότε το επιθετικό caching είναι ασφαλές.
Ένα στοιχείο κατάρρευσης DaisyUI με ακτινική πρόοδο που δείχνει βαθμολογίες ομοιότητας:
@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>
}
Η ακτινική πρόοδος δείχνει ομοιότητα ως ποσοστό (- 100%), βοηθώντας τους χρήστες να κατανοήσουν πώς σχετίζεται κάθε θέση.
GET /api/search/{query}
~ Παράμετρος ~ Τύπος ~ Description ~
|-----------|------|-------------|
| query > συμβολοσειρά (path) > Αναζήτηση όρος (min 2 χαρακτήρες) >
Απάντηση: List<SearchResults>
[
{
"title": "Docker Containers Explained",
"slug": "docker-containers",
"url": "https://example.com/blog/docker-containers"
}
]
Φύλαξη: 1 ώρα, ποικίλλει ανάλογα με την ερώτηση
GET /search/semantic?query={query}&limit={limit}
□ Παράμετρος & Τύπος & & & & > > > > > < > > > > > > > > > > > > > > > > > > < > < > > > > < > > > < > > > > > < > > > > > > > > > > < > < > > > > > > > < > > > > < > < > > > > < > > > > < > < > < < > < > < < < < < < < > > > > > > > > > > > > >
|-----------|------|---------|-------------|
| query Ο όρος "αναζήτηση" είναι ο όρος "αναζήτηση"
| limit ~ int ~ 10 ~ Max results ~
Απάντηση: List<SearchResult> με βαθμολογίες ομοιότητας
GET /search/related/{slug}/{language}?limit={limit}
□ Παράμετρος & Τύπος & & & & > > > > > < > > > > > > > > > > > > > > > > > > < > < > > > > < > > > < > > > > > < > > > > > > > > > > < > < > > > > > > > < > > > > < > < > > > > < > > > > < > < > < < > < > < < < < < < < > > > > > > > > > > > > >
|-----------|------|---------|-------------|
| slug Η συμβολοσειρά είναι απαραίτητη για το blog post slug
| language Η συμβολοσειρά είναι απαραίτητη, κωδικός γλώσσας (εν, es, κ.λπ.)
| limit Οι θέσεις που σχετίζονται με το μέγ
Απάντηση: List<SearchResult> ταξινομήθηκε κατά ομοιότητα
Φύλαξη: 2 ώρες, ποικίλει ανά γυμνοσάλιαγκα και γλώσσα
GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}
□ Παράμετρος & Τύπος & & & & > > > > > < > > > > > > > > > > > > > > > > > > < > < > > > > < > > > < > > > > > < > > > > > > > > > > < > < > > > > > > > < > > > > < > < > > > > < > > > > < > < > < < > < > < < < < < < < > > > > > > > > > > > > >
|-----------|------|---------|-------------|
| query Ο όρος "αναζήτηση" είναι ο όρος "αναζήτηση"
| page □ int
| pageSize Αpiοτελέσματα ανά σελίδα
| language Φίλτρο από τη γλώσσα
| dateRange Όλο το χρόνο, τελευταίος χρόνος, τελευταίος μήνας, τελευταίος χρόνος, συνήθεια
| startDate ΗμερομηνίαTime null Ημερομηνία έναρξης (με ημερομηνίαRange=Custom)
| endDate ΗμερομηνίαTime null Ημερομηνία λήξης (με την ημερομηνίαRange=Προσοχή)
// 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" })]
Πάντα να απενεργοποιήσετε την είσοδο του χρήστη για να αποτρέψετε υπερβολικές κλήσεις API:
x-on:input.debounce.300ms="search"
300ms είναι μια καλή ισορροπία - αρκετά γρήγορη για να αισθάνονται ανταποκρίνεται, αρκετά αργή ώστε να μειώσει το φορτίο του διακομιστή.
Χρήση HTMX load delay: ενεργοποίηση για μη κρίσιμο περιεχόμενο:
hx-trigger="load delay:500ms"
Αυτό εξασφαλίζει ότι το κύριο περιεχόμενο είναι ορατό πριν από τα δευτερεύοντα φορτία περιεχομένου.
Το άρθρο αυτό κάλυψε το Εμπειρία αναζήτησης - πώς οι χρήστες αλληλεπιδρούν με σημασιολογική αναζήτηση. ανάπτυξη της παραγωγής συμπεριλαμβανομένης της αυτόματης ευρετηρίασης και της υπηρεσίας υποβάθρου, εξακολουθούν να:
Μέρος 5: Υβριδική Αναζήτηση & Auto-Indexing - Τρόποι ενσωμάτωσης στην παραγωγή:
Όλος ο διαθέσιμος κωδικός στη διεύθυνση: github.com/scottgal/κυρίως διαυγής ιστός
Mostlylucid/API/SearchApi.cs - API τύπου agreeceMostlylucid/Controllers/SearchController.cs - Πλήρης σελίδα αναζήτησηςMostlylucid.Services/Blog/BlogSearchService.cs - Υβριδική λογική αναζήτησηςMostlylucid/src/js/typeahead.js - Alpine.js component© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.