/** * Ranking retrieved chunks. * * **Pure vector search is not good enough for code, and this is the whole reason * the module exists.** An embedding of `getUserById` sits close to every other * lookup function in the repository, so a query naming it returns ten plausible * lookups and buries the actual definition. A keyword match has the opposite * failure: it finds the definition instantly and misses the query that describes * behaviour without naming it. * * The two are combined by **reciprocal rank fusion** rather than by adding * normalised scores. Cosine similarity and a term-frequency score live on * different scales, and the usual fix — min-max normalising each into [0,1] — * is actively harmful here: when every candidate is unrelated, the similarities * are tightly clustered, and stretching that cluster across the full range turns * noise into a confident-looking ranking. Rank fusion is scale-free, so it * cannot make that mistake, and a chunk found by only one of the two methods * still surfaces. */ /** RRF damping. 60 is the value from the original TREC work; larger flattens. */ const RRF_K = 60 /** Terms shorter than this carry no signal in code and match everywhere. */ const MIN_TERM_LENGTH = 2 /** Words that appear in almost every source file and therefore rank nothing. */ const STOPWORDS = new Set([ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'then', 'const', 'let', 'var', 'function', 'return', 'import', 'export', 'class', 'def', 'self', 'new', 'use', 'are', 'was', 'were', 'not', 'but', 'you', 'how', 'does', 'what', 'where', 'which', 'who', 'why', 'can', 'should', 'would', ]) /** Lowercased haystacks, keyed by the matrix object they describe. */ const haystacks = new WeakMap() /** * Split an identifier into its words. * * `getUserById` must match the query terms `get`, `user`, `by` and `id`, because * a caller writing a search phrase naturally writes them separately. Without * this, the single most useful lexical signal in a codebase — a name spelled in * camelCase — is invisible. * * @param {string} identifier * @returns {string[]} lowercased words */ export function splitIdentifier(identifier) { return identifier .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .split(/[^A-Za-z0-9]+/) .filter((word) => word !== '') } /** * The search terms of a query. * * Both the raw tokens and the sub-words of each identifier are kept, so a query * of `getUserById` matches a chunk that spells it `get_user_by_id` as well as * one that spells it identically. * * @param {string} query * @returns {string[]} de-duplicated terms, longest first */ export function queryTerms(query) { const found = new Set() for (const raw of String(query).split(/[^A-Za-z0-9_$]+/)) { if (raw === '') continue const lower = raw.toLowerCase() if (lower.length >= MIN_TERM_LENGTH && !STOPWORDS.has(lower)) found.add(lower) if (raw.length >= MIN_TERM_LENGTH) { for (const word of splitIdentifier(raw)) { const part = word.toLowerCase() if (part.length >= MIN_TERM_LENGTH && !STOPWORDS.has(part)) found.add(part) } } } return [...found].sort((left, right) => right.length - left.length) } /** The lowercased text a chunk is matched against, built once per matrix. */ function haystackFor(matrix, row) { let list = haystacks.get(matrix) if (list === undefined) { list = new Array(matrix.rows.length) haystacks.set(matrix, list) } if (list[row.id] === undefined) { const symbol = row.symbol === undefined ? '' : ` ${row.symbol}` list[row.id] = `${row.path}${symbol} ${row.text}`.toLowerCase() } return list[row.id] } /** * Score every chunk against the query terms. * * Matches in the path or the symbol are weighted above matches in the body, * because a question about behaviour usually names the thing, and the name is * what the path and symbol carry. * * @param {object} matrix * @param {string[]} terms * @returns {{lexical: Float64Array, matched: Uint8Array}} raw scores and a hit flag */ export function lexicalScores(matrix, terms) { const count = matrix.rows.length const lexical = new Float64Array(count) const matched = new Uint8Array(count) if (terms.length === 0) return { lexical, matched } // Document frequency per term, for the inverse-document-frequency weight. const frequency = new Map() for (const term of terms) { let seen = 0 for (let at = 0; at < count; at += 1) { if (haystackFor(matrix, matrix.rows[at]).includes(term)) seen += 1 } frequency.set(term, seen) } for (let at = 0; at < count; at += 1) { const row = matrix.rows[at] const hay = haystackFor(matrix, row) const header = `${row.path} ${row.symbol ?? ''}`.toLowerCase() let score = 0 for (const term of terms) { const df = frequency.get(term) ?? 0 if (df === 0) continue // Standard IDF, damped so a term present in every file still contributes. const idf = Math.log(1 + count / (1 + df)) let occurrences = 0 let from = hay.indexOf(term) while (from !== -1) { occurrences += 1 from = hay.indexOf(term, from + term.length) } if (occurrences === 0) continue const weight = header.includes(term) ? 3 : 1 score += idf * (1 + Math.log(occurrences)) * weight } if (score > 0) { lexical[at] = score matched[at] = 1 } } return { lexical, matched } } /** Indices ordered by score, descending, ties broken by index for determinism. */ function orderBy(score) { const indices = Array.from({ length: score.length }, (_, at) => at) indices.sort((left, right) => score[right] - score[left] || left - right) return indices } /** * The excerpt a result shows. * * Head-truncated rather than tail-truncated: a function's signature, its * parameters and its first statements are what identify it, while the tail of a * long function is usually a closing brace and a return. * * @param {string} text chunk text * @param {number} maxLines cap * @returns {{excerpt: string, truncated: boolean}} */ export function excerptOf(text, maxLines) { const lines = text.split('\n') if (lines.length <= maxLines) return { excerpt: text, truncated: false } return { excerpt: lines.slice(0, maxLines).join('\n'), truncated: true } } /** * The fused order of every chunk for one query, before selection. * * Split out from ranking so several queries can be ranked against the same rows * and merged: query expansion produces a handful of phrasings, each gets an * order, and the orders are fused. Selecting results is a separate step because * it is the only part that cares about limits and budgets. * * @param {object} input * @param {object} input.matrix vector matrix and rows * @param {Float32Array} input.queryVector unit query vector * @param {string} input.query raw query text * @param {number} [input.vectorWeight] fusion weight for the vector ranking * @param {number} [input.lexicalWeight] fusion weight for the keyword ranking * @returns {{order: number[], similarity: Float64Array, vectorBest: number, lexicalBest: number}} */ export function rankOrder(input) { const { matrix, queryVector, query, vectorWeight = 0.7, lexicalWeight = 0.3 } = input const count = matrix.rows.length if (count === 0) { return { order: [], similarity: new Float64Array(0), vectorBest: 0, lexicalBest: 0 } } const dimensions = matrix.dimensions const similarity = new Float64Array(count) for (let at = 0; at < count; at += 1) { let sum = 0 const offset = at * dimensions for (let dim = 0; dim < dimensions; dim += 1) sum += matrix.vectors[offset + dim] * queryVector[dim] similarity[at] = sum } const terms = queryTerms(query) const { lexical } = lexicalScores(matrix, terms) const vectorOrder = orderBy(similarity) const lexicalOrder = orderBy(lexical) const vectorRank = new Int32Array(count).fill(count) const lexicalRank = new Int32Array(count).fill(count) for (let rank = 0; rank < count; rank += 1) vectorRank[vectorOrder[rank]] = rank for (let rank = 0; rank < count; rank += 1) lexicalRank[lexicalOrder[rank]] = rank const fused = new Float64Array(count) for (let at = 0; at < count; at += 1) { fused[at] = vectorWeight / (RRF_K + vectorRank[at]) + lexicalWeight / (RRF_K + lexicalRank[at]) } return { order: orderBy(fused), similarity, vectorBest: Number(similarity[vectorOrder[0]]?.toFixed(4) ?? 0), lexicalBest: Number(lexical[lexicalOrder[0]]?.toFixed(4) ?? 0), } } /** * Merge several rankings of the same rows into one order, by rank. * * Used for query expansion: each alternative phrasing ranks every chunk, and a * chunk that several phrasings agree on should outrank one that a single * phrasing happened to like. Rank fusion again rather than adding scores, * because the rankings come from different query vectors and their similarities * are not on a comparable scale. * * A chunk missing from a ranking is simply absent from that ranking's * contribution rather than penalised, so a phrasing that finds nothing does not * drag anything down. * * @param {readonly (readonly number[])[]} rankings row indices, each in rank order * @returns {number[]} row indices, best first */ export function fuseRankings(rankings) { const present = rankings.filter((ranking) => Array.isArray(ranking) && ranking.length > 0) if (present.length === 0) return [] if (present.length === 1) return [...present[0]] const scores = new Map() for (const ranking of present) { for (let rank = 0; rank < ranking.length; rank += 1) { const row = ranking[rank] scores.set(row, (scores.get(row) ?? 0) + 1 / (RRF_K + rank)) } } // Ties break by row index, so the same input always gives the same output. return [...scores.keys()].sort((left, right) => scores.get(right) - scores.get(left) || left - right) } /** * Turn a fused order into the results a caller sees. * * @param {number[]} order row indices, best first * @param {object} input * @param {object} input.matrix * @param {Float64Array} input.similarity per-row similarity, for reporting * @param {number} [input.limit] * @param {number} [input.perFile] * @param {number} [input.excerptLines] * @param {number} [input.totalLines] * @returns {object[]} results */ export function selectFrom(order, input) { const { matrix, similarity, limit = 8, perFile = 2, excerptLines = 80, totalLines = 400 } = input const perFileUsed = new Map() const results = [] // A shared line budget across the whole result set. The per-excerpt cap alone // does not bound the response — `limit` does — and a chunk is already a // semantic unit, so cutting one in half is how a search turns into a // follow-up read that costs more than the lines it saved. Allocation is by // rank, so the best answer gets the room. let remaining = totalLines for (const at of order) { if (results.length >= limit) break // Always return at least one result, however small the budget. if (remaining <= 0 && results.length > 0) break const row = matrix.rows[at] if (row === undefined) continue const used = perFileUsed.get(row.path) ?? 0 if (used >= perFile) continue perFileUsed.set(row.path, used + 1) const allowance = Math.max(1, Math.min(excerptLines, remaining)) const { excerpt, truncated } = excerptOf(row.text, allowance) remaining -= excerpt.split('\n').length results.push({ path: row.path, startLine: row.startLine, endLine: row.endLine, ...(row.symbol === undefined ? {} : { symbol: row.symbol }), similarity: Number(similarity[at].toFixed(4)), excerpt, truncated, }) } return results } /** * Rank chunks for a query. * * @param {object} input * @param {object} input.matrix vector matrix and rows * @param {Float32Array} input.queryVector unit query vector * @param {string} input.query raw query text * @param {number} [input.limit] results to return * @param {number} [input.perFile] most chunks one file may contribute * @param {number} [input.excerptLines] lines per excerpt * @param {number} [input.totalLines] total line budget * @param {number} [input.vectorWeight] fusion weight for the vector ranking * @param {number} [input.lexicalWeight] fusion weight for the keyword ranking * @returns {{results: object[], vectorBest: number, lexicalBest: number}} */ export function rankChunks(input) { const ranked = rankOrder(input) const results = selectFrom(ranked.order, { matrix: input.matrix, similarity: ranked.similarity, limit: input.limit, perFile: input.perFile, excerptLines: input.excerptLines, totalLines: input.totalLines, }) return { results, vectorBest: ranked.vectorBest, lexicalBest: ranked.lexicalBest } }