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 用于执行者@:语义搜索行动
Tuesday, 25 November 2025
一. 导言导言 导言 导言 导言
📖+RAG系列的一部分 这是 & 4% b @ MS K1} 搜索功能和 UIQ:
- RAG 起源和基本要点 “-”什么嵌入是“,”,为什么它们重要?
- RAG 建筑和内部 @ -_ Chunking,}代号@,}矢量数据库
- 内容:3: 建立完整的RAG系统
- “4a :ONNX”“& Qdrant 履行” - CPU-友好语义搜索基金会
- 操作中的语义搜索 ( @ this article @) @ @ MS K2_ typeahead @ MPK3}混合搜索@,和UI组件
- @ 5:_ 混合搜索@ MS K1\ 自动 @ MASK2 @ Indexing 生产一体化模式
- 6: 图表集 - 用于 proposal_- 水平理解的知识图表
内 第一部分:4_a, @% 我们建立了基础@:ONNX嵌入和 Qdrant 矢量存储 @.}现在让我们来让's将其与 a一起工作吧 实际搜索 UI “-”包括头型自动完成“,” 混合搜索,结合语义@+ full @- text,和高级过滤+.
本篇文章涵盖实际搜索经验用户在这个博客“.”上的交互作用。
机头搜索经验
此站点顶端的搜索框提供即时搜索@ - @ as @ MS K1_ you @ MPK2}% type 结果@ MOSK3 这里 @ I' 工作方式 @ OMK5}
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
AlpineMSMK0js typeahead 组件
搜索框使用 阿尔卑斯山 用于无重 JavaScript 框架的活性 UI+. @ Here'_%s
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 = '';
}
}
}
密钥特性 @:
- 跳跃输入@ : @ @ MPK1 @%s 延迟阻止了对服务器的敲锤
- 最短长度* :* 至少 {2} 需要字符
- 键盘导航@ :_ 箭头键@ MS K1_ 输入可访问性
- HTMX 整合: 成果使用 HTMX 进行顺利导航
搜索框 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>
为什么 x-on:click.outside? 在下拉调之外点击关闭自动完成的 . 标准 UX 模式
搜索API
缩略 /api/search/{query} 端点能量 typeahead@ . @ here @ MS K1} 控制器=:
[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使您能够切换语义搜索 - 输出缓存@ : @ @ MPK1_ hour 缓存减少用于常见查询的服务器载荷
- 分析跟踪@: 每一次搜索都被追踪\ (}#Helps理解用户的行为 )
- 优减退化如果语义搜索失败, :瀑布返回 PostgreSQL
与对等相互搜索混合
真正的魔法是 混合混合搜索 “-”结合语义和全文“-”文本结果“MS K2 ”我们用对等阵列混编“(”RRF) 来将它们完全合为一体,
为何混合搜索@? @%
不同的搜索方法有不同强项@: @%
| +搜索类型 @ | +强点 @MS K2+弱点@ | } | |
|---|---|---|---|
| 语义 “ | ”和“Synonoms” 等同语意指“,”概念:“ | ”可能错过了准确的短语“MS K4” | |
| 完整- @ text “ | ” 确切关键词@,}技术术语 @ | }没有同义词理解 * | * |
实例:: 正在搜索 @ " @ action
- 语义发现@: @ @ @ "_ Docker 教程 @ MPK2} @ I"# Kubernetes 指南" @ @ *(# 相关概念 @)#
- 完整-text finds @: 包含确切的@"}#container appredition *\ "
- 混合法获得最佳的两种“!”
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@(_convention 防止早期军衔 占据统治地位#)}rank此搜索方法中的 =Q 位置 @ MS K1QS 结果@ (1-\ indexed)}
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) - Case -不敏感的 Dedup:螺丝与
ToLowerInvariant() - 累计积分@:在两个来源的相同文章都会加分
- 最顶端 @ 15 @ 收效@:#足够打字机@,#没有压倒性
全-\Text 搜索后退
当语义搜索被禁用或失败@ , @ we fall back to PostgreSQL full-}( text search)\ MS K2}时, 我们返回到PostgresSQ L full@ msk1}
查询处理
完整的“-”文本搜索处理两个不同的案例@:
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"):使用通配符前缀搜索 docker:*
多个单词 ("docker 容器@"):}使用 PostgreSQL @'}网络搜索语法
PostgresSQL 查询
// 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? 它处理像谷歌那样的自然语言问询
"docker containers"→ 搜索两个单词docker OR kubernetes-=YTET -伊甸园字幕组=- 翻译:docker -compose→不包括 @"}compose"
在 PostgreSQL 全@- @ text search_ ,\ 查看 用 Postgres 搜索完整文本.
完整搜索页面
在编程头@ , @ there' 之后, 一个带有高级过滤器的完整搜索结果页面 @ MS K2}}
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
};
}
相关员额,含懒惰加载
每个博客文章都显示在折叠面板中的语义相似的文章@.}这使用 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 @ms @?# 用户立刻看到内容@. @%
相关员额
[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-_hour 缓存@:#相关文章@'_t change 常变吗?
相关员额构成部分
DaisiUI 的崩溃部件, 有辐射进展显示相似得分@: @%
@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>
}
光线进步 帮助使用者了解每个职位的关联程度@. @%
API 参考
类型标题搜索
GET /api/search/{query}
| 參數 | 類型 | 描述MS K3 |
|---|---|---|
query |
字符串 @(path) | 搜索术语 @(min @MS K5字元}) @# |
回应:: List<SearchResults>
[
{
"title": "Docker Containers Explained",
"slug": "docker-containers",
"url": "https://example.com/blog/docker-containers"
}
]
卡辛: @ 1_ hour@ MS K1} 依查询的不同而不同
语义搜索
GET /search/semantic?query={query}&limit={limit}
| 參數 | 類型 | 默认MS K3 描述□ | ||
|---|---|---|---|---|
query |
}字符串 @ | }需要@ | _搜索名 *{ | } |
limit @ |
@ int@ | } @ MS K2 @ @ * | # 最大结果 @ MPK4 @ |
回应:: List<SearchResult> 类似得分
相关员额
GET /search/related/{slug}/{language}?limit={limit}
| 參數 | 類型 | 默认MS K3 描述□ | ||
|---|---|---|---|---|
slug @ |
@ string { | } 需要 | _Blog post splash { | 博客贴文: @MS K3} |
language |
#字符串 @ | }需要語言代碼@(_en{,}(是,}等) | ||
limit @ |
}#MSK4* 相关文章 |
回应:: List<SearchResult> 按相似性分类
卡辛: @2@ hours ,}按弹孔和语言的不同而不同
带过滤器的完整搜索
GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}
| 參數 | 類型 | 默认MS K3 描述□ | ||
|---|---|---|---|---|
query |
}字符串 @ | }需要@ | _搜索名 *{ | } |
page @ |
@ int@ | } @ 1 @ @ @ I | _ page number# | @ |
pageSize @ |
@ int { | 10_ | }(每页结果)♫# | } |
language |
字符串 @ | null # | }用語言寫成的过滤器@ | |
dateRange 最后一年, 自定义 * |
* | |||
startDate @ |
_Datetime@ | _Nele | _BARBAR_开始日期#(}和约会时间Range{=_Custom})} | } |
endDate @ |
日期@ | 无效 @MS K2 结束日期 @( 和约会时间Range{=}_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" })]
击败
总是去掉用户输入,以防止API调用过多@: @%
x-on:input.debounce.300ms="search"
@300_%s 是一个很好的平衡 -} 足够快,足以感觉到反应迅速 , 慢到可以减少服务器负载@. *
懒惰装入
使用 HTMX @ ' @%s load delay: 触发非@- @ 关键内容 @ :+%
hx-trigger="load delay:500ms"
这确保了主要内容在二级内容负荷前能够明显可见@.
下一步是什么?
本条涵盖: 搜索经验 用户如何与语义搜索互动@. @ for 生产量部署 包括自动索引和背景服务@,}继续到 @:
@ 5:_ 混合搜索@ MS K1\ 自动 @ MASK2 @ Indexing 生产一体化模式
- 实时文件系统监视器@ - @ 时间索引
- 启动指数化的背景服务
- 用于增量更新的内容散列检测
资源资源资源 资源和资源资源资源
相关条款
- “4a :ONNX”“& Qdrant 履行” 本文以
- 使用 PostgreSQL 全文本搜索 - PostgreSQL 全@-}文本搜索设置
- 完整文本搜索@ - @ typeahead 原版实施
使用的技术
- 阿尔卑斯山 -轻量级活性 JavaScript
- HTMX -+HTML在线上
- DisayUI 调音界面 - 尾风CSS 组件库
- 相互排名融合 RRF算法文件
完整代码
所有代码都可用于 @: @% 多数为平质网,
Mostlylucid/API/SearchApi.cs“-” 型号APIMostlylucid/Controllers/SearchController.cs- 完整搜索页面Mostlylucid.Services/Blog/BlogSearchService.cs混合搜索逻辑Mostlylucid/src/js/typeahead.js-Alpine.js 组件