Skip to content

Commit f957ad7

Browse files
authored
Merge 9ece62d into 81e5320
2 parents 81e5320 + 9ece62d commit f957ad7

2 files changed

Lines changed: 80 additions & 11 deletions

File tree

extensions/memory-core/src/memory/hybrid.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export function bm25RankToScore(rank: number): number {
4949
return 1 / (1 + rank);
5050
}
5151

52+
/**
53+
* Check if two line ranges overlap.
54+
*/
55+
function linesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
56+
return aStart <= bEnd && bStart <= aEnd;
57+
}
58+
5259
export async function mergeHybridResults(params: {
5360
vector: HybridVectorResult[];
5461
keyword: HybridKeywordResult[];
@@ -87,6 +94,9 @@ export async function mergeHybridResults(params: {
8794
}
8895
>();
8996

97+
// Secondary index: group vector results by path for fallback matching
98+
const vectorByPath = new Map<string, HybridVectorResult[]>();
99+
90100
for (const r of params.vector) {
91101
byId.set(r.id, {
92102
id: r.id,
@@ -98,26 +108,64 @@ export async function mergeHybridResults(params: {
98108
vectorScore: r.vectorScore,
99109
textScore: 0,
100110
});
111+
const existing = vectorByPath.get(r.path);
112+
if (existing) {
113+
existing.push(r);
114+
} else {
115+
vectorByPath.set(r.path, [r]);
116+
}
101117
}
102118

103119
for (const r of params.keyword) {
104120
const existing = byId.get(r.id);
105121
if (existing) {
122+
// Exact chunk ID match — update textScore directly
106123
existing.textScore = r.textScore;
107124
if (r.snippet && r.snippet.length > 0) {
108125
existing.snippet = r.snippet;
109126
}
110127
} else {
111-
byId.set(r.id, {
112-
id: r.id,
113-
path: r.path,
114-
startLine: r.startLine,
115-
endLine: r.endLine,
116-
source: r.source,
117-
snippet: r.snippet,
118-
vectorScore: 0,
119-
textScore: r.textScore,
120-
});
128+
// No chunk ID match — try path + line range overlap as fallback
129+
// Pick the overlapping vector chunk with the highest vectorScore
130+
const candidates = vectorByPath.get(r.path);
131+
let merged = false;
132+
if (candidates) {
133+
let best: HybridVectorResult | undefined;
134+
for (const v of candidates) {
135+
if (linesOverlap(r.startLine, r.endLine, v.startLine, v.endLine)) {
136+
if (!best || v.vectorScore > best.vectorScore) {
137+
best = v;
138+
}
139+
}
140+
}
141+
if (best) {
142+
const entry = byId.get(best.id)!;
143+
entry.textScore = Math.max(entry.textScore, r.textScore);
144+
if (r.startLine < entry.startLine) {
145+
entry.startLine = r.startLine;
146+
}
147+
if (r.endLine > entry.endLine) {
148+
entry.endLine = r.endLine;
149+
}
150+
if (r.snippet && r.snippet.length > 0) {
151+
entry.snippet = r.snippet;
152+
}
153+
merged = true;
154+
}
155+
}
156+
if (!merged) {
157+
// No overlap found — add as keyword-only entry
158+
byId.set(r.id, {
159+
id: r.id,
160+
path: r.path,
161+
startLine: r.startLine,
162+
endLine: r.endLine,
163+
source: r.source,
164+
snippet: r.snippet,
165+
vectorScore: 0,
166+
textScore: r.textScore,
167+
});
168+
}
121169
}
122170
}
123171

extensions/memory-core/src/memory/manager-search.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ function planKeywordSearch(params: {
123123
const matchTerms: string[] = [];
124124
const substringTerms: string[] = [];
125125
for (const token of tokens) {
126-
if (SHORT_CJK_TRIGRAM_RE.test(token) && Array.from(token).length < 3) {
126+
if (SHORT_CJK_TRIGRAM_RE.test(token)) {
127127
substringTerms.push(token);
128128
continue;
129129
}
@@ -380,6 +380,27 @@ export async function searchKeyword(params: {
380380
params.limit,
381381
) as typeof rows;
382382
usedMatch = true;
383+
// If MATCH returned 0 rows, fall back to LIKE for better recall
384+
if (rows.length === 0) {
385+
const queryTokens = normalizeStringEntries(params.query.match(FTS_QUERY_TOKEN_RE) ?? []);
386+
const allTerms = uniqueStrings([...queryTokens, ...plan.substringTerms]);
387+
const fbLike = allTerms.map(() => " OR text LIKE ? ESCAPE '\\'").join("");
388+
const fbParams = allTerms.map((term) => `%${escapeLikePattern(term)}%`);
389+
rows = params.db
390+
.prepare(
391+
`SELECT id, path, source, start_line, end_line, text,
392+
` +
393+
` 0 AS rank
394+
` +
395+
` FROM ${params.ftsTable}
396+
` +
397+
` WHERE 1=0${fbLike}${liveChunkClause}${params.sourceFilter.sql}
398+
` +
399+
` LIMIT ?`,
400+
)
401+
.all(...fbParams, ...params.sourceFilter.params, params.limit) as typeof rows;
402+
usedMatch = false;
403+
}
383404
} catch (matchErr) {
384405
// FTS5 MATCH can fail on certain token patterns depending on the
385406
// Node.js sqlite runtime and tokenizer (e.g. unicode61 vs trigram).

0 commit comments

Comments
 (0)