Att fixa platsen's sökningar: PostgreSQL-fulla sökningar -Textsökningar | ( och var den bryter sig
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.
Problemet
1. Vacka resultat visade slumpmässiga artiklar
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);
}
2. Akronymer som "DiSEM SK2 Weren' matchar inte
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}%"));
3. Technische termer med särskilda tecken Breke sök
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
};
4. Frågor som "ASP.NET och AlpineM SK3 fungerade inte
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
Lösningen
Google-Style sökoperatörer
Jag implementerade SearchQueryParser klass som analyserar frågor med stöd för:
- Citta fraser:
"exact match"- söker efter den exakta frasen - uteslutna villkor:
-unwanted- utelämnar resultat som innehåller det här begreppet - Wildkort:
ASP*matchar - Teknika villkor: Automatiskt hanterar
Parsaren 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());
}
}
}
Att bygga PostgreSQL tsquery
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
Förstärkt BuildSearchQuery
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;
}
sök exempel
Här är några exempel på den förbättrade sökfunktionen
Det grundläggande sökandet
DiSE
✅ Matchar nu artiklar med "DiSE" i titeln eller innehållet
Teknika villkor
ASP.NET
✅ Hittar artiklar om ASP.NET ( omvandlas automatiskt till SSK3aspnet
Phrasesökning
"semantic search"
✅ hittar exakta fraser "semantiskt sökandeM SK2 i artiklar
uteslutna villkor
ASP.NET -Core
✅ hittar ASP-, .- och NET-artiklar men utelämnar de som nämner "-, Core- och "-artiklar
Wildkort
ASP*
✅ Matchar "ASP", "A SPNETM SK4 | |
komplexa frågor
"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
För och efter exempel
Fråga: ASP.NET and Alpine
Innan (brokenM SK1
- "ASP
- " och " togs bort som slutord
- Resultat: Löser bara efter "AlpineM SK2
Efter (fixed
- "ASP
- " och" intelligent bevarade som en del av användarbeteendet
- "AlpineM SK1 söktes normalt
- Resultat: Hittar artiklar om både ASP .NET och Alpine
RRF-Rankingintegrering
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å
- Kategorismatch: +2.0
- Titelmatch: +1.0
- Frihet: Exponentiell nedgång över 1 år (+1.5 max
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
Teknik arkitektur
beroende injection
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.
Frågaflödet
- Användaren inser en sökfråga → sökkontroll
- Försöksanalys → SearchQueryParser bryter ner sig i strukturerade komponenter
- Semantiska sökningar ( om aktiverat) → Qdrant-vektordatabas med ONNX-bäddar
- PostgreSQL full -textsökning (ganär tillgängliga) → BM25-baserade nyckelmatchning
- RRF fusion → Kombinerar båda resultatuppsättningarna med kategorinM SK1frödhetsförhöjningar
- Inga resultat → Ge tillbaka de senaste Beiträgen som förslag
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.
Databaseringsschema
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
Tillvägagångspunkter
Varför ILIKE för akronymer?
Medan ILIKE (caseM SK1insensitiv LIKE) är långsammare än fullaMska3textsökningenM Ska4 det Mska5 är nödvändigt för akronymer eftersom
- Full- normaliserar textsökningen / standardiserar termer , bryter korta överhuvudtaget strängar
- Akronymer är vanligtvis korta, (≤6 tecken ), som begränsar effekten
- Bedingelsen är bara tilldanad när den behövs (identifiserade akronymer)
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
PostgreSQL-beläggningsdensitetsklassificering (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 tillsammans
Vi 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:
Aditionella Performanceoptimeringar
Förutom att fixa sökfunktionaliteten, flera viktiga optimeringar förbättrar prestationen :
1. Language Caching
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
2. Batched ILIKE queries
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
3. Omfattningsindex för vanliga frågor
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
4. Removed Unnecessary Includes
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:
En frågaoptimeringsstrategi
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
Executive Summary
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.
Lektioner som lärts
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_cdistället förts_rankför BM25- liknande relevansProfil före optimering: De bottlenecks som fanns i ", ", FTS-testning och ), var inte det verkliga problemet.
Batchdatabas-operationer: Flera
foreachloops som skapar separata var clauser genererar suboptimal SQL. AnvändAny()ellerAll()att byta till ett enda uttryck.
Framtidens förbättringar
Förhoppningsbara förbättringar för framtiden
Uppfinning för förbättringar:
- Fuzzy matchning: Levenshtein avstånd för typotolerans
- Synonymexpansion: "bloggpost
Förstärkningar för att analysera:
- Kategorisfiltering:
category:ASP.NET-operatör - Datumslängd:
after:2025-01-01-operatör
Rangeringsbestämningar:
- Result highlighting: Vertoon matchade textdelar i resultat
- Click- genom att spåra: Lär dig av användarbeteenden för att förbättra rankingen
Observerbarhet:
- sökanalys: Lyssna efter vanliga frågor och misslyckade sökningar för att identifiera luckor
Slutsatsen
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:
- Densitetsgradering (
ts_rank_cd) för bättre relevans - Språkkassering som eliminerar upprepade insökningar
- Batched ILIKE-expressioner för renare SQL
- Att täcka index som gör det möjligt att scanna index
- Avlägsna onödiga EF Core inkluderar
Den 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:
- Användare- riktar sig mot sökandet: Kombinerar nyckel och semantisk matchning för bättre resultat
- RAG-avsökning: Förstärker Avrättare GPT skrivassistent
Related articles:
- Semantiska sökningar i handling - Foundation : hybridsökning med RRF
- Att bygga en "Lawyer GPT" - Del 3 - Embeddings & vektordatabaser
- Att bygga en "Lawyer GPT" - Del 4 - Ingestion-torn
officiella dokumentation:
Alla koder tillgängliga i blog's GitHub-repository.