Suchen är en av de egenskaper som alla underskattar. konceptuellt rätt men textmässigt fel. När en sökning misslyckas i dessa fall , tycker inte användaren
Det är också sometigt att "'" har plagierat den här sidan sedan dess inception. OpenSearch, har arbetat med PostgreSQLs fulltextsökningar med det 's fina vektorgrejer men var aldrig riktigt nöjda med det bugg i synnerhet nu bygger jag ett sökverktyg klarRAG ja, jag tänkte att jag skulle äntligen fixa det. Vare sig det är eller inte är en annan fråga
Den här artikeln handlar inte om att bygga en sökmotor från början, det handlar om att fixa de skarpa kanten på PostgreSQL fulltextsökningen i ett verkligt produktionssystem. Jag ska gå igenom de specifika misslyckanden jag träffade, varför de hände, och de praktiska lösningarna som behövs för att få sökningen att bete sig så som användaren redan förväntar sig.
Att bygga på tidigare arbete: Detta artikel förlänger implementering av semantiska sökningar som lagt till hybridsökningar med Reciprocal Rank Fusion (RRF). Den tidigare artikeln ombjöd grunden | - | att kombinera PostgreSQLs fulltextsökning |- | textsökning med Qdrant-vektorsökning ♫ . | Denna artikel löser de marginalfall som implementeringen misslyckats med
Den semantiska sökinfrastrukturen tjänar två syften: :, den driver sökningen och förser dragningslayret för Avrättare GPT RAG-systemet - en skriftassistent som utvecklar nya jobb med hjälp av hemsidan ' den existerande innehållet som kunskapsbasen Att bygga en "Lawyer GPT" – Del 3 på inbäddar och vektorsökningar.
När en sökning visade inga resultat, visade systemet slumpmässiga gamla artiklar istället för hjälpsamma förslag . Detta överträdde principet att minsta överraskning - användaren förväntade sig antingen relevanta resultat eller en tydlig | " inget match hittade |" meddelande med nyliga Beiträge som förslag | МSK5 Det gjorde det se ut som om sökningen hade fungerat
Fixen: Modifierade BlogSearchService.HybridSearchWithPagingAsync() för att upptäcka när en sökning ger noll resultat och återvänder till att visa de senaste Beiträgena som är ordenade efter datum som faller NoMatchFound flagga till BasePagingModel så att gränssnittet kan visa ett lämpligt meddelande som "Ingen match gevind. Betydde du en av dessa?
// No match found - return recent posts as suggestions
if (noMatchFound)
{
Log.Logger.Information("No search results for '{Query}', returning recent posts as suggestions", query);
return await GetRecentPostsAsSuggestions(targetLanguage, startDate, endDate, page, pageSize);
}
PostgreSQLs standardinriktade engelska textsökningskonfiguration indexerar akronymer tekniskt sett, men normalisering och utgång gör korta, ,, fall, -, betydelsefulla termer tillförlitliga i praktiken.. När du söker efter ", DiSE och ", blir det nedkalkat till ", dise och M SK8, så kan engelska ordboken slänga bort det eller påskilja det med låg vikt.
Fixen: Läggde till akronym-detektorn | ( | terminer | ≤6 | uppskriftiga bokstäver |) | och fall | - | okänslig substring fallback med hjälp av PostgreSQL ILIKE. Det här kompletterar fulltextsökningen, snarare än att ersätta den.
// Detect if query looks like an acronym or short term
var isAcronymLike = query.Length <= 6 && query.Any(char.IsUpper);
// Add substring search for acronyms
searchQuery = searchQuery.Where(x =>
EF.Functions.ILike(x.Title, $"%{acronym}%")
|| EF.Functions.ILike(x.PlainTextContent, $"%{acronym}%"));
sökandet efter "ASPM SK1NET" eller "CMSC4 skulle misslyckas eftersom PostgreSQLs textsucher analyserar perioder och hash-symboler som begränsare.
Fixen: skapade en SearchQueryParser som känner igen vanliga tekniska termer och ersätter dem med sökbara versioner:
private static readonly Dictionary<string, string> TechnicalTerms = new(StringComparer.OrdinalIgnoreCase)
{
["asp.net"] = "aspnet",
["c#"] = "csharp",
[".net"] = "dotnet",
["f#"] = "fsharp",
["node.js"] = "nodejs",
// ... more terms
};
PostgreSQL behandlar " och " som ett stoppord och tar bort dem från sökningar.
Fixen: Implementerade en Google- -query-parser som intelligent hanterar stop words och stödjer avancerade sökoperatörer
Jag implementerade SearchQueryParser klass som analyserar frågor med stöd för:
"exact match" - söker efter den exakta frasen-unwanted - utelämnar resultat som innehåller det här begreppetASP* matcharParsaren använder ett kompilerat regex-mönster för att tokenisera queryn:
[GeneratedRegex(@"""([^""]+)""|(-)?(\S+)", RegexOptions.Compiled)]
private static partial Regex QueryTokenRegex();
public ParsedQuery Parse(string query)
{
var matches = QueryTokenRegex().Matches(processedQuery);
foreach (Match match in matches)
{
// Quoted phrase
if (match.Groups[1].Success)
{
var phrase = match.Groups[1].Value.Trim();
result.Phrases.Add(phrase);
continue;
}
// Excluded term (starts with -)
if (match.Groups[2].Success)
{
var term = match.Groups[3].Value.Trim();
result.ExcludeTerms.Add(term.ToLowerInvariant());
continue;
}
// Regular term or wildcard
var token = match.Groups[3].Value.Trim();
if (token.Contains('*'))
{
result.WildcardTerms.Add(token.Replace("*", ""));
}
else if (!StopWords.Contains(token))
{
result.IncludeTerms.Add(token.ToLowerInvariant());
}
}
}
Det parser ut strukturerade data som konverteras sedan till PostgreSQL to_tsquery eftersom vi genererar strukturerad syntax - inte websearch_to_tsquery eftersom vi- -have redan analyserat själva operatorerna- -som ger oss större kontroll över hur villkoren kombineras.
public string BuildTsQuery(ParsedQuery parsed)
{
var queryParts = new List<string>();
// Add include terms with AND
foreach (var term in parsed.IncludeTerms)
{
queryParts.Add(term);
}
// Add wildcard terms with :* suffix
foreach (var term in parsed.WildcardTerms)
{
queryParts.Add($"{term}:*");
}
return queryParts.Count > 0 ? string.Join(" & ", queryParts) : string.Empty;
}
Varför inte bara använda
websearch_to_tsquery? Eftersom det fortfarande misslyckas med tekniska termer, akronymer , och domain- specifik syntax M SK3 och inte erbjuder några anhängningar för hybrid ranking eller fallbacksMska4 Genom att analysera oss självaMske5 kan vi hantera dessa marginalfall innan de når PostgreSQL
Den BuildSearchQuery metoden var helt omskriven för att använda den analyserade frågeställningsstrukturen baseQuery already filters by language, datumslängd , och synlighet - viM SK3tillhandahåller sökningarMska4 specifika villkor på toppenM Ska5
private IOrderedQueryable<BlogPostEntity> BuildSearchQuery(
string query,
string language,
DateTime? startDate,
DateTime? endDate,
string order)
{
var parsed = _queryParser.Parse(query);
IQueryable<BlogPostEntity> searchQuery = baseQuery;
// Handle phrases (exact substring matching)
foreach (var phrase in parsed.Phrases)
{
searchQuery = searchQuery.Where(x =>
EF.Functions.ILike(x.Title, $"%{phrase}%")
|| EF.Functions.ILike(x.PlainTextContent, $"%{phrase}%")
|| x.Categories.Any(c => EF.Functions.ILike(c.Name, $"%{phrase}%")));
}
// Build tsquery for include terms and wildcards
var tsQuery = _queryParser.BuildTsQuery(parsed);
// Apply full-text search if we have terms
if (!string.IsNullOrWhiteSpace(tsQuery))
{
searchQuery = searchQuery.Where(x =>
x.SearchVector.Matches(EF.Functions.ToTsQuery("english", tsQuery))
|| x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.ToTsQuery("english", tsQuery))));
}
// Handle acronyms with case-insensitive substring search
// This supplements full-text search (additive OR), not replaces it
var acronymTerms = parsed.IncludeTerms
.Concat(parsed.WildcardTerms)
.Where(t => _queryParser.IsAcronymLike(t))
.ToList();
foreach (var acronym in acronymTerms)
{
searchQuery = searchQuery.Where(x =>
EF.Functions.ILike(x.Title, $"%{acronym}%")
|| EF.Functions.ILike(x.PlainTextContent, $"%{acronym}%"));
}
// Handle excluded terms (must NOT contain these)
foreach (var excludeTerm in parsed.ExcludeTerms)
{
searchQuery = searchQuery.Where(x =>
!EF.Functions.ILike(x.Title, $"%{excludeTerm}%")
&& !EF.Functions.ILike(x.PlainTextContent, $"%{excludeTerm}%")
&& !x.Categories.Any(c => EF.Functions.ILike(c.Name, $"%{excludeTerm}%")));
}
return orderedQuery;
}
Här är några exempel på den förbättrade sökfunktionen
DiSE
✅ Matchar nu artiklar med "DiSE" i titeln eller innehållet
ASP.NET
✅ Hittar artiklar om ASP.NET ( omvandlas automatiskt till SSK3aspnet
"semantic search"
✅ hittar exakta fraser "semantiskt sökandeM SK2 i artiklar
ASP.NET -Core
✅ hittar ASP-, .- och NET-artiklar men utelämnar de som nämner "-, Core- och "-artiklar
ASP*
✅ Matchar "ASP", "A SPNETM SK4 | |
"full text search" PostgreSQL -MySQL
✅ Hittar artiklar med exakta fraser " full text search " Och nämningar om PostgreSQL M SK3 men utom artiklar som nämner MySQL
Fråga: ASP.NET and Alpine
Innan (brokenM SK1
Efter (fixed
sökandet integreras med Reciprocal Rank Fusion (RRF) som implementerat i tidigare semantisk sökartikel. RRF används här för Rangering, inte återhämta ♫ ♫ - ♫ det kombinerar redan ♫
// Fuse using RRF with category/freshness boosts
var fusedDtos = _ranker.FuseResults(bm25Results, vectorResults, query);
RRF-algoritmen använder 1/(k+rank) där k=60 för att kombinera resultat från flera källor , sätter sedan upphöjningar på
För den kompletta RRF-implementationen och hur hybridsökningar fungerar, se Semantiska sökningar i handling. För djupare dykar in i inbäddar och vektors likhet Att bygga en "Lawyer GPT" - Del 3. Samma infrastruktur förstärker både användaren - att göra sökningar och att få fram RAG för AI
De nya komponenterna är registrerade som tjänster:
services.AddSingleton<SearchQueryParser>();
services.AddSingleton<SearchRanker>();
services.AddScoped<BlogSearchService>();
SearchQueryParser och SearchRanker är singletoner eftersom de' är statlösa , trådar M SK2 säkra , och kan användas om och om igen i alla applikationer BlogSearchService är skalad eftersom den går in i databasens kontext.
Samma semantiska sökkomponenter används också av Avrättare GPT system för att hämta relevanta tidigare bloggposter för AI- hjälpskrivande. Se Del 4 för detaljer om intagningssledning.
sökandet är beroende av ett förutberäknat SearchVector i kolumnen BlogPosts Tabella:
ALTER TABLE mostlylucid."BlogPosts"
ADD COLUMN "SearchVector" tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
coalesce("Title", '') || ' ' ||
coalesce("PlainTextContent", '')
)
) STORED;
CREATE INDEX idx_blog_posts_search_vector
ON mostlylucid."BlogPosts"
USING GIN ("SearchVector");
GIN (Allgemeeniserad omvänd index) ger en snabb fulltextsökning över stora textkorpor
Medan ILIKE (caseM SK1insensitiv LIKE) är långsammare än fullaMska3textsökningenM Ska4 det Mska5 är nödvändigt för akronymer eftersom
Den här metoden skulle inte skalas till en godtycklig substringssökning över miljoner rader - men för riktade akronymfallbackar så är den ' den lämpliga
ts_rank_cd)PostgreSQL erbjuder två klassificeringsfunktioner för fulla sökningar
ts_rank: Grundbegreppsfrekvens ( hur många gånger uppträder begreppents_rank_cd: Ranging för ytdensitet ( hur nära termerna ser ut tillsammansVi använder ts_rank_cd eftersom det förser relevans som BM25- genom att räkna ut termisk närhet:
// Order by cover density ranking - rewards term proximity
orderedQuery = searchQuery.OrderByDescending(x =>
x.SearchVector.RankCoverDensity(EF.Functions.ToTsQuery("english", tsQuery)));
Varför? ts_rank_cd är bättre:
| Metrik | ts_rank |
ts_rank_cd |
|||||||
|---|---|---|---|---|---|---|---|---|---|
| Algoritm | Tydlig frekvens | Omfångsdensitet | ( | Ungefär | ) | ||||
| Multi-ord sökningar | räknar villkoren separat | Belöningskapsklauseln som uppträder tillsammans | |||||||
| exempel: "dockerbehållare " | Artikel med | " | docker | " | МSK3 | gånger hög poäng | Artikel med | ||
| Förmågor | Straft | Marginellt långsammare | , | lever fortfarande GIN-index | |||||
| Vanor | Enkel räknaning | Ungefär BMM SK2 |
Det här är en snabb vinnande optimering - bättre relevans-Ranking med noll tillämpning -nivåberäkningar . Allt ranking sker i PostgreSQL med hjälp av det existerande GIN-indexet på SearchVector.
Referenser:
Förutom att fixa sökfunktionaliteten, flera viktiga optimeringar förbättrar prestationen :
Möjligheterna till språk förändras sällan men söktes i varje sökansökning.. Cachérats nu med dubbelt.
private static readonly TimeSpan LanguageCacheDuration = TimeSpan.FromHours(1);
private static List<string>? _cachedLanguages;
private static DateTime _languageCacheExpiry = DateTime.MinValue;
private static readonly SemaphoreSlim _cacheLock = new(1, 1);
Intryck: Löser bort 1 database-frågan per sökanfrage
Den ursprungliga koden som användes foreach loops som skapar flera varelseklagor. Nu knutna till ensamma uttryck
// BEFORE: Multiple WHERE clauses
foreach (var acronym in acronymTerms)
{
searchQuery = searchQuery.Where(x =>
EF.Functions.ILike(x.Title, $"%{acronym}%"));
}
// AFTER: Single batched WHERE
if (acronymTerms.Count > 0)
{
searchQuery = searchQuery.Where(x =>
acronymTerms.Any(acronym =>
EF.Functions.ILike(x.Title, $"%{acronym}%")));
}
Intryck: renare SQL, ~5-10% snabbare för flera sökningar
Hinsatt delindex med INCLUDE- kolumner för vanliga tillgångsmönster:
CREATE INDEX idx_blog_posts_search_covering
ON mostlylucid."BlogPosts" ("LanguageId", "IsHidden", "ScheduledPublishDate")
INCLUDE ("Id", "Slug", "Title", "PublishedDate")
WHERE "IsHidden" = false;
Intryck: Aktiverar index- endast skanner - PostgreSQL behöver inte accessera tabellhalvan
EF Core's Include() ladda fulla navigeringsenheter. Removed where only navigation properties used in WHERE clauses:
// BEFORE: Loads full LanguageEntity into memory
.Include(x => x.LanguageEntity)
.Where(x => x.LanguageEntity.Name == "en")
// AFTER: EF translates navigation property without loading entity
.Where(x => x.LanguageEntity.Name == "en")
Intryck: ~5-10% minnesförsämring, mindre data överförd från databasenM SK3
Referenser:
sökningar bygger var clauser stegvis används EF Core's uttryckträdskomposition:
IQueryable<BlogPostEntity> searchQuery = baseQuery;
// Each filter added conditionally - PostgreSQL optimizes the final query
if (parsed.Phrases.Count > 0) { searchQuery = searchQuery.Where(...); }
if (!string.IsNullOrWhiteSpace(tsQuery)) { searchQuery = searchQuery.Where(...); }
if (acronymTerms.Count > 0) { searchQuery = searchQuery.Where(...); }
Detta genererar en en enda optimerad SQL-fråga snarare än flera omloppsresor. PostgreSQL's sökplanerare kan använda statistik och index effektivt när de ser den kompletta "Var" -klaxen
Cumulativ påverkan av alla optimeringar:
| Optimering ♫ ♫ | ♫ Latency Impact ♫ | ||||||
|---|---|---|---|---|---|---|---|
| Sprachens kassering | Minimalt ♫ ♫ | ♫ | |||||
| Ta bort Inbegripa() | -5-10% \ | Mindre dataöverföring | |||||
| Batch ILIKE | |||||||
| Omslagsindex | -20-30% | Index | - | endast skanningar | |||
| tsM SK1rank_cd | Samma | bättre relevans | |||||
| Cachefix (route params) | NM SK4A |
Total förväntad förbättring: 30-50% snabbare sök med signifikant minskad datamängd.
Don' inte anta att fulla-textsökningen hanterar allt: Edge cases like acronyms and special characters need special handling
Kombinera flera angrepp: FullM SK1textsökning (BM25) ♫ ♫ + semantisksökning ♫
Google-formade användares förväntningar: Att stödja citera fraser
Ge hjälpsamma bakslag: När sökandet misslyckas , gör inget ' inte visa någonting M SK3 visar de senaste Beiträgen och gör det tydligt att inga matchar hittades
Parse , don't hack: En korrekt fråga-parser är renare och mer underhållningsbar än en rad strängmanipulationer
Leverage PostgreSQL: Använd ts_rank_cd istället för ts_rank för BM25- liknande relevans
Profil före optimering: De bottlenecks som fanns i ", ", FTS-testning och ), var inte det verkliga problemet.
Batchdatabas-operationer: Flera foreach loops som skapar separata var clauser genererar suboptimal SQL. Använd Any() eller All() att byta till ett enda uttryck.
Förhoppningsbara förbättringar för framtiden
Uppfinning för förbättringar:
Förstärkningar för att analysera:
category:ASP.NET -operatörafter:2025-01-01 -operatörRangeringsbestämningar:
Observerbarhet:
Byggnaderproduktionen-sortssökning kräver fixande kanter och optimering av prestationer. Den här artikeln omfattade både: Fixering of PostgreSQL fullM SK2 text search edge cases | ( | acronyms |, | tekniska termer | , | Google |- | stiloperatörer ♫ ) | och implementering of key performance optimizations ts_rank_cd).
Implementering uppnådde 30-50% snabbare sök medan datamängden minskar genom:
ts_rank_cd) för bättre relevansDen viktigaste insikten: ingen enskild metod hanterar alla fall . PostgreSQL FTS är duktig på att matcha nyckelordar , semantiskt sökande hanterar konceptuella frågor M SK3 och riktade fallbacks fångar randprov i fallet, . Den rena paringslayern knyter ihop det,, hanterar operatörer och tekniska termer innan de når sökmotorerna,
Den semantiska sökinfrastrukturen tjänar två ändamål:
Related articles:
officiella dokumentation:
Alla koder tillgängliga i blog's GitHub-repository.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.