|
@@ -207,23 +207,133 @@ function buildDefaultOptions(override) {
|
|
|
: defaults;
|
|
: defaults;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function resolveEmbeddingQuery(searchQuery) {
|
|
|
|
|
- if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query" };
|
|
|
|
|
|
|
+const HYDE_MIN_QUERY_LENGTH = 15;
|
|
|
|
|
+
|
|
|
|
|
+function shouldSkipHyde(originalMessage, searchQuery) {
|
|
|
|
|
+ const orig = originalMessage.trim().toLowerCase();
|
|
|
|
|
+ if (orig.length < HYDE_MIN_QUERY_LENGTH) return true;
|
|
|
|
|
+ if (searchQuery.trim().toLowerCase() === orig) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resolveEmbeddingQuery(searchQuery, originalMessage) {
|
|
|
|
|
+ if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
|
|
|
+ if (shouldSkipHyde(originalMessage, searchQuery)) {
|
|
|
|
|
+ return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
|
|
|
|
|
+ }
|
|
|
const hydeText = await generateHydeDocument(searchQuery);
|
|
const hydeText = await generateHydeDocument(searchQuery);
|
|
|
return hydeText
|
|
return hydeText
|
|
|
- ? { embeddingQuery: hydeText, embedRole: "passage" }
|
|
|
|
|
- : { embeddingQuery: searchQuery, embedRole: "query" };
|
|
|
|
|
|
|
+ ? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
|
|
|
|
|
+ : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const REWRITE_HYDE_SYSTEM_PROMPT = [
|
|
|
|
|
+ 'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
|
|
|
|
|
+ '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
|
|
|
|
|
+ '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
|
|
|
|
|
+ "para busca em documentos internos da empresa, em linguagem natural (nunca em código, SQL,",
|
|
|
|
|
+ "fórmulas ou pseudocódigo), resolvendo pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
|
|
|
|
|
+ '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de documentação técnica',
|
|
|
|
|
+ "interna (comandos, telas, campos, passos) que responderia a essa pergunta, plausível mesmo sem certeza,",
|
|
|
|
|
+ "pois será usado apenas para busca por similaridade, nunca mostrado ao usuário. Responda em português brasileiro.",
|
|
|
|
|
+ "Não inclua explicações, texto extra ou markdown fora do JSON."
|
|
|
|
|
+].join(" ");
|
|
|
|
|
+
|
|
|
|
|
+function parseRewriteHydeResult(content, originalQuery) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const cleaned = String(content ?? "")
|
|
|
|
|
+ .trim()
|
|
|
|
|
+ .replace(/^```json\s*/i, "")
|
|
|
|
|
+ .replace(/^```\s*/, "")
|
|
|
|
|
+ .replace(/```$/, "")
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ const parsed = JSON.parse(cleaned);
|
|
|
|
|
+ const rewrittenQuery = typeof parsed?.rewrittenQuery === "string" && parsed.rewrittenQuery.trim()
|
|
|
|
|
+ ? parsed.rewrittenQuery.trim()
|
|
|
|
|
+ : originalQuery;
|
|
|
|
|
+ const hydeDocument = typeof parsed?.hydeDocument === "string" && parsed.hydeDocument.trim()
|
|
|
|
|
+ ? parsed.hydeDocument.trim()
|
|
|
|
|
+ : null;
|
|
|
|
|
+ return { rewrittenQuery, hydeDocument };
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return { rewrittenQuery: originalQuery, hydeDocument: null };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function rewriteAndHyde(query, history) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const hasHistory = history.length > 0;
|
|
|
|
|
+ const messages = [
|
|
|
|
|
+ { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
|
|
|
|
|
+ ...(hasHistory
|
|
|
|
|
+ ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
|
|
|
|
|
+ : []),
|
|
|
|
|
+ { role: "user", content: query }
|
|
|
|
|
+ ];
|
|
|
|
|
+ const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
|
|
|
|
|
+ const result = parseRewriteHydeResult(content, query);
|
|
|
|
|
+ if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
|
|
|
|
|
+ return { rewrittenQuery: query, hydeDocument: null };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resolveSearchAndEmbedding(message, history, timer) {
|
|
|
|
|
+ const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
|
|
|
|
|
+
|
|
|
|
|
+ if (bothEnabled) {
|
|
|
|
|
+ const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
|
|
|
|
|
+ timer?.mark("rewrite_hyde");
|
|
|
|
|
+ const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
|
|
|
|
|
+ if (skip) {
|
|
|
|
|
+ timer?.mark("hyde_used", "skipped");
|
|
|
|
|
+ return { searchQuery: rewrittenQuery, embeddingQuery: rewrittenQuery, embedRole: "query", hydeSkipped: true };
|
|
|
|
|
+ }
|
|
|
|
|
+ return { searchQuery: rewrittenQuery, embeddingQuery: hydeDocument, embedRole: "passage", hydeSkipped: false };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
|
|
+ timer?.mark("rewrite");
|
|
|
|
|
+ const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
|
|
|
|
|
+ timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
|
|
|
|
|
+ return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createTimer() {
|
|
|
|
|
+ const t0 = Date.now();
|
|
|
|
|
+ let last = t0;
|
|
|
|
|
+ const marks = [];
|
|
|
|
|
+ return {
|
|
|
|
|
+ mark(label, value) {
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ marks.push(`${label}=${value !== undefined ? value : `${now - last}ms`}`);
|
|
|
|
|
+ last = now;
|
|
|
|
|
+ },
|
|
|
|
|
+ log(prefix) {
|
|
|
|
|
+ marks.push(`total=${Date.now() - t0}ms`);
|
|
|
|
|
+ console.log(`[chat:timing] ${prefix} ${marks.join(" ")}`.replace(/\s+/g, " ").trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export async function answerWithContext({ message, conversationId, userId, options }) {
|
|
export async function answerWithContext({ message, conversationId, userId, options }) {
|
|
|
|
|
+ const timer = createTimer();
|
|
|
const history = await loadHistory(conversationId, userId);
|
|
const history = await loadHistory(conversationId, userId);
|
|
|
- const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
|
|
- const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
|
|
|
|
|
|
|
+ timer.mark("history");
|
|
|
|
|
+ const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
|
|
|
let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
- if (config.rag.rerank && hits.length > 1) hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
|
|
+ timer.mark("search");
|
|
|
|
|
+ if (config.rag.rerank && hits.length > 1) {
|
|
|
|
|
+ hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
+ timer.mark("rerank");
|
|
|
|
|
+ }
|
|
|
const context = buildContextBlock(hits);
|
|
const context = buildContextBlock(hits);
|
|
|
const messages = buildMessages(message, context, history);
|
|
const messages = buildMessages(message, context, history);
|
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
|
|
|
+ timer.mark("llm");
|
|
|
|
|
+ timer.log(`conversationId=${conversationId ?? "-"}`);
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
answer: completion.content,
|
|
answer: completion.content,
|
|
@@ -232,17 +342,19 @@ export async function answerWithContext({ message, conversationId, userId, optio
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export async function answerWithContextStream({ message, conversationId, userId, options, onChunk, onSources, onStatus, signal }) {
|
|
export async function answerWithContextStream({ message, conversationId, userId, options, onChunk, onSources, onStatus, signal }) {
|
|
|
|
|
+ const timer = createTimer();
|
|
|
onStatus?.("buscando");
|
|
onStatus?.("buscando");
|
|
|
const history = await loadHistory(conversationId, userId);
|
|
const history = await loadHistory(conversationId, userId);
|
|
|
- const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
|
|
- if (config.rag.hyde) onStatus?.("gerando_hipotese");
|
|
|
|
|
- const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
|
|
|
|
|
|
|
+ timer.mark("history");
|
|
|
|
|
+ const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
|
|
|
let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
|
|
+ timer.mark("search");
|
|
|
onStatus?.("encontrou", hits.length);
|
|
onStatus?.("encontrou", hits.length);
|
|
|
|
|
|
|
|
if (config.rag.rerank && hits.length > 1) {
|
|
if (config.rag.rerank && hits.length > 1) {
|
|
|
onStatus?.("reordenando");
|
|
onStatus?.("reordenando");
|
|
|
hits = await rerankHits(searchQuery, hits);
|
|
hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
+ timer.mark("rerank");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const sources = hitsToSources(hits);
|
|
const sources = hitsToSources(hits);
|
|
@@ -251,6 +363,8 @@ export async function answerWithContextStream({ message, conversationId, userId,
|
|
|
const messages = buildMessages(message, context, history);
|
|
const messages = buildMessages(message, context, history);
|
|
|
onStatus?.("gerando");
|
|
onStatus?.("gerando");
|
|
|
const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
|
|
const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
|
|
|
|
|
+ timer.mark("llm");
|
|
|
|
|
+ timer.log(`conversationId=${conversationId ?? "-"}`);
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
answer: completion.content,
|
|
answer: completion.content,
|