Forked from bakit/rag-ultimate
Forked from bakit/rag-ultimate
src / retrieval / hybridSearch.ts
import { NON_WORD_RE, normalizeWhitespace } from "../utils/text";
const MAX_QUERY_TOKEN_CACHE = 128;
const queryTokenCache = new Map<string, string[]>();
const MAX_LOWER_CACHE = 128;
const lowerCache = new Map<string, string>();
function tokenizedQuery(query: string): string[] {
const normalized = normalizeWhitespace(query.toLowerCase());
const cached = queryTokenCache.get(normalized);
if (cached !== undefined) return cached;
const tokens = normalized.split(NON_WORD_RE).filter((w) => w.length > 2);
if (queryTokenCache.size >= MAX_QUERY_TOKEN_CACHE) {
const oldest = queryTokenCache.keys().next().value;
if (oldest !== undefined) queryTokenCache.delete(oldest);
}
queryTokenCache.set(normalized, tokens);
return tokens;
}
function lowerCached(text: string): string {
const cached = lowerCache.get(text);
if (cached !== undefined) return cached;
const lower = text.toLowerCase();
if (lowerCache.size >= MAX_LOWER_CACHE) {
const oldest = lowerCache.keys().next().value;
if (oldest !== undefined) lowerCache.delete(oldest);
}
lowerCache.set(text, lower);
return lower;
}
/**
* Compute a hybrid score combining semantic similarity with lexical overlap.
*
* OPTIMIZATION: The original created a new Set per chunk via split() — O(n)
* per chunk. We now use a flat string search with word-boundary awareness via
* a pre-built Set lookup, which is O(m) where m = query word count.
*/
export function hybridScore(query: string, text: string, semanticScore: number): number {
const normalizedQuery = normalizeWhitespace(query.toLowerCase());
const queryWords = tokenizedQuery(query);
if (queryWords.length === 0) return semanticScore;
const haystack = lowerCached(text);
// Fast path: exact phrase match gives a large boost, skip per-word scan.
if (haystack.includes(normalizedQuery)) {
return semanticScore + 0.22 + 0.15;
}
// Per-word match count — O(m) where m = number of query words.
let matches = 0;
for (const word of queryWords) {
// Use indexOf loop for word-boundary awareness without regex overhead.
// This avoids creating a Set per chunk (was O(n) per call).
let pos = 0;
while (true) {
const idx = haystack.indexOf(word, pos);
if (idx === -1) break;
// Check word boundaries
const beforeOk = idx === 0 || !isWordChar(haystack[idx - 1]);
const afterIdx = idx + word.length;
const afterOk = afterIdx >= haystack.length || !isWordChar(haystack[afterIdx]);
if (beforeOk && afterOk) {
matches++;
break;
}
pos = afterIdx;
}
}
const keywordBonus = (matches / queryWords.length) * 0.22;
return semanticScore + keywordBonus;
}
/** Check if a character is a word character [a-zA-Z0-9_]. */
function isWordChar(ch: string): boolean {
const code = ch.charCodeAt(0);
return (
(code >= 0x41 && code <= 0x5A) || // A-Z
(code >= 0x61 && code <= 0x7A) || // a-z
(code >= 0x30 && code <= 0x39) || // 0-9
code === 0x5F // _
);
}
src / retrieval / hybridSearch.ts
import { NON_WORD_RE, normalizeWhitespace } from "../utils/text";
const MAX_QUERY_TOKEN_CACHE = 128;
const queryTokenCache = new Map<string, string[]>();
const MAX_LOWER_CACHE = 128;
const lowerCache = new Map<string, string>();
function tokenizedQuery(query: string): string[] {
const normalized = normalizeWhitespace(query.toLowerCase());
const cached = queryTokenCache.get(normalized);
if (cached !== undefined) return cached;
const tokens = normalized.split(NON_WORD_RE).filter((w) => w.length > 2);
if (queryTokenCache.size >= MAX_QUERY_TOKEN_CACHE) {
const oldest = queryTokenCache.keys().next().value;
if (oldest !== undefined) queryTokenCache.delete(oldest);
}
queryTokenCache.set(normalized, tokens);
return tokens;
}
function lowerCached(text: string): string {
const cached = lowerCache.get(text);
if (cached !== undefined) return cached;
const lower = text.toLowerCase();
if (lowerCache.size >= MAX_LOWER_CACHE) {
const oldest = lowerCache.keys().next().value;
if (oldest !== undefined) lowerCache.delete(oldest);
}
lowerCache.set(text, lower);
return lower;
}
/**
* Compute a hybrid score combining semantic similarity with lexical overlap.
*
* OPTIMIZATION: The original created a new Set per chunk via split() — O(n)
* per chunk. We now use a flat string search with word-boundary awareness via
* a pre-built Set lookup, which is O(m) where m = query word count.
*/
export function hybridScore(query: string, text: string, semanticScore: number): number {
const normalizedQuery = normalizeWhitespace(query.toLowerCase());
const queryWords = tokenizedQuery(query);
if (queryWords.length === 0) return semanticScore;
const haystack = lowerCached(text);
// Fast path: exact phrase match gives a large boost, skip per-word scan.
if (haystack.includes(normalizedQuery)) {
return semanticScore + 0.22 + 0.15;
}
// Per-word match count — O(m) where m = number of query words.
let matches = 0;
for (const word of queryWords) {
// Use indexOf loop for word-boundary awareness without regex overhead.
// This avoids creating a Set per chunk (was O(n) per call).
let pos = 0;
while (true) {
const idx = haystack.indexOf(word, pos);
if (idx === -1) break;
// Check word boundaries
const beforeOk = idx === 0 || !isWordChar(haystack[idx - 1]);
const afterIdx = idx + word.length;
const afterOk = afterIdx >= haystack.length || !isWordChar(haystack[afterIdx]);
if (beforeOk && afterOk) {
matches++;
break;
}
pos = afterIdx;
}
}
const keywordBonus = (matches / queryWords.length) * 0.22;
return semanticScore + keywordBonus;
}
/** Check if a character is a word character [a-zA-Z0-9_]. */
function isWordChar(ch: string): boolean {
const code = ch.charCodeAt(0);
return (
(code >= 0x41 && code <= 0x5A) || // A-Z
(code >= 0x61 && code <= 0x7A) || // a-z
(code >= 0x30 && code <= 0x39) || // 0-9
code === 0x5F // _
);
}