

























@@ -71,6 +71,7 @@ const FTS_TABLE = "chunks_fts";
7171const EMBEDDING_CACHE_TABLE = "embedding_cache";
7272const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
7373export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000;
74+const KEYWORD_FALLBACK_SEARCH_TERM_LIMIT = 6;
7475const log = createSubsystemLogger("memory");
7576type MemoryIndexManagerPurpose = "default" | "status" | "cli";
7677type MemoryEmbeddingProviderRequirement = {
@@ -88,6 +89,8 @@ type EmbeddingProbeCacheEntry = {
8889expireAtMs: number;
8990};
909192+type KeywordSearchHit = MemorySearchResult & { id: string; textScore: number };
93+9194const EMBEDDING_PROBE_CACHE = new Map<string, EmbeddingProbeCacheEntry>();
92959396export async function closeAllMemoryIndexManagers(): Promise<void> {
@@ -689,7 +692,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
689692return [];
690693}
691694692-const fullQueryResults = await this.searchKeyword(
695+const keywordResults = await this.searchKeywordWithFallback(
693696cleaned,
694697candidates,
695698{
@@ -700,47 +703,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
700703log.warn(`memory search: FTS keyword query failed: ${formatErrorMessage(err)}`);
701704return [];
702705});
703-const resultSets =
704-fullQueryResults.length > 0
705- ? [fullQueryResults]
706- : await Promise.all(
707-// Fallback: broaden recall for conversational queries when the
708-// exact AND query is too strict to return any results.
709-(() => {
710-const keywords = extractKeywords(cleaned, {
711-ftsTokenizer: this.settings.store.fts.tokenizer,
712-});
713-const searchTerms = keywords.length > 0 ? keywords : [cleaned];
714-return searchTerms.map((term) =>
715-this.searchKeyword(
716-term,
717-candidates,
718-{ boostFallbackRanking: true },
719-sourceFilterList,
720-).catch((err: unknown) => {
721-log.warn(
722-`memory search: FTS per-keyword query failed for "${term}": ${formatErrorMessage(err)}`,
723-);
724-return [];
725-}),
726-);
727-})(),
728-);
729-730-// Merge and deduplicate results, keeping highest score for each chunk
731-const seenIds = new Map<string, (typeof resultSets)[0][0]>();
732-for (const results of resultSets) {
733-for (const result of results) {
734-const existing = seenIds.get(result.id);
735-if (!existing || result.score > existing.score) {
736-seenIds.set(result.id, result);
737-}
738-}
739-}
740706741-const merged = [...seenIds.values()];
742707const decayed = await applyTemporalDecayToHybridResults({
743-results: merged,
708+results: keywordResults,
744709temporalDecay: hybrid.temporalDecay,
745710workspaceDir: this.workspaceDir,
746711});
@@ -751,7 +716,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
751716// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
752717const loadKeywordResults = async () =>
753718hybrid.enabled && this.fts.enabled && this.fts.available
754- ? await this.searchKeyword(
719+ ? await this.searchKeywordWithFallback(
755720cleaned,
756721candidates,
757722{ boostFallbackRanking: true },
@@ -824,8 +789,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
824789}
825790826791// Hybrid defaults can produce keyword-only matches below minScore after
827-// weighting. If strict vector+keyword results are empty, preserve the FTS
828-// matches; FTS already established lexical relevance.
792+// BM25 normalization and textWeight scaling. Preserve FTS-backed lexical
793+// hits when they are the only relevant results.
829794const relaxedMinScore = 0;
830795const keywordKeys = new Set(
831796keywordResults.map(
@@ -910,7 +875,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
910875limit: number,
911876options?: { boostFallbackRanking?: boolean },
912877sourceFilterList?: MemorySource[],
913-): Promise<Array<MemorySearchResult & { id: string; textScore: number }>> {
878+): Promise<KeywordSearchHit[]> {
914879if (!this.fts.enabled || !this.fts.available) {
915880return [];
916881}
@@ -927,7 +892,63 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
927892 bm25RankToScore,
928893boostFallbackRanking: options?.boostFallbackRanking,
929894});
930-return results.map((entry) => entry as MemorySearchResult & { id: string; textScore: number });
895+return results.map((entry) => entry as KeywordSearchHit);
896+}
897+898+private async searchKeywordWithFallback(
899+query: string,
900+limit: number,
901+options: { boostFallbackRanking?: boolean } | undefined,
902+sourceFilterList: MemorySource[],
903+): Promise<KeywordSearchHit[]> {
904+const fullQueryResults = await this.searchKeyword(
905+query,
906+limit,
907+options,
908+sourceFilterList,
909+).catch(() => []);
910+if (fullQueryResults.length > 0) {
911+return fullQueryResults;
912+}
913+914+// Broaden recall for conversational queries when the exact AND query is too
915+// strict, but cap the number of extra FTS probes so long prompts cannot fan
916+// out into unbounded sqlite work.
917+const fallbackTerms = this.resolveKeywordFallbackTerms(query);
918+if (fallbackTerms.length === 0) {
919+return [];
920+}
921+922+const resultSets = await Promise.all(
923+fallbackTerms.map((term) =>
924+this.searchKeyword(term, limit, options, sourceFilterList).catch(() => []),
925+),
926+);
927+return this.mergeKeywordSearchHits(resultSets);
928+}
929+930+private resolveKeywordFallbackTerms(query: string): string[] {
931+const keywords = extractKeywords(query, {
932+ftsTokenizer: this.settings.store.fts.tokenizer,
933+}).filter((term) => term !== query);
934+return keywords.slice(0, KEYWORD_FALLBACK_SEARCH_TERM_LIMIT);
935+}
936+937+private mergeKeywordSearchHits(resultSets: KeywordSearchHit[][]): KeywordSearchHit[] {
938+const seenIds = new Map<string, KeywordSearchHit>();
939+for (const results of resultSets) {
940+for (const result of results) {
941+const existing = seenIds.get(result.id);
942+if (
943+!existing ||
944+result.textScore > existing.textScore ||
945+(result.textScore === existing.textScore && result.score > existing.score)
946+) {
947+seenIds.set(result.id, result);
948+}
949+}
950+}
951+return [...seenIds.values()].toSorted((a, b) => b.score - a.score);
931952}
932953933954private mergeHybridResults(params: {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。