UNPKG

ndx

Version:

Lightweight Full-Text Indexing and Searching Library.

166 lines (152 loc) 4.8 kB
import type { DocumentIndex, InvertedIndexNode } from "./index.js"; import { findInvertedIndexNode } from "./index.js"; /** * Query Result. * * @typeparam T Document key. */ export interface QueryResult<I> { /** Document key. */ readonly key: I; /** Result score. */ readonly score: number; } const sortByScore = ( a: QueryResult<any>, b: QueryResult<any>, ) => b.score - a.score; const max = Math.max; const log = Math.log; const fromCharCode = String.fromCharCode; /** * Performs a search with a simple free text query. * * All token separators work as a disjunction operator. * * @typeparam T Document key. * @param index {@link DocumentIndex}. * @param fieldBoost Field boost factors. * @param bm25k1 BM25 ranking function constant `k1`, controls non-linear term * frequency normalization (saturation). * @param bm25b BM25 ranking function constant `b`, controls to what degree * document length normalizes tf values. * @param s Query string. * @returns Array of {@link QueryResult} objects. */ export const indexQuery = <T>( index: DocumentIndex<T>, fieldBoost: number[], bm25k1: number, bm25b: number, s: string, ): QueryResult<T>[] => { const { docs, root, fAvg, tokenizer, filter } = index; const terms = tokenizer(s); const scores = new Map<T, number>(); for (let i = 0; i < terms.length; i++) { const term = filter(terms[i]); if (term !== "") { const expandedTerms = expandTerm(index, term); const visitedDocuments = new Set<T>(); for (let j = 0; j < expandedTerms.length; j++) { const eTerm = expandedTerms[j]; const expansionBoost = eTerm === term ? 1 : log(1 + (1 / (1 + eTerm.length - term.length))); const termNode = findInvertedIndexNode(root, eTerm); let d; if (termNode !== void 0 && (d = termNode.d) !== null) { let documentFrequency = 0; for (let k = 0; k < d.length; k++) { const pointer = d[k]; if (pointer.details.removed === false) { documentFrequency++; } } if (documentFrequency > 0) { // calculating BM25 idf const idf = log(1 + (docs.size - documentFrequency + 0.5) / (documentFrequency + 0.5)); for (let k = 0; k < d.length; k++) { const pointer = d[k]; if (pointer.details.removed === false) { let score = 0; for (let x = 0; x < pointer.details.fCount.length; x++) { let tf = pointer.tf[x]; if (tf > 0) { // calculating BM25 tf const fieldLength = pointer.details.fCount[x]; const avgFieldLength = fAvg[x]; tf = ((bm25k1 + 1) * tf) / (bm25k1 * ((1 - bm25b) + bm25b * (fieldLength / avgFieldLength)) + tf); score += tf * idf * fieldBoost[x] * expansionBoost; } } if (score > 0) { const key = pointer.details.key; const prevScore = scores.get(key); scores.set( key, prevScore !== void 0 && visitedDocuments.has(key) ? max(prevScore, score) : prevScore === void 0 ? score : prevScore + score ); visitedDocuments.add(key); } } } } } } } } const result = [] as QueryResult<T>[]; scores.forEach((score, key) => { result.push({ key, score }); }); result.sort(sortByScore); return result; }; /** * Recursively goes through inverted index nodes and expands term with all possible combinations. * * @typeparam I Document ID type. * @param index {@link DocumentIndex} * @param results Results. * @param term Term. */ const _expandTerm = <I>( node: InvertedIndexNode<I>, results: string[], term: string, ): void => { if (node.d !== null) { results.push(term); } const children = node.c; if (children !== null) { for (let i = 0; i < children.length; i++) { const c = children[i]; _expandTerm(c, results, term + fromCharCode(c.k)); } } }; /** * Expands term with all possible combinations. * * @typeparam I Document ID type. * @param index {@link DocumentIndex} * @param term Term. * @returns All terms that starts with [term] string. */ export const expandTerm = <I>( index: DocumentIndex<I>, term: string, ): string[] => { const node = findInvertedIndexNode(index.root, term); const results = [] as string[]; if (node !== void 0) { _expandTerm(node, results, term); } return results; }