firestore-search-engine
Version:
Firestore Search Engine is a powerful helper library for enhancing search functionality in Firestore. Designed to handle misspellings, prefixes, and phonetic matching, this package generates multiple search variations for optimized approximate search resu
3 lines (2 loc) • 9.08 kB
JavaScript
"use strict";var e=require("firebase-functions/https"),t=require("fastembed"),n=require("@google-cloud/firestore");let i=null;async function r(e,n){if(0===e.length)return[];let r=[`query: ${e}`];const s=await async function(e){return i||(i=await t.FlagEmbedding.init({model:t.EmbeddingModel.AllMiniLML6V2,cacheDir:"src/cache",executionProviders:[t.ExecutionProvider.CPU],maxLength:e})),i}(n),o=s.embed(r,1),a=await o.next(),c=(null==a?void 0:a.value.length)>0?a.value:[];return Array.from(c[0])}class s{constructor(e,t,n,i){this.firestoreInstance=e,this.fieldValueInstance=t,this.config=n,this.props=i,this.config.wordMaxLength?this.wordMaxLength=this.config.wordMaxLength:this.wordMaxLength=100,this.config.wordMinLength?this.wordMinLength=this.config.wordMinLength:this.wordMinLength=3}async execute(){const e=await r(this.props.inputField,this.wordMaxLength);return await this.saveWithLimitedKeywords(this.props.returnedFields,e)}async saveWithLimitedKeywords(e,t){const n=this.firestoreInstance.bulkWriter();await this.cleanOldIndexes(e,n),n.create(this.firestoreInstance.collection(this.config.collection).doc(),{vectors:this.fieldValueInstance.vector(t),...e}),await n.close()}async cleanOldIndexes(e,t){const{indexedDocumentPath:n}=e,i=await this.firestoreInstance.collection(this.config.collection).where("indexedDocumentPath","==",n).get();if(!i.empty){for(let e=0;e<i.docs.length;e++)t.delete(i.docs[e].ref),e>500&&await t.flush();await t.flush()}}}class o{constructor(e,t,n){this.firestoreInstance=e,this.fieldValueInstance=t,this.config=n,this.config.wordMaxLength?this.wordMaxLength=this.config.wordMaxLength:this.wordMaxLength=100,this.config.wordMinLength?this.wordMinLength=this.config.wordMinLength:this.wordMinLength=3}async execute({documentProps:e,documentsToIndexes:t}){const n=[];for(const{...i}of t){const t=i[e.indexedKey],s={indexedDocumentPath:i.indexedDocumentPath,fieldValue:t.toLowerCase()};for(const t of e.returnedKey)s[t]=i[t];const o=await r(t,this.wordMaxLength);n.push({keywords:o,returnedFields:s})}return await this.bulkWithLimitedKeywords(n)}async bulkWithLimitedKeywords(e){const t=this.firestoreInstance.bulkWriter();let n=0;for(const i of e)n%1e3==0&&await t.flush(),await this.cleanOldIndexes(i.returnedFields,t,n),t.create(this.firestoreInstance.collection(this.config.collection).doc(),{vectors:this.fieldValueInstance.vector(i.keywords),...i.returnedFields}),n++,n>1500&&(await t.flush(),n++);await t.close()}async cleanOldIndexes(e,t,n){const{indexedDocumentPath:i}=e,r=await this.firestoreInstance.collection(this.config.collection).where("indexedDocumentPath","==",i).get();if(!r.empty)for(let e=0;e<r.docs.length;e++)t.delete(r.docs[e].ref),++n>1500&&await t.flush()}}function a(e,t){const n=Array.from({length:e.length+1},((e,n)=>[n,...Array(t.length).fill(0)]));for(let e=0;e<=t.length;e++)n[0][e]=e;for(let i=1;i<=e.length;i++)for(let r=1;r<=t.length;r++){const s=e[i-1]===t[r-1]?0:1;n[i][r]=Math.min(n[i-1][r]+1,n[i][r-1]+1,n[i-1][r-1]+s)}return n[e.length][t.length]}class c{constructor(e,t,n){this.firestoreInstance=e,this.config=t,this.props=n,this.props.limit||(this.props.limit=10),this.config.wordMaxLength?this.wordMaxLength=this.config.wordMaxLength:this.wordMaxLength=100,this.config.wordMinLength?this.wordMinLength=this.config.wordMinLength:this.wordMinLength=3,void 0!==this.props.distanceThreshold&&"number"==typeof this.props.distanceThreshold&&this.props.distanceThreshold>0&&this.props.distanceThreshold<1?this.distanceThreshold=this.props.distanceThreshold:void 0!==this.config.distanceThreshold&&"number"==typeof this.config.distanceThreshold&&this.config.distanceThreshold>0&&this.config.distanceThreshold<1?this.distanceThreshold=this.config.distanceThreshold:(console.log({message:"DistanceThreshold must be a float between 0 and 1"}),this.distanceThreshold=.2)}async execute(){return await this.search(this.props.fieldValue)}async search(e){var t;const n=await r(e,this.wordMaxLength),i=await this.firestoreInstance.collectionGroup(this.config.collection).findNearest({vectorField:"vectors",queryVector:n,limit:this.props.limit,distanceMeasure:"COSINE",distanceThreshold:null!==(t=this.props.distanceThreshold)&&void 0!==t?t:this.distanceThreshold,distanceResultField:"distance"}).get();if(i.empty)return[];const s=new Set,o=[];for(const e of i.docs){const t=e.data(),n=t.indexedDocumentPath;s.has(n)||(s.add(n),o.push(t))}const c=function(e,t){return e.map((e=>{const n=a(t.toLowerCase(),e.fieldValue.toLowerCase()),[i,r,s,o,c,h,d,l,u,f]=t.toLowerCase(),g=.03*(n+a([i,r,s,o,c,h,d,l,u,f].reverse().join(""),e.fieldValue.toLowerCase()))+.97*(1+10*e.distance);return{...e,finalScore:g}})).sort(((e,t)=>t.finalScore-e.finalScore)).reverse()}(o,e);return c}}function h(e,t){if(e===t)return!0;if(typeof e!=typeof t)return!1;if(e instanceof n.Timestamp&&t instanceof n.Timestamp)return e.isEqual(t);if(e&&t&&"object"==typeof e&&"object"==typeof t){const n=Object.keys(e),i=Object.keys(t);if(n.length!==i.length)return!1;for(const r of n){if(!i.includes(r))return!1;if(!h(e[r],t[r]))return!1}return!0}return!1}const d=e=>{const t=e.before.data(),n=e.after.data(),i={},r={},s={};Object.keys(n).forEach((e=>{const s=e,o=n[s],a=t[s];void 0===a?r[s]=o:h(o,a)||(i[s]=o)})),Object.keys(t).forEach((e=>{const i=e;void 0===n[i]&&(s[i]=t[i])}));return{before:t,after:n,changes:i,added:r,removed:s}};exports.FirestoreSearchEngine=class{constructor(e,t,n){if(this.firestoreInstance=e,this.config=t,this.fieldValueInstance=n,this.firestoreInstance.settings({ignoreUndefinedProperties:!0}),this.config.collection.length<1)throw new Error("collectionName is required and must be a non-empty string.")}async search(e){if("string"!=typeof e.fieldValue||0===e.fieldValue.length)throw new Error("fieldValue is required and must be a non-empty string.");return await new c(this.firestoreInstance,this.config,e).execute()}async indexes(e){if("string"!=typeof e.inputField||0===e.inputField.length)throw new Error("fieldValue is required and must be a non-empty string.");return await new s(this.firestoreInstance,this.fieldValueInstance,this.config,e).execute()}async indexesAll(e){return await new o(this.firestoreInstance,this.fieldValueInstance,this.config).execute(e)}async expressWrapper(e,t="/search",n){if(!t||!t.startsWith("/"))throw new Error("Path must be in the format '/search'");return e.get(`${t}/:searchValue`,(async(e,t)=>{var i;const{searchValue:r}=e.params;if(!r||"string"!=typeof r||r.length<(null!==(i=this.config.wordMinLength)&&void 0!==i?i:3))return console.log("WordMinLenght catched"),void t.json([]);try{const e=await this.search({...n,fieldValue:r});t.status(200).json(e)}catch(e){t.status(400).json(this.buildError(e))}})),e}onRequestWrapped(e){return async(t,n)=>{var i;const r=t.query.searchValue;if(!r||"string"!=typeof r||r.length<(null!==(i=this.config.wordMinLength)&&void 0!==i?i:3))return console.log("WordMinLenght catched"),void n.json([]);try{const t=await this.search({...e,fieldValue:r});n.status(200).json(t)}catch(e){n.status(400).json(this.buildError(e))}}}onCallWrapped(t,n){return async({data:i,auth:r})=>{var s;if(t){if(!await t(r))throw new e.HttpsError("unauthenticated","Unauthorized")}const o=i.searchValue;if(!o||"string"!=typeof o||o.length<(null!==(s=this.config.wordMinLength)&&void 0!==s?s:3))return console.log("WordMinLenght catched"),[];try{return await this.search({...n,fieldValue:o})}catch(t){const n=this.buildError(t);throw new e.HttpsError("aborted",n.message,n)}}}onDocumentWriteWrapper(e,t,n,i={},r={}){return e({...r,document:n},(async e=>{var n,r;const s=null===(n=e.data)||void 0===n?void 0:n.data();if(!e.data||!s)return;const o=s[t.indexedKey],a={};for(const e of t.returnedKey)(s[e]||0===s[e])&&(a[e]=s[e]);if(o&&"string"==typeof o&&o.length>(null!==(r=i.wordMinLength)&&void 0!==r?r:3))try{await this.indexes({...i,inputField:o,returnedFields:{indexedDocumentPath:e.data.ref.path,...a}})}catch(e){return void console.error(e)}}))}onDocumentUpdateWrapper(e,t,n,i={},r={}){return e({...r,document:n},(async e=>{var n;if(!e.data)return;const{changes:r,after:s}=d(e.data),o=r[t.indexedKey],a={};for(const e of t.returnedKey)(s[e]||0===s[e])&&(a[e]=s[e]);if(o&&"string"==typeof o&&o.length>(null!==(n=i.wordMinLength)&&void 0!==n?n:3))try{await this.indexes({...i,inputField:o,returnedFields:{indexedDocumentPath:e.data.after.ref.path,...a}})}catch(e){return void console.error(e)}}))}onDocumentDeletedWrapper(e,t,n={}){return e({...n,document:t},(async e=>{var t;const n=null===(t=e.data)||void 0===t?void 0:t.data();if(e.data&&n)try{const t=this.firestoreInstance.bulkWriter(),n=e.data.ref.path,i=await this.firestoreInstance.collection(this.config.collection).where("indexedDocumentPath","==",n).get();for(let e=0;e<i.docs.length;e++){const n=i.docs[e];t.delete(n.ref),e>500&&await t.flush()}await t.close()}catch(e){return}}))}buildError(e){const t=(new Error).stack;return{message:"An error was ocured at search endpoint for "+this.config.collection+"path collection.",error:"object"==typeof e?e:JSON.stringify(e),trace:t}}};
//# sourceMappingURL=index.cjs.map