Skip to content

Commit 5ac7e49

Browse files
committed
All the search logic
1 parent 4cefb0b commit 5ac7e49

1 file changed

Lines changed: 313 additions & 4 deletions

File tree

controllers/search.js

Lines changed: 313 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,15 +364,12 @@ const searchAsPhrase = async function (req, res, next) {
364364
const phraseOptions = {
365365
slop: 2
366366
}
367-
368367
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip)
369-
370368
try {
371369
const [resultsPresi3, resultsPresi2] = await Promise.all([
372370
db.aggregate(queryPresi3).toArray().catch(() => []),
373371
db.aggregate(queryPresi2).toArray().catch(() => [])
374372
])
375-
376373
const merged = mergeSearchResults(resultsPresi3, resultsPresi2)
377374
let results = merged.slice(skip, skip + limit)
378375
results = results.map(o => idNegotiation(o))
@@ -384,7 +381,319 @@ const searchAsPhrase = async function (req, res, next) {
384381
}
385382
}
386383

384+
/**
385+
* Fuzzy text search endpoint - searches for approximate matches allowing for typos and misspellings.
386+
*
387+
* @route POST /fuzzySearch
388+
* @param {Object} req.body - Request body containing search text
389+
* @param {string} req.body.searchText - The text to search for (can also be a plain string body)
390+
* @param {number} [req.query.limit=100] - Maximum number of results to return
391+
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
392+
* @returns {Array<Object>} JSON array of matching annotation objects sorted by relevance score
393+
*
394+
* @description
395+
* Performs a fuzzy MongoDB Atlas Search that allows for approximate matches:
396+
* - Tolerates up to 1 character edit (insertion, deletion, substitution, transposition)
397+
* - Requires at least 2 characters to match exactly before fuzzy matching begins
398+
* - Expands to up to 50 similar terms
399+
* - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
400+
*
401+
* Fuzzy Options:
402+
* - maxEdits: 1 (allows one character difference)
403+
* - prefixLength: 2 (first 2 characters must match exactly)
404+
* - maxExpansions: 50 (considers up to 50 similar terms)
405+
*
406+
* Search Behavior Examples:
407+
* - "Bryan" → matches "Bryan", "Brian" (1 edit)
408+
* - "Haberberger" → matches "Haberberger", "Haberburger" (1 edit)
409+
* - "manuscript" → matches "manuscript", "manuscripr" (1 edit)
410+
* - "ab" → only exact matches (too short for fuzzy, at prefixLength)
411+
*
412+
* Use Cases:
413+
* - Handling user typos
414+
* - Finding names with spelling variations
415+
* - Searching when exact spelling is uncertain
416+
* - More lenient search than standard text search
417+
*
418+
* Note: Fuzzy search typically returns more results than standard search and may
419+
* have slightly lower precision due to approximate matching.
420+
*
421+
* @example
422+
* POST /fuzzySearch?limit=200
423+
* Body: "manuscripr"
424+
* Returns: Annotations containing "manuscript" (correcting the typo)
425+
*/
426+
const searchFuzzily = async function (req, res, next) {
427+
res.set("Content-Type", "application/json; charset=utf-8")
428+
let searchText = req.body?.searchText ?? req.body
429+
if (!searchText) {
430+
let err = {
431+
message: "You did not provide text to search for in the search request.",
432+
status: 400
433+
}
434+
next(utils.createExpressError(err))
435+
return
436+
}
437+
const limit = parseInt(req.query.limit ?? 100)
438+
const skip = parseInt(req.query.skip ?? 0)
439+
const fuzzyOptions = {
440+
fuzzy: {
441+
maxEdits: 1,
442+
prefixLength: 2,
443+
maxExpansions: 50
444+
}
445+
}
446+
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: fuzzyOptions }, limit, skip)
447+
try {
448+
const [resultsPresi3, resultsPresi2] = await Promise.all([
449+
db.aggregate(queryPresi3).toArray().catch(() => []),
450+
db.aggregate(queryPresi2).toArray().catch((error) => { console.error(error); return []; })
451+
])
452+
const merged = mergeSearchResults(resultsPresi3, resultsPresi2)
453+
let results = merged.slice(skip, skip + limit)
454+
results = results.map(o => idNegotiation(o))
455+
res.set(utils.configureLDHeadersFor(results))
456+
res.json(results)
457+
} catch (error) {
458+
console.error(error)
459+
next(utils.createExpressError(error))
460+
}
461+
}
462+
463+
/**
464+
* Wildcard pattern search endpoint - searches using wildcard patterns for partial matches.
465+
*
466+
* @route POST /wildcardSearch
467+
* @param {Object} req.body - Request body containing search pattern
468+
* @param {string} req.body.searchText - The wildcard pattern to search for (must contain * or ?)
469+
* @param {number} [req.query.limit=100] - Maximum number of results to return
470+
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
471+
* @returns {Array<Object>} JSON array of matching annotation objects sorted by relevance score
472+
*
473+
* @description
474+
* Performs a wildcard search using pattern matching:
475+
* - '*' matches zero or more characters (any length)
476+
* - '?' matches exactly one character
477+
* - Searches across both IIIF Presentation API 3.0 and 2.1 indexes in parallel
478+
* - Requires at least one wildcard character in the search pattern
479+
*
480+
* Wildcard Options:
481+
* - allowAnalyzedField: true (enables wildcard search on analyzed text fields)
482+
*
483+
* Pattern Matching Examples:
484+
* - "Bryan*" → matches "Bryan", "Bryanna", "Bryan Haberberger"
485+
* - "*berger" → matches "Haberberger", "hamburger", "cheeseburger"
486+
* - "B?yan" → matches "Bryan", "Broan", "Bruan"
487+
* - "man*script" → matches "manuscript", "manuscripts", "manuscript illumination"
488+
* - "*the*" → matches any text containing "the"
489+
*
490+
* Use Cases:
491+
* - Searching for word prefixes or suffixes
492+
* - Finding variations of a term
493+
* - Partial word matching
494+
* - Pattern-based discovery
495+
*
496+
* Important Notes:
497+
* - Search pattern MUST contain at least one wildcard (* or ?)
498+
* - Returns 400 error if no wildcards are present
499+
* - Wildcard searches may be slower than standard text searches
500+
* - Leading wildcards (*term) are less efficient but supported
501+
*
502+
* Performance Tips:
503+
* - Avoid leading wildcards when possible ("term*" is faster than "*term")
504+
* - Be specific to reduce result set size
505+
* - Use with limit parameter for large result sets
506+
*
507+
* @example
508+
* POST /wildcardSearch
509+
* Body: "*berger"
510+
* Returns: All annotations with words ending in "berger"
511+
*
512+
* @example
513+
* POST /wildcardSearch
514+
* Body: "man?script"
515+
* Returns: Annotations matching "manuscript", "manuscript", etc.
516+
*/
517+
const searchWildly = async function (req, res, next) {
518+
res.set("Content-Type", "application/json; charset=utf-8")
519+
let searchText = req.body?.searchText ?? req.body
520+
if (!searchText) {
521+
let err = {
522+
message: "You did not provide text to search for in the search request.",
523+
status: 400
524+
}
525+
next(utils.createExpressError(err))
526+
return
527+
}
528+
// Require wildcards in the search text
529+
if (!searchText.includes('*') && !searchText.includes('?')) {
530+
let err = {
531+
message: "Wildcards must be used in wildcard search. Use '*' to match any characters or '?' to match a single character.",
532+
status: 400
533+
}
534+
next(utils.createExpressError(err))
535+
return
536+
}
537+
const limit = parseInt(req.query.limit ?? 100)
538+
const skip = parseInt(req.query.skip ?? 0)
539+
const wildcardOptions = {
540+
allowAnalyzedField: true
541+
}
542+
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "wildcard", options: wildcardOptions }, limit, skip)
543+
try {
544+
const [resultsPresi3, resultsPresi2] = await Promise.all([
545+
db.aggregate(queryPresi3).toArray().catch(() => []),
546+
db.aggregate(queryPresi2).toArray().catch(() => [])
547+
])
548+
const merged = mergeSearchResults(resultsPresi3, resultsPresi2)
549+
let results = merged.slice(skip, skip + limit)
550+
results = results.map(o => idNegotiation(o))
551+
res.set(utils.configureLDHeadersFor(results))
552+
res.json(results)
553+
} catch (error) {
554+
console.error(error)
555+
next(utils.createExpressError(error))
556+
}
557+
}
558+
559+
/**
560+
* "More Like This" search endpoint - finds documents similar to a provided example document.
561+
*
562+
* @route POST /searchAlikes
563+
* @param {Object} req.body - A complete JSON document to use as the search example
564+
* @param {number} [req.query.limit=100] - Maximum number of results to return
565+
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
566+
* @returns {Array<Object>} JSON array of similar annotation objects sorted by relevance score
567+
*
568+
* @description
569+
* Performs a "moreLikeThis" search that finds documents similar to an example document:
570+
* - Analyzes the provided document's text content
571+
* - Extracts significant terms and patterns
572+
* - Finds other documents with similar content
573+
* - Uses both IIIF 3.0 (presi3AnnotationText) and IIIF 2.1 (presi2AnnotationText) indexes
574+
* - Great for discovery and finding related content
575+
*
576+
* How It Works:
577+
* 1. You provide a complete JSON document (annotation, manifest, etc.)
578+
* 2. MongoDB Atlas Search extracts key terms from the document
579+
* 3. Searches for other documents containing similar terms
580+
* 4. Returns results ranked by similarity score
581+
*
582+
* Use Cases:
583+
* - "Find more annotations like this one"
584+
* - Discovering related content after viewing a document
585+
* - Building recommendation systems
586+
* - Content clustering and grouping
587+
* - Finding duplicates or near-duplicates
588+
*
589+
* Workflow:
590+
* 1. User performs standard search → gets results
591+
* 2. User selects an interesting result
592+
* 3. Pass that document to /searchAlikes
593+
* 4. Get more documents with similar content
594+
*
595+
* Important Notes:
596+
* - Requires a full JSON document in request body (not just text)
597+
* - Searches both IIIF 3.0 (presi3AnnotationText) and IIIF 2.1 (presi2AnnotationText) indexes
598+
* - Returns 400 error if body is empty or invalid
599+
* - More effective with documents containing substantial text content
600+
*
601+
* Input Document Structure:
602+
* - Can be any annotation object from your collection
603+
* - Should contain text in body.value, bodyValue, or nested fields
604+
* - The more text content, the better the similarity matching
605+
*
606+
* @example
607+
* POST /searchAlikes
608+
* body: {
609+
* "type": "Annotation",
610+
* "body": {
611+
* "value": "Medieval manuscript with gold leaf illumination..."
612+
* }
613+
* }
614+
* Returns: Other annotations about medieval manuscripts and illumination
615+
*
616+
* @example
617+
* // Typical workflow:
618+
* // 1. Search for "illuminated manuscripts"
619+
* const results = await fetch('/search', {body: {searchText: "illuminated manuscripts"}})
620+
* // 2. User likes result[0], find more like it
621+
* const similar = await fetch('/searchAlikes', {body: results[0]})
622+
*/
623+
const searchAlikes = async function (req, res, next) {
624+
res.set("Content-Type", "application/json; charset=utf-8")
625+
let likeDocument = req.body
626+
// Validate that a document was provided
627+
if (!likeDocument || (typeof likeDocument !== 'object') || Object.keys(likeDocument).length === 0) {
628+
let err = {
629+
message: "You must provide a JSON document in the request body to find similar documents.",
630+
status: 400
631+
}
632+
next(utils.createExpressError(err))
633+
return
634+
}
635+
const limit = parseInt(req.query.limit ?? 100)
636+
const skip = parseInt(req.query.skip ?? 0)
637+
// Build moreLikeThis queries for both IIIF 3.0 and IIIF 2.1 indexes
638+
const searchQuery_presi3 = [
639+
{
640+
$search: {
641+
index: "presi3AnnotationText",
642+
moreLikeThis: {
643+
like: Array.isArray(likeDocument) ? likeDocument : [likeDocument]
644+
}
645+
}
646+
},
647+
{
648+
$addFields: {
649+
"__rerum.score": { $meta: "searchScore" }
650+
}
651+
},
652+
{
653+
$limit: limit + skip // Get extra to handle deduplication
654+
}
655+
]
656+
const searchQuery_presi2 = [
657+
{
658+
$search: {
659+
index: "presi2AnnotationText",
660+
moreLikeThis: {
661+
like: Array.isArray(likeDocument) ? likeDocument : [likeDocument]
662+
}
663+
}
664+
},
665+
{
666+
$addFields: {
667+
"__rerum.score": { $meta: "searchScore" }
668+
}
669+
},
670+
{
671+
$limit: limit + skip // Get extra to handle deduplication
672+
}
673+
]
674+
try {
675+
// Execute both queries in parallel
676+
const [results_presi3, results_presi2] = await Promise.all([
677+
db.aggregate(searchQuery_presi3).toArray(),
678+
db.aggregate(searchQuery_presi2).toArray()
679+
])
680+
// Merge and deduplicate results
681+
const merged = mergeSearchResults(results_presi3, results_presi2)
682+
// Apply pagination after merging
683+
let results = merged.slice(skip, skip + limit)
684+
results = results.map(o => idNegotiation(o))
685+
res.set(utils.configureLDHeadersFor(paginatedResults))
686+
res.json(paginatedResults)
687+
} catch (error) {
688+
console.error(error)
689+
next(utils.createExpressError(error))
690+
}
691+
}
692+
387693
export {
388694
searchAsWords,
389-
searchAsPhrase
695+
searchAsPhrase,
696+
searchWildly,
697+
searchFuzzily,
698+
searchAlikes
390699
}

0 commit comments

Comments
 (0)