mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 12:34:27 +00:00
280 lines
9.0 KiB
TypeScript
280 lines
9.0 KiB
TypeScript
/**
|
|
* conversation_search tool: Agent-callable tool for searching L0 conversation records.
|
|
*
|
|
* Supports three search strategies with automatic degradation:
|
|
* 1. **hybrid** (default) — FTS5 keyword + vector embedding in parallel,
|
|
* merged via Reciprocal Rank Fusion (RRF).
|
|
* 2. **embedding** — pure vector similarity (when FTS5 is unavailable).
|
|
* 3. **fts** — pure FTS5 keyword search (when embedding is unavailable).
|
|
*
|
|
* The tool is registered via `api.registerTool()` in index.ts.
|
|
*/
|
|
|
|
import type { IMemoryStore, L0SearchResult } from "../store/types.js";
|
|
import { buildFtsQuery } from "../store/sqlite.js";
|
|
import type { EmbeddingService } from "../store/embedding.js";
|
|
|
|
// ============================
|
|
// Types
|
|
// ============================
|
|
|
|
interface Logger {
|
|
debug?: (message: string) => void;
|
|
info: (message: string) => void;
|
|
warn: (message: string) => void;
|
|
error: (message: string) => void;
|
|
}
|
|
|
|
export interface ConversationSearchResultItem {
|
|
id: string;
|
|
session_key: string;
|
|
/** Role of the message sender: "user" or "assistant" */
|
|
role: string;
|
|
/** Text content of this single message */
|
|
content: string;
|
|
score: number;
|
|
recorded_at: string;
|
|
}
|
|
|
|
export interface ConversationSearchResult {
|
|
results: ConversationSearchResultItem[];
|
|
total: number;
|
|
/** Actual search strategy used: "hybrid", "embedding", "fts", or "none". */
|
|
strategy: string;
|
|
/** Optional message, e.g. when embedding is not configured. */
|
|
message?: string;
|
|
}
|
|
|
|
const TAG = "[memory-tdai][tdai_conversation_search]";
|
|
|
|
// ============================
|
|
// RRF (Reciprocal Rank Fusion)
|
|
// ============================
|
|
|
|
/** Standard RRF constant from the original RRF paper. */
|
|
const RRF_K = 60;
|
|
|
|
/**
|
|
* Merge multiple ranked lists of `ConversationSearchResultItem` via Reciprocal
|
|
* Rank Fusion. Items appearing in multiple lists get their RRF scores summed.
|
|
*
|
|
* Returns items sorted by descending RRF score. The `score` field of each
|
|
* returned item is replaced by the RRF score for consistent ranking semantics.
|
|
*/
|
|
function rrfMergeL0(...lists: ConversationSearchResultItem[][]): ConversationSearchResultItem[] {
|
|
const map = new Map<string, { item: ConversationSearchResultItem; rrfScore: number }>();
|
|
|
|
for (const list of lists) {
|
|
for (let rank = 0; rank < list.length; rank++) {
|
|
const item = list[rank];
|
|
const score = 1 / (RRF_K + rank + 1);
|
|
const existing = map.get(item.id);
|
|
if (existing) {
|
|
existing.rrfScore += score;
|
|
} else {
|
|
map.set(item.id, { item, rrfScore: score });
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...map.values()]
|
|
.sort((a, b) => b.rrfScore - a.rrfScore)
|
|
.map(({ item, rrfScore }) => ({ ...item, score: rrfScore }));
|
|
}
|
|
|
|
// ============================
|
|
// Search implementation
|
|
// ============================
|
|
|
|
export async function executeConversationSearch(params: {
|
|
query: string;
|
|
limit: number;
|
|
sessionKey?: string;
|
|
vectorStore?: IMemoryStore;
|
|
embeddingService?: EmbeddingService;
|
|
logger?: Logger;
|
|
}): Promise<ConversationSearchResult> {
|
|
const {
|
|
query,
|
|
limit,
|
|
sessionKey: sessionFilter,
|
|
vectorStore,
|
|
embeddingService,
|
|
logger,
|
|
} = params;
|
|
|
|
logger?.debug?.(
|
|
`${TAG} CALLED: query="${query.slice(0, 100)}", limit=${limit}, ` +
|
|
`sessionFilter=${sessionFilter ?? "(none)"}, ` +
|
|
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
|
|
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`,
|
|
);
|
|
|
|
if (!query || query.trim().length === 0) {
|
|
logger?.debug?.(`${TAG} Empty query, returning empty`);
|
|
return { results: [], total: 0, strategy: "none" };
|
|
}
|
|
|
|
if (!vectorStore) {
|
|
logger?.warn?.(`${TAG} VectorStore not available`);
|
|
return { results: [], total: 0, strategy: "none" };
|
|
}
|
|
|
|
// ── Determine available capabilities ──
|
|
const hasEmbedding = !!embeddingService;
|
|
const hasFts = vectorStore.isFtsAvailable();
|
|
|
|
if (!hasEmbedding && !hasFts) {
|
|
logger?.warn?.(`${TAG} Neither EmbeddingService nor FTS5 available — cannot search`);
|
|
return {
|
|
results: [],
|
|
total: 0,
|
|
strategy: "none",
|
|
message:
|
|
"Embedding service is not configured and FTS is not available. " +
|
|
"Conversation search requires an embedding provider or FTS5 support. " +
|
|
"Please configure an embedding provider in the embedding.provider setting (e.g. openai_compatible).",
|
|
};
|
|
}
|
|
|
|
// ── Over-retrieve for later filtering and RRF merging ──
|
|
const candidateK = sessionFilter ? limit * 4 : limit * 3;
|
|
|
|
// ── Run available search strategies in parallel ──
|
|
const [ftsItems, vecItems] = await Promise.all([
|
|
// FTS5 keyword search on L0
|
|
(async (): Promise<ConversationSearchResultItem[]> => {
|
|
if (!hasFts) return [];
|
|
try {
|
|
const ftsQuery = buildFtsQuery(query);
|
|
if (!ftsQuery) {
|
|
logger?.debug?.(`${TAG} [hybrid-fts] No usable FTS tokens from query`);
|
|
return [];
|
|
}
|
|
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
|
|
const ftsResults = await vectorStore.searchL0Fts(ftsQuery, candidateK);
|
|
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
|
|
return ftsResults.map((r) => ({
|
|
id: r.record_id,
|
|
session_key: r.session_key,
|
|
role: r.role,
|
|
content: r.message_text,
|
|
score: r.score,
|
|
recorded_at: r.recorded_at,
|
|
}));
|
|
} catch (err) {
|
|
logger?.warn?.(
|
|
`${TAG} [hybrid-fts] FTS5 search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
return [];
|
|
}
|
|
})(),
|
|
|
|
// Vector embedding search on L0
|
|
(async (): Promise<ConversationSearchResultItem[]> => {
|
|
if (!hasEmbedding) return [];
|
|
try {
|
|
logger?.debug?.(`${TAG} [hybrid-vec] Generating query embedding...`);
|
|
const queryEmbedding = await embeddingService!.embed(query);
|
|
logger?.debug?.(
|
|
`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`,
|
|
);
|
|
const vecResults: L0SearchResult[] = await vectorStore.searchL0Vector(queryEmbedding, candidateK, query);
|
|
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
|
|
return vecResults.map((r) => ({
|
|
id: r.record_id,
|
|
session_key: r.session_key,
|
|
role: r.role,
|
|
content: r.message_text,
|
|
score: r.score,
|
|
recorded_at: r.recorded_at,
|
|
}));
|
|
} catch (err) {
|
|
logger?.warn?.(
|
|
`${TAG} [hybrid-vec] Embedding search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
return [];
|
|
}
|
|
})(),
|
|
]);
|
|
|
|
// ── Determine effective strategy ──
|
|
const ftsOk = ftsItems.length > 0;
|
|
const vecOk = vecItems.length > 0;
|
|
let strategy: string;
|
|
|
|
if (ftsOk && vecOk) {
|
|
strategy = "hybrid";
|
|
} else if (vecOk) {
|
|
strategy = "embedding";
|
|
} else if (ftsOk) {
|
|
strategy = "fts";
|
|
} else {
|
|
logger?.debug?.(`${TAG} Both search paths returned 0 results`);
|
|
return { results: [], total: 0, strategy: hasEmbedding ? "embedding" : "fts" };
|
|
}
|
|
|
|
// ── Merge results ──
|
|
let results: ConversationSearchResultItem[];
|
|
if (strategy === "hybrid") {
|
|
results = rrfMergeL0(ftsItems, vecItems);
|
|
logger?.debug?.(
|
|
`${TAG} [hybrid] RRF merged: fts=${ftsItems.length}, vec=${vecItems.length} → ${results.length} unique`,
|
|
);
|
|
} else {
|
|
// Single-source: use whichever list has results (already sorted by score)
|
|
results = ftsOk ? ftsItems : vecItems;
|
|
}
|
|
|
|
// ── Apply session key filter ──
|
|
if (sessionFilter) {
|
|
const preFilterCount = results.length;
|
|
results = results.filter((r) => r.session_key === sessionFilter);
|
|
logger?.debug?.(`${TAG} After session filter "${sessionFilter}": ${results.length}/${preFilterCount}`);
|
|
}
|
|
|
|
// ── Trim to requested limit ──
|
|
const trimmed = results.slice(0, limit);
|
|
|
|
logger?.debug?.(
|
|
`${TAG} RESULT (strategy=${strategy}): returning ${trimmed.length} messages ` +
|
|
`(scores: [${trimmed.map((r) => r.score.toFixed(3)).join(", ")}])`,
|
|
);
|
|
|
|
return {
|
|
results: trimmed,
|
|
total: trimmed.length,
|
|
strategy,
|
|
};
|
|
}
|
|
|
|
// ============================
|
|
// Tool response formatter
|
|
// ============================
|
|
|
|
export function formatConversationSearchResponse(result: ConversationSearchResult): string {
|
|
if (result.message) {
|
|
return result.message;
|
|
}
|
|
if (result.results.length === 0) {
|
|
return "No matching conversation messages found.";
|
|
}
|
|
|
|
const lines: string[] = [
|
|
`Found ${result.total} matching message(s):`,
|
|
"",
|
|
];
|
|
|
|
for (const item of result.results) {
|
|
const scoreStr = typeof item.score === "number" ? ` (score: ${item.score.toFixed(3)})` : "";
|
|
const dateStr = item.recorded_at ? ` [${item.recorded_at}]` : "";
|
|
lines.push(`---`);
|
|
lines.push(`**[${item.role}]** Session: ${item.session_key}${dateStr}${scoreStr}`);
|
|
lines.push("");
|
|
lines.push(item.content);
|
|
lines.push("");
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|