AutomationFlowsAI & RAG › Agent Query Main

Agent Query Main

agent_query_main. Uses httpRequest, postgres. Webhook trigger; 12 nodes.

Webhook trigger★★★★☆ complexity12 nodesHTTP RequestPostgres
AI & RAG Trigger: Webhook Nodes: 12 Complexity: ★★★★☆ Added:

This workflow follows the HTTP Request → Postgres recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "9c7e0a12-6d2f-4f9c-9ab1-cf2d6c8f5303",
  "name": "agent_query_main",
  "active": true,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "WebhookAgentQuery",
      "name": "webhook_agent_query",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        220,
        340
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "agent-query",
        "responseMode": "lastNode",
        "options": {}
      }
    },
    {
      "id": "NormalizeInput",
      "name": "Normalize Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        340
      ],
      "parameters": {
        "jsCode": "const body = $json.body ?? $json;\nconst question = String(body.question ?? body.message ?? '').trim();\nif (!question) throw new Error('question is required');\nconst sessionId = String(body.session_id ?? body.user_id ?? 'default').trim();\nconst userId = String(body.user_id ?? 'anonymous').trim();\nconst token = $env.AGENT_WORKFLOW_TOKEN || '';\nconst traceId = String(body.trace_id ?? `${sessionId}-${Date.now()}`).trim();\nreturn [{\n  json: {\n    session_id: sessionId,\n    user_id: userId,\n    question,\n    token,\n    trace_id: traceId\n  }\n}];"
      }
    },
    {
      "id": "WriteUserMessage",
      "name": "HTTP: memory_write user",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        700,
        340
      ],
      "continueOnFail": true,
      "parameters": {
        "method": "POST",
        "url": "={{ (($env.INTERNAL_WEBHOOK_BASE_URL || 'http://127.0.0.1:5678').replace(/\\/?$/, '')) + '/webhook/' + ($env.N8N_MEMORY_WRITE_WEBHOOK_PATH || 'agent-memory-write') }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { session_id: $json.session_id, role: 'user', message: $json.question, metadata: { source: 'agent_query_main', user_id: $json.user_id }, token: $json.token } }}",
        "options": {}
      }
    },
    {
      "id": "BuildWikiQuery",
      "name": "Build Wiki Query",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        940,
        340
      ],
      "parameters": {
        "jsCode": "const normalized = $node['Normalize Input'].json;\nconst question = String(normalized.question ?? '').trim();\nconst questionLower = question.toLowerCase().replace(/\u0451/g, '\u0435');\nconst normalizedQuestion = questionLower\n  .replace(/[^\\p{L}\\p{N}\\s-]+/gu, ' ')\n  .replace(/\u0434\u043e\u043a\u0435\u0440\u0430/g, 'docker')\n  .replace(/\u0434\u043e\u043a\u0435\u0440/g, 'docker')\n  .replace(/\u043a\u043e\u043d\u0442\u0435\u043d\u0435\u0440/g, '\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440')\n  .replace(/[_-]+/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\nconst stopWords = new Set([\n  '\u0447\u0442\u043e', '\u044d\u0442\u043e', '\u043a\u0430\u043a', '\u0434\u043b\u044f', '\u0438\u043b\u0438', '\u043f\u0440\u043e', '\u043d\u0430\u0434', '\u043f\u043e\u0434', '\u0442\u0430\u043a\u043e\u0435', '\u0442\u0430\u043a\u043e\u0439',\n  '\u043a\u0430\u043a\u0430\u044f', '\u043a\u0430\u043a\u043e\u0435', '\u043a\u0442\u043e', '\u0437\u0430\u0447\u0435\u043c', '\u043f\u043e\u0447\u0435\u043c\u0443', '\u0440\u0430\u0441\u0441\u043a\u0430\u0436\u0438', '\u043e\u0431\u044a\u044f\u0441\u043d\u0438',\n  'the', 'and', 'with', 'from', 'into', 'about', 'vs'\n]);\nconst terms = normalizedQuestion\n  .split(' ')\n  .map((term) => term.trim())\n  .filter((term) => term.length >= 2 && !stopWords.has(term))\n  .slice(0, 16);\nconst fallbackTerms = normalizedQuestion\n  .split(' ')\n  .map((term) => term.trim())\n  .filter((term) => term.length >= 3)\n  .slice(0, 10);\nconst searchTerms = terms.length > 0 ? terms : fallbackTerms;\nconst questionPlain = normalizedQuestion || questionLower;\nconst tsText = searchTerms.length > 0 ? searchTerms.join(' ') : questionPlain;\nconst questionPlainSql = questionPlain.replace(/'/g, \"''\");\nconst questionTermsSql = searchTerms.join(' ').replace(/'/g, \"''\");\nconst questionTsSql = tsText.replace(/'/g, \"''\");\nconst limitRaw = Number($env.AGENT_WIKI_LIMIT ?? 5);\nconst limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.trunc(limitRaw))) : 5;\nconst maxContextCharsRaw = Number($env.AGENT_WIKI_CONTEXT_CHARS ?? 8000);\nconst maxContextChars = Number.isFinite(maxContextCharsRaw) ? Math.max(1000, Math.min(50000, Math.trunc(maxContextCharsRaw))) : 8000;\nconst identRe = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;\nconst wikiSchema = String($env.AGENT_WIKI_SCHEMA || 'public').trim();\nconst wikiTable = String($env.AGENT_WIKI_TABLE || 'pages').trim();\nconst wikiTitleColumn = String($env.AGENT_WIKI_TITLE_COLUMN || 'title').trim();\nconst wikiPathColumn = String($env.AGENT_WIKI_PATH_COLUMN || 'path').trim();\nconst wikiContentColumn = String($env.AGENT_WIKI_CONTENT_COLUMN || 'content').trim();\nfor (const [name, value] of Object.entries({ wikiSchema, wikiTable, wikiTitleColumn, wikiPathColumn, wikiContentColumn })) {\n  if (!identRe.test(value)) {\n    throw new Error('invalid ' + name);\n  }\n}\nreturn [{\n  json: {\n    ...normalized,\n    question_plain_sql: questionPlainSql,\n    question_terms_sql: questionTermsSql,\n    question_ts_sql: questionTsSql,\n    question_terms_count: searchTerms.length,\n    limit,\n    max_context_chars: maxContextChars,\n    wiki_schema_sql: wikiSchema,\n    wiki_table_sql: wikiTable,\n    wiki_title_column_sql: wikiTitleColumn,\n    wiki_path_column_sql: wikiPathColumn,\n    wiki_content_column_sql: wikiContentColumn\n  }\n}];"
      }
    },
    {
      "id": "ReadWikiContext",
      "name": "Postgres: Read Wiki Context",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1180,
        340
      ],
      "continueOnFail": true,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH params AS (\n  SELECT\n    LOWER('{{$json.question_plain_sql}}') AS q,\n    NULLIF(TRIM('{{$json.question_terms_sql}}'), '') AS terms,\n    plainto_tsquery('russian', '{{$json.question_ts_sql}}') AS q_ru,\n    plainto_tsquery('simple', '{{$json.question_ts_sql}}') AS q_simple\n),\nsource_rows AS (\n  SELECT\n    COALESCE(to_jsonb(t) ->> '{{$json.wiki_title_column_sql}}', '') AS title,\n    COALESCE(to_jsonb(t) ->> '{{$json.wiki_path_column_sql}}', '') AS path,\n    COALESCE(to_jsonb(t) ->> '{{$json.wiki_content_column_sql}}', '') AS content\n  FROM {{$json.wiki_schema_sql}}.{{$json.wiki_table_sql}} t\n  WHERE COALESCE(to_jsonb(t) ->> '{{$json.wiki_content_column_sql}}', '') <> ''\n),\nranked AS (\n  SELECT\n    s.title,\n    s.path,\n    s.content,\n    ts_rank_cd(\n      setweight(to_tsvector('russian', s.title), 'A') ||\n      setweight(to_tsvector('russian', s.content), 'B'),\n      p.q_ru\n    ) AS fts_rank,\n    ts_rank_cd(to_tsvector('simple', s.path), p.q_simple) AS path_rank,\n    CASE\n      WHEN s.title <> '' AND LOWER(s.title) LIKE '%' || p.q || '%' THEN 3\n      WHEN s.path <> '' AND LOWER(s.path) LIKE '%' || p.q || '%' THEN 2\n      WHEN LOWER(s.content) LIKE '%' || p.q || '%' THEN 1\n      ELSE 0\n    END AS exact_rank,\n    COALESCE((\n      SELECT COUNT(*)\n      FROM UNNEST(string_to_array(p.terms, ' ')) AS term\n      WHERE length(term) >= 2\n        AND (\n          LOWER(s.title) LIKE '%' || term || '%'\n          OR LOWER(s.path) LIKE '%' || term || '%'\n          OR LOWER(s.content) LIKE '%' || term || '%'\n          OR LOWER(s.title) LIKE '%' || LEFT(term, 4) || '%'\n          OR LOWER(s.path) LIKE '%' || LEFT(term, 4) || '%'\n          OR LOWER(s.content) LIKE '%' || LEFT(term, 4) || '%'\n        )\n    ), 0) AS term_hits\n  FROM source_rows s\n  CROSS JOIN params p\n),\nmatched AS (\n  SELECT\n    title,\n    path,\n    content,\n    term_hits,\n    (fts_rank * 10.0 + path_rank * 7.0 + exact_rank * 6.0 + term_hits * 2.0)::numeric AS score\n  FROM ranked\n  WHERE term_hits > 0 OR fts_rank > 0 OR path_rank > 0 OR exact_rank > 0\n  ORDER BY score DESC, term_hits DESC, LENGTH(content) DESC\n  LIMIT {{$json.limit}}\n)\nSELECT title, path, content, score, term_hits\nFROM matched\nUNION ALL\nSELECT '' AS title, '' AS path, '' AS content, 0::numeric AS score, 0::int AS term_hits\nWHERE NOT EXISTS (SELECT 1 FROM matched);"
      }
    },
    {
      "id": "ShapeWikiContext",
      "name": "Build Wiki Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1420,
        340
      ],
      "parameters": {
        "jsCode": "const maxChars = Number($node['Build Wiki Query'].json.max_context_chars ?? 8000);\nconst rows = items.map((item) => item.json);\nlet used = 0;\nlet topScore = 0;\nlet topTermHits = 0;\nconst blocks = [];\nconst sources = [];\nfor (const row of rows) {\n  const title = String(row.title ?? '').trim();\n  const path = String(row.path ?? '').trim();\n  const content = String(row.content ?? '').replace(/\\s+/g, ' ').trim();\n  const score = Number(row.score ?? 0);\n  const termHits = Number(row.term_hits ?? 0);\n  if (Number.isFinite(score) && score > topScore) topScore = score;\n  if (Number.isFinite(termHits) && termHits > topTermHits) topTermHits = termHits;\n  if (!content) continue;\n  const snippet = content.length > 1600 ? `${content.slice(0, 1600)}\u2026` : content;\n  const docId = `D${sources.length + 1}`;\n  sources.push({ id: docId, title, path, content: snippet, score, term_hits: termHits });\n  const block = [\n    `[${docId}]`,\n    title ? `TITLE: ${title}` : null,\n    path ? `PATH: ${path}` : null,\n    `CONTENT: ${snippet}`\n  ].filter(Boolean).join('\\n');\n  const nextUsed = used + block.length + (blocks.length > 0 ? 6 : 0);\n  if (nextUsed > maxChars && blocks.length > 0) break;\n  blocks.push(block);\n  used = nextUsed;\n}\nconst contextText = blocks.join('\\n\\n---\\n\\n');\nreturn [{\n  json: {\n    count: blocks.length,\n    memory_text: contextText,\n    context_source: 'wiki',\n    top_score: topScore,\n    top_term_hits: topTermHits,\n    sources\n  }\n}];"
      }
    },
    {
      "id": "BuildPrompt",
      "name": "Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1660,
        340
      ],
      "parameters": {
        "jsCode": "const normalized = $node['Normalize Input'].json;\nconst queryMeta = $node['Build Wiki Query'].json;\nconst memory = String($json.memory_text ?? '').trim();\nconst memoryCount = Number($json.count ?? 0);\nconst topScore = Number($json.top_score ?? 0);\nconst topTermHits = Number($json.top_term_hits ?? 0);\nconst questionTermsCount = Number(queryMeta.question_terms_count ?? 0);\nconst sources = Array.isArray($json.sources) ? $json.sources : [];\nconst minMemoryCharsRaw = Number($env.AGENT_MIN_MEMORY_CHARS ?? 40);\nconst minMemoryChars = Number.isFinite(minMemoryCharsRaw) ? Math.max(0, Math.trunc(minMemoryCharsRaw)) : 40;\nconst minWikiScoreRaw = Number($env.AGENT_WIKI_MIN_SCORE ?? 2);\nconst minWikiScore = Number.isFinite(minWikiScoreRaw) ? Math.max(0, minWikiScoreRaw) : 2;\nconst minTermHitsRaw = Number($env.AGENT_WIKI_MIN_TERM_HITS ?? 1);\nconst minTermHits = Number.isFinite(minTermHitsRaw) ? Math.max(0, minTermHitsRaw) : 1;\nconst memoryHasContext = memoryCount > 0 && memory.length >= minMemoryChars && topScore >= minWikiScore && topTermHits >= minTermHits;\nconst questionLower = String(normalized.question ?? '').toLowerCase().replace(/\u0451/g, '\u0435');\nconst greetingPatterns = [/^\u043f\u0440\u0438\u0432\u0435\u0442\\b/, /^\u0437\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439/, /^\u0434\u043e\u0431\u0440\u044b\u0439\\s+(\u0434\u0435\u043d\u044c|\u0432\u0435\u0447\u0435\u0440|\u0443\u0442\u0440\u043e)\\b/, /^hello\\b/, /^hi\\b/];\nconst isGreetingQuestion = greetingPatterns.some((re) => re.test(questionLower));\nconst identityPatterns = [/\u043a\u0442\u043e\\s+\u0442\u044b/, /\u0442\u044b\\s+\u043a\u0442\u043e/, /who\\s+are\\s+you/];\nconst isIdentityQuestion = identityPatterns.some((re) => re.test(questionLower));\nconst identityAnswer = '\u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.';\nconst greetingAnswer = '\u041f\u0440\u0438\u0432\u0435\u0442! \u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.';\nconst metaQuestionPatterns = [\n  /\u0442\u0432\u043e\u044f \u0440\u043e\u043b\u044c/,\n  /\u0441\u0438\u0441\u0442\u0435\u043c\u043d(\u044b\u0439|\u043e\u0433\u043e)? \u043f\u0440\u043e\u043c\u043f\u0442/,\n  /\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446/,\n  /\u0447\u0442\u043e \u0442\u044b \u0443\u043c\u0435\u0435\u0448\u044c/,\n  /\u0447\u0443\u0432\u0441\u0442\u0432/,\n  /\u043f\u0430\u043c\u044f\u0442[\u044c\u0438]/,\n  /session/\n];\nconst isMetaQuestion = metaQuestionPatterns.some((re) => re.test(questionLower));\nconst defaultSystemPrompt = [\n  '\u0422\u044b \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.',\n  '\u0412\u0441\u0435\u0433\u0434\u0430 \u043e\u0442\u0432\u0435\u0447\u0430\u0439 \u043d\u0430 \u0440\u0443\u0441\u0441\u043a\u043e\u043c \u044f\u0437\u044b\u043a\u0435.',\n  '\u041e\u0442\u0432\u0435\u0447\u0430\u0439 \u0442\u043e\u043b\u044c\u043a\u043e \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432 \u0438\u0437 CONTEXT.',\n  '\u041d\u0435\u043b\u044c\u0437\u044f \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0442\u044c \u0444\u0430\u043a\u0442\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0445 \u043d\u0435\u0442 \u0432 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0445.',\n  '\u0412\u0435\u0440\u043d\u0438 \u0441\u0442\u0440\u043e\u0433\u043e JSON \u0431\u0435\u0437 markdown \u0438 \u0431\u0435\u0437 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0442\u0435\u043a\u0441\u0442\u0430.',\n  '\u0424\u043e\u0440\u043c\u0430\u0442: {\"answer\":\"...\",\"citations\":[\"D1\"],\"enough_context\":true}',\n  '\u0415\u0441\u043b\u0438 \u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e: {\"answer\":\"\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.\",\"citations\":[],\"enough_context\":false}'\n].join(' ');\nconst systemPrompt = String($env.AGENT_SYSTEM_PROMPT || defaultSystemPrompt).trim();\nconst ollamaModel = String($env.OLLAMA_MODEL || '').trim();\nif (!ollamaModel) throw new Error('OLLAMA_MODEL is required');\nconst prompt = [\n  systemPrompt,\n  '',\n  'CONTEXT:',\n  memoryHasContext ? memory : '[empty]',\n  '',\n  'SOURCES_JSON:',\n  JSON.stringify(sources),\n  '',\n  `QUESTION: ${normalized.question}`,\n  '',\n  '\u041e\u0442\u0432\u0435\u0442 \u0432\u0435\u0440\u043d\u0438 \u0442\u043e\u043b\u044c\u043a\u043e JSON-\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u043c.'\n].join('\\n');\nreturn [{\n  json: {\n    session_id: normalized.session_id,\n    user_id: normalized.user_id,\n    question: normalized.question,\n    token: normalized.token,\n    trace_id: normalized.trace_id,\n    memory_count: memoryCount,\n    memory_has_context: memoryHasContext,\n    top_score: topScore,\n    top_term_hits: topTermHits,\n    question_terms_count: questionTermsCount,\n    min_wiki_score: minWikiScore,\n    min_term_hits: minTermHits,\n    is_identity_question: isIdentityQuestion,\n    is_greeting_question: isGreetingQuestion,\n    identity_answer: identityAnswer,\n    greeting_answer: greetingAnswer,\n    is_meta_question: isMetaQuestion,\n    ollama_model: ollamaModel,\n    prompt,\n    sources\n  }\n}];"
      }
    },
    {
      "id": "CallOllama",
      "name": "HTTP: Ollama Generate",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1900,
        340
      ],
      "continueOnFail": true,
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434') + '/api/generate' }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { model: $json.ollama_model, prompt: $json.prompt, stream: false, format: 'json', options: { temperature: 0.1 } } }}",
        "options": {
          "timeout": 120000
        }
      }
    },
    {
      "id": "NormalizeAnswer",
      "name": "Normalize Answer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2140,
        340
      ],
      "parameters": {
        "jsCode": "const base = $node['Build Prompt'].json;\nconst allowGeneralKb = String($env.AGENT_ALLOW_GENERAL_KB || 'false').toLowerCase() === 'true';\nconst minOverlapRaw = Number($env.AGENT_MIN_ANSWER_OVERLAP ?? 0);\nconst minOverlap = Number.isFinite(minOverlapRaw) ? Math.max(0, Math.trunc(minOverlapRaw)) : 0;\nlet answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\nlet guardReason = 'ok';\nlet citations = [];\nlet overlapCount = 0;\nif (base.is_greeting_question) {\n  answer = String(base.greeting_answer || '\u041f\u0440\u0438\u0432\u0435\u0442! \u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.');\n  guardReason = 'greeting_fixed';\n} else if (base.is_identity_question) {\n  answer = String(base.identity_answer || '\u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.');\n  guardReason = 'identity_fixed';\n} else if (!allowGeneralKb && base.is_meta_question) {\n  answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n  guardReason = 'meta_question_blocked';\n} else if (!allowGeneralKb && !base.memory_has_context) {\n  answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n  guardReason = 'no_context';\n} else {\n  const raw = String($json.response ?? '').trim();\n  const tryParseJson = (input) => {\n    if (!input) return null;\n    try {\n      return JSON.parse(input);\n    } catch {\n      return null;\n    }\n  };\n  const extractJsonObject = (input) => {\n    if (!input) return '';\n    const cleaned = input\n      .replace(/^```json\\s*/i, '')\n      .replace(/^```\\s*/i, '')\n      .replace(/\\s*```$/i, '')\n      .trim();\n    if (!cleaned) return '';\n    let depth = 0;\n    let start = -1;\n    let inString = false;\n    let escaped = false;\n    for (let i = 0; i < cleaned.length; i += 1) {\n      const ch = cleaned[i];\n      if (inString) {\n        if (escaped) {\n          escaped = false;\n        } else if (ch === '\\\\') {\n          escaped = true;\n        } else if (ch === '\"') {\n          inString = false;\n        }\n        continue;\n      }\n      if (ch === '\"') {\n        inString = true;\n        continue;\n      }\n      if (ch === '{') {\n        if (depth === 0) start = i;\n        depth += 1;\n      } else if (ch === '}') {\n        if (depth > 0) depth -= 1;\n        if (depth === 0 && start !== -1) {\n          return cleaned.slice(start, i + 1);\n        }\n      }\n    }\n    return '';\n  };\n  let parsed = tryParseJson(raw);\n  if (!parsed || typeof parsed !== 'object') {\n    const extracted = extractJsonObject(raw);\n    parsed = tryParseJson(extracted);\n  }\n  if (!parsed || typeof parsed !== 'object') {\n    answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n    guardReason = 'model_non_json';\n  } else {\n    const parsedAnswer = String(parsed.answer ?? '').trim();\n    const enoughContext = Boolean(parsed.enough_context);\n    let parsedCitations = Array.isArray(parsed.citations)\n      ? parsed.citations.map((v) => String(v || '').trim()).filter(Boolean)\n      : [];\n    if (!enoughContext || !parsedAnswer || parsedAnswer === '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.') {\n      answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n      guardReason = 'insufficient_context';\n    } else {\n      const sourceList = Array.isArray(base.sources) ? base.sources : [];\n      const sourceMap = new Map(sourceList.map((s) => [String(s.id), String(s.content ?? '').toLowerCase()]));\n      if (parsedCitations.length === 0 && sourceList.length > 0) {\n        parsedCitations = [String(sourceList[0].id)];\n      }\n      const allValidCitations = parsedCitations.length > 0 && parsedCitations.every((id) => sourceMap.has(id));\n      if (!allValidCitations) {\n        answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n        guardReason = 'invalid_citations';\n      } else {\n        const citedText = parsedCitations.map((id) => sourceMap.get(id) || '').join(' ');\n        const tokenSet = new Set(\n          parsedAnswer\n            .toLowerCase()\n            .replace(/[^\\p{L}\\p{N}\\s-]+/gu, ' ')\n            .split(/\\s+/)\n            .map((t) => t.trim())\n            .filter((t) => t.length >= 4)\n        );\n        overlapCount = 0;\n        for (const token of tokenSet) {\n          if (citedText.includes(token)) overlapCount += 1;\n        }\n        if (minOverlap > 0 && overlapCount < minOverlap) {\n          answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n          guardReason = 'evidence_mismatch';\n        } else {\n          answer = parsedAnswer;\n          citations = parsedCitations;\n          guardReason = 'ok';\n        }\n      }\n    }\n  }\n}\nconst shouldPersistAssistant = answer !== '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.' && (guardReason === 'ok' || guardReason === 'identity_fixed' || guardReason === 'greeting_fixed');\nreturn [{\n  json: {\n    session_id: base.session_id,\n    user_id: base.user_id,\n    question: base.question,\n    answer,\n    citations,\n    token: base.token,\n    trace_id: base.trace_id,\n    memory_count: base.memory_count,\n    memory_has_context: base.memory_has_context,\n    top_score: base.top_score,\n    top_term_hits: base.top_term_hits,\n    question_terms_count: base.question_terms_count,\n    min_wiki_score: base.min_wiki_score,\n    min_term_hits: base.min_term_hits,\n    is_identity_question: base.is_identity_question,\n    is_greeting_question: base.is_greeting_question,\n    is_meta_question: base.is_meta_question,\n    ollama_model: base.ollama_model,\n    guard_reason: guardReason,\n    overlap_count: overlapCount,\n    should_persist_assistant: shouldPersistAssistant,\n    prompt: base.prompt\n  }\n}];"
      }
    },
    {
      "id": "ShouldPersistAssistant",
      "name": "IF: persist assistant",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2360,
        340
      ],
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.should_persist_assistant }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "WriteAssistantMessage",
      "name": "HTTP: memory_write assistant",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2620,
        340
      ],
      "continueOnFail": true,
      "parameters": {
        "method": "POST",
        "url": "={{ (($env.INTERNAL_WEBHOOK_BASE_URL || 'http://127.0.0.1:5678').replace(/\\/?$/, '')) + '/webhook/' + ($env.N8N_MEMORY_WRITE_WEBHOOK_PATH || 'agent-memory-write') }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ { session_id: $json.session_id, role: 'assistant', message: $json.answer, metadata: { source: 'agent_query_main', model: $json.ollama_model, trace_id: $json.trace_id, guard_reason: $json.guard_reason, memory_count: Number($json.memory_count ?? 0) }, token: $json.token } }}",
        "options": {}
      }
    },
    {
      "id": "BuildResponse",
      "name": "Build API Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        340
      ],
      "parameters": {
        "jsCode": "const base = $node['Normalize Answer'].json;\nconst debugEnabled = String($env.AGENT_DEBUG || 'false').toLowerCase() === 'true';\nconst response = {\n  ok: true,\n  trace_id: base.trace_id,\n  session_id: base.session_id,\n  user_id: base.user_id,\n  question: base.question,\n  answer: base.answer,\n  citations: Array.isArray(base.citations) ? base.citations : []\n};\nif (debugEnabled) {\n  response.debug = {\n    memory_count: Number(base.memory_count ?? 0),\n    memory_has_context: Boolean(base.memory_has_context),\n    top_score: Number(base.top_score ?? 0),\n    top_term_hits: Number(base.top_term_hits ?? 0),\n    question_terms_count: Number(base.question_terms_count ?? 0),\n    min_wiki_score: Number(base.min_wiki_score ?? 0),\n    min_term_hits: Number(base.min_term_hits ?? 0),\n    is_identity_question: Boolean(base.is_identity_question),\n    is_meta_question: Boolean(base.is_meta_question),\n    guard_reason: String(base.guard_reason ?? 'unknown'),\n    overlap_count: Number(base.overlap_count ?? 0),\n    prompt_preview: String(base.prompt ?? '').slice(0, 600)\n  };\n}\nreturn [{ json: response }];"
      }
    }
  ],
  "connections": {
    "webhook_agent_query": {
      "main": [
        [
          {
            "node": "Normalize Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Input": {
      "main": [
        [
          {
            "node": "HTTP: memory_write user",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP: memory_write user": {
      "main": [
        [
          {
            "node": "Build Wiki Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Wiki Query": {
      "main": [
        [
          {
            "node": "Postgres: Read Wiki Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Postgres: Read Wiki Context": {
      "main": [
        [
          {
            "node": "Build Wiki Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Wiki Context": {
      "main": [
        [
          {
            "node": "Build Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompt": {
      "main": [
        [
          {
            "node": "HTTP: Ollama Generate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP: Ollama Generate": {
      "main": [
        [
          {
            "node": "Normalize Answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Answer": {
      "main": [
        [
          {
            "node": "IF: persist assistant",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: persist assistant": {
      "main": [
        [
          {
            "node": "HTTP: memory_write assistant",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build API Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP: memory_write assistant": {
      "main": [
        [
          {
            "node": "Build API Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "versionId": "00000000-0000-0000-0000-000000000003",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": [
    {
      "name": "agent"
    },
    {
      "name": "query"
    }
  ]
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

agent_query_main. Uses httpRequest, postgres. Webhook trigger; 12 nodes.

Source: https://github.com/Andrey787878/ai-knowledge-assistant/blob/main/n8n/workflows/agent_query_main.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Jigsaw API key for image processing, I use this as a gatekeeper/second pair of eyes. LINK to their website https://jigsawstack.com/ SECOND A postgress DATABASE (I use Supabase) LlamaCloud for the pars

HTTP Request, Postgres, Stop And Error +2
AI & RAG

W1 - IN WhatsApp Adapter (Secure + Fast ACK). Uses postgres, redis, httpRequest. Webhook trigger; 48 nodes.

Postgres, Redis, HTTP Request
AI & RAG

W2 - IN Instagram Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.

Postgres, HTTP Request
AI & RAG

W3 - IN Messenger Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.

Postgres, HTTP Request
AI & RAG

Engagement Tracking Workflow. Uses postgres, httpRequest. Webhook trigger; 22 nodes.

Postgres, HTTP Request