@@ -876,6 +876,13 @@ class PostgresElasticClient implements ElasticFacade.ElasticClient {
876876 hits. add(hit)
877877 }
878878
879+ // Fuzzy fallback: if tsvector search returned 0 results and we have a search text,
880+ // retry using pg_trgm similarity matching (handles typos, misspellings)
881+ if (hits. isEmpty() && tq. cleanedSearchText && tq. tsqueryExpr) {
882+ Map fuzzyResult = searchFuzzyFallback(indexNames, tq, useDbHighlights)
883+ if (fuzzyResult != null ) return fuzzyResult
884+ }
885+
879886 return [hits : [total : [value : totalCount, relation : " eq" ], hits : hits],
880887 _shards : [total : 1 , successful : 1 , failed : 0 ]]
881888 } finally { rs. close() }
@@ -1102,6 +1109,164 @@ class PostgresElasticClient implements ElasticFacade.ElasticClient {
11021109 return (List<Map > ) ((Map ) result. get(" hits" )). get(" hits" )
11031110 }
11041111
1112+ // ============================================================
1113+ // Fuzzy Search Fallback (pg_trgm)
1114+ // ============================================================
1115+
1116+ /**
1117+ * Fuzzy fallback search using pg_trgm similarity when tsvector FTS returns zero results.
1118+ * Uses the existing GIN trigram index (idx_mq_doc_trgm) on content_text.
1119+ * Returns null if fuzzy search also finds nothing.
1120+ */
1121+ private Map searchFuzzyFallback (List<String > indexNames , ElasticQueryTranslator.TranslatedQuery tq , boolean useDbHighlights ) {
1122+ String searchText = tq. cleanedSearchText
1123+ if (! searchText) return null
1124+
1125+ String idxPlaceholders = indexNames. collect { " ?" }. join(" , " )
1126+
1127+ // Set the pg_trgm similarity threshold for this query (lower = more results)
1128+ Connection conn = getConnection()
1129+ PreparedStatement threshPs = conn. prepareStatement(" SELECT set_limit(0.2)" )
1130+ try { threshPs. executeQuery(). close() } catch (Exception e) {
1131+ // set_limit may not exist in newer PG (use pg_trgm.similarity_threshold GUC instead)
1132+ try {
1133+ PreparedStatement gucPs = conn. prepareStatement(" SET pg_trgm.similarity_threshold = 0.2" )
1134+ gucPs. execute()
1135+ gucPs. close()
1136+ } catch (Exception e2) { logger. trace(" Could not set pg_trgm threshold: ${ e2.message} " ) }
1137+ } finally { threshPs. close() }
1138+
1139+ String fuzzyScoreExpr = ElasticQueryTranslator . buildFuzzyScoreExpr()
1140+
1141+ // Build highlight columns for fuzzy results (use ILIKE-based highlighting since we don't have a tsquery)
1142+ String hlSelect = " "
1143+ List<String > hlFieldNames = []
1144+ if (useDbHighlights && tq. highlightFields) {
1145+ List<String > hlExprs = []
1146+ for (String hlField in tq. highlightFields. keySet()) {
1147+ String jsonPath = ElasticQueryTranslator . fieldToJsonPath(" document" , hlField)
1148+ // For fuzzy matches, use a simple regexp_replace highlight since ts_headline requires a tsquery
1149+ hlExprs. add(" regexp_replace(coalesce(${ jsonPath} , ''), '(' || ? || ')', '<em>\\ 1</em>', 'gi') AS hl_${ hlFieldNames.size()} " . toString())
1150+ hlFieldNames. add(hlField)
1151+ }
1152+ if (hlExprs) hlSelect = " , " + hlExprs. join(" , " )
1153+ }
1154+
1155+ String sql = """
1156+ SELECT doc_id, index_name, doc_type, document, ${ fuzzyScoreExpr} AS _score${ hlSelect}
1157+ FROM moqui_document
1158+ WHERE index_name IN (${ idxPlaceholders} ) AND content_text % ?
1159+ ORDER BY _score DESC
1160+ LIMIT ? OFFSET ?
1161+ """ . trim()
1162+
1163+ // Parameters: fuzzyScoreExpr(?=searchText), hlParams, indexNames, similarity(?=searchText), limit, offset
1164+ List<Object > allParams = []
1165+ allParams. add(searchText) // for similarity() score expression
1166+ // highlight params (search text for each highlight field's regexp_replace)
1167+ for (int h = 0 ; h < hlFieldNames. size(); h++ ) {
1168+ allParams. add(escapeRegex(searchText))
1169+ }
1170+ allParams. addAll(indexNames)
1171+ allParams. add(searchText) // for the WHERE content_text % ?
1172+ allParams. add(tq. sizeLimit)
1173+ allParams. add(tq. fromOffset)
1174+
1175+ PreparedStatement ps = conn. prepareStatement(sql)
1176+ try {
1177+ for (int i = 0 ; i < allParams. size(); i++ ) setParam(ps, i + 1 , allParams[i])
1178+ ResultSet rs = ps. executeQuery()
1179+ try {
1180+ List<Map > hits = []
1181+ while (rs. next()) {
1182+ String docJson = rs. getString(" document" )
1183+ Map source = docJson ? (Map ) jsonToObject(docJson) : [:]
1184+ double score = rs. getDouble(" _score" )
1185+
1186+ Map hit = [_index : unprefixIndexName(rs. getString(" index_name" )),
1187+ _id : rs. getString(" doc_id" ),
1188+ _type : rs. getString(" doc_type" ),
1189+ _score : score, _source : source] as Map
1190+
1191+ if (hlFieldNames) {
1192+ Map<String , List<String > > highlights = [:]
1193+ for (int h = 0 ; h < hlFieldNames. size(); h++ ) {
1194+ String hlResult = rs. getString(" hl_${ h} " )
1195+ if (hlResult) highlights. put(hlFieldNames[h], [hlResult])
1196+ }
1197+ if (highlights) hit. put(" highlight" , highlights)
1198+ }
1199+
1200+ hits. add(hit)
1201+ }
1202+ if (hits. isEmpty()) return null
1203+
1204+ logger. info(" Fuzzy fallback matched ${ hits.size()} documents for query '${ searchText} '" )
1205+ return [hits : [total : [value : (long ) hits. size(), relation : " eq" ], hits : hits],
1206+ _shards : [total : 1 , successful : 1 , failed : 0 ]]
1207+ } finally { rs. close() }
1208+ } finally { ps. close() }
1209+ }
1210+
1211+ /**
1212+ * Suggest completions for a partial search term using pg_trgm word_similarity().
1213+ * Searches against document field values for typeahead/autocomplete.
1214+ * @param index Index name (or null for all indexes)
1215+ * @param field Document field to suggest from (e.g. "productName", "description")
1216+ * @param prefix The partial text to complete
1217+ * @param maxResults Maximum number of suggestions to return
1218+ * @return A list of suggestion maps with [text: String, score: double]
1219+ */
1220+ List<Map > suggest (String index , String field , String prefix , int maxResults = 10 ) {
1221+ if (! prefix || prefix. trim(). isEmpty()) return []
1222+ ElasticQueryTranslator . sanitizeFieldName(field)
1223+ String cleanPrefix = prefix. trim()
1224+
1225+ List<String > indexNames = resolveIndexNames(index)
1226+ if (indexNames. isEmpty()) return []
1227+
1228+ String idxPlaceholders = indexNames. collect { " ?" }. join(" , " )
1229+ String jsonPath = ElasticQueryTranslator . fieldToJsonPath(" document" , field)
1230+
1231+ String sql = """
1232+ SELECT DISTINCT ${ jsonPath} AS val, word_similarity(?, ${ jsonPath} ) AS score
1233+ FROM moqui_document
1234+ WHERE index_name IN (${ idxPlaceholders} )
1235+ AND ${ jsonPath} IS NOT NULL
1236+ AND ${ jsonPath} <> ''
1237+ AND ? <% ${ jsonPath}
1238+ ORDER BY score DESC
1239+ LIMIT ?
1240+ """ . trim()
1241+
1242+ List<Object > allParams = []
1243+ allParams. add(cleanPrefix) // for word_similarity()
1244+ allParams. addAll(indexNames)
1245+ allParams. add(cleanPrefix) // for <% operator
1246+ allParams. add(maxResults)
1247+
1248+ Connection conn = getConnection()
1249+ PreparedStatement ps = conn. prepareStatement(sql)
1250+ try {
1251+ for (int i = 0 ; i < allParams. size(); i++ ) setParam(ps, i + 1 , allParams[i])
1252+ ResultSet rs = ps. executeQuery()
1253+ try {
1254+ List<Map > suggestions = []
1255+ while (rs. next()) {
1256+ String text = rs. getString(" val" )
1257+ double score = rs. getDouble(" score" )
1258+ if (text) suggestions. add([text : text, score : score])
1259+ }
1260+ return suggestions
1261+ } finally { rs. close() }
1262+ } finally { ps. close() }
1263+ }
1264+
1265+ /* * Escape special regex characters in a string for use in regexp_replace */
1266+ private static String escapeRegex (String text ) {
1267+ return text. replaceAll(/ ([.\\ +*?\[ ^\] $(){}=!<>|:\- ])/ , ' \\\\ $1' )
1268+ }
1269+
11051270 @Override
11061271 Map validateQuery (String index , Map queryMap , boolean explain ) {
11071272 try {
0 commit comments