first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,267 @@
import { NodeConnectionTypes, parseErrorMetadata, sleep } from 'n8n-workflow';
import {
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import {
promptTypeOptions,
promptTypeOptionsDeprecated,
textFromGuardrailsNode,
textFromPreviousNode,
} from '@utils/descriptions';
import { getBatchingOptionFields, getTemplateNoticeField } from '@n8n/ai-utilities';
import { INPUT_TEMPLATE_KEY, LEGACY_INPUT_TEMPLATE_KEY, systemPromptOption } from './constants';
import { processItem } from './processItem';
export class ChainRetrievalQa implements INodeType {
description: INodeTypeDescription = {
displayName: 'Question and Answer Chain',
name: 'chainRetrievalQa',
icon: 'fa:link',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
description: 'Answer questions about retrieved documents',
defaults: {
name: 'Question and Answer Chain',
color: '#909298',
},
codex: {
alias: ['LangChain'],
categories: ['AI'],
subcategories: {
AI: ['Chains', 'Root Nodes'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.chainretrievalqa/',
},
],
},
},
inputs: [
NodeConnectionTypes.Main,
{
displayName: 'Model',
maxConnections: 1,
type: NodeConnectionTypes.AiLanguageModel,
required: true,
},
{
displayName: 'Retriever',
maxConnections: 1,
type: NodeConnectionTypes.AiRetriever,
required: true,
},
],
outputs: [NodeConnectionTypes.Main],
builderHint: {
inputs: {
ai_languageModel: { required: true },
ai_retriever: { required: true },
},
},
credentials: [],
properties: [
getTemplateNoticeField(1960),
{
displayName: 'Query',
name: 'query',
type: 'string',
required: true,
default: '={{ $json.input }}',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Query',
name: 'query',
type: 'string',
required: true,
default: '={{ $json.chat_input }}',
displayOptions: {
show: {
'@version': [1.1],
},
},
},
{
displayName: 'Query',
name: 'query',
type: 'string',
required: true,
default: '={{ $json.chatInput }}',
displayOptions: {
show: {
'@version': [1.2],
},
},
},
{
...promptTypeOptionsDeprecated,
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.2 } }, { _cnd: { gte: 1.7 } }],
},
},
},
{
...promptTypeOptions,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.7 } }],
},
},
},
{
...textFromGuardrailsNode,
displayOptions: {
show: { promptType: ['guardrails'], '@version': [{ _cnd: { gte: 1.4 } }] },
},
},
{
...textFromPreviousNode,
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.4 } }] } },
},
{
displayName: 'Prompt (User Message)',
name: 'text',
type: 'string',
required: true,
default: '',
placeholder: 'e.g. Hello, how can you help me?',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
promptType: ['define'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [
{
...systemPromptOption,
description: `Template string used for the system prompt. This should include the variable \`{context}\` for the provided context. For text completion models, you should also include the variable \`{${LEGACY_INPUT_TEMPLATE_KEY}}\` for the users query.`,
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 1.5 } }],
},
},
},
{
...systemPromptOption,
description: `Template string used for the system prompt. This should include the variable \`{context}\` for the provided context. For text completion models, you should also include the variable \`{${INPUT_TEMPLATE_KEY}}\` for the users query.`,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.5 } }],
},
},
},
getBatchingOptionFields({
show: {
'@version': [{ _cnd: { gte: 1.6 } }],
},
}),
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing Retrieval QA Chain');
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 5) as number;
const delayBetweenBatches = this.getNodeParameter(
'options.batching.delayBetweenBatches',
0,
0,
) as number;
if (this.getNode().typeVersion >= 1.6 && batchSize >= 1) {
// Run in batches
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchPromises = batch.map(async (_item, batchItemIndex) => {
return await processItem(this, i + batchItemIndex);
});
const batchResults = await Promise.allSettled(batchPromises);
batchResults.forEach((response, index) => {
if (response.status === 'rejected') {
const error = response.reason;
if (this.continueOnFail()) {
const metadata = parseErrorMetadata(error);
returnData.push({
json: { error: error.message },
pairedItem: { item: index },
metadata,
});
return;
} else {
throw error;
}
}
const output = response.value;
const answer = output.answer as string;
if (this.getNode().typeVersion >= 1.5) {
returnData.push({ json: { response: answer } });
} else {
// Legacy format for versions 1.4 and below is { text: string }
returnData.push({ json: { response: { text: answer } } });
}
});
// Add delay between batches if not the last batch
if (i + batchSize < items.length && delayBetweenBatches > 0) {
await sleep(delayBetweenBatches);
}
}
} else {
// Run for each item
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const response = await processItem(this, itemIndex);
const answer = response.answer as string;
if (this.getNode().typeVersion >= 1.5) {
returnData.push({ json: { response: answer } });
} else {
// Legacy format for versions 1.4 and below is { text: string }
returnData.push({ json: { response: { text: answer } } });
}
} catch (error) {
if (this.continueOnFail()) {
const metadata = parseErrorMetadata(error);
returnData.push({
json: { error: error.message },
pairedItem: { item: itemIndex },
metadata,
});
continue;
}
throw error;
}
}
}
return [returnData];
}
}
@@ -0,0 +1,20 @@
import type { INodeProperties } from 'n8n-workflow';
export const SYSTEM_PROMPT_TEMPLATE = `You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
----------------
Context: {context}`;
// Due to the refactoring in version 1.5, the variable name {question} needed to be changed to {input} in the prompt template.
export const LEGACY_INPUT_TEMPLATE_KEY = 'question';
export const INPUT_TEMPLATE_KEY = 'input';
export const systemPromptOption: INodeProperties = {
displayName: 'System Prompt Template',
name: 'systemPromptTemplate',
type: 'string',
default: SYSTEM_PROMPT_TEMPLATE,
typeOptions: {
rows: 6,
},
};
@@ -0,0 +1,101 @@
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import {
ChatPromptTemplate,
HumanMessagePromptTemplate,
PromptTemplate,
SystemMessagePromptTemplate,
} from '@langchain/core/prompts';
import type { BaseRetriever } from '@langchain/core/retrievers';
import { createStuffDocumentsChain } from '@langchain/classic/chains/combine_documents';
import { createRetrievalChain } from '@langchain/classic/chains/retrieval';
import { type IExecuteFunctions, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { isChatInstance } from '@n8n/ai-utilities';
import { getPromptInputByType } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing';
import { INPUT_TEMPLATE_KEY, LEGACY_INPUT_TEMPLATE_KEY, SYSTEM_PROMPT_TEMPLATE } from './constants';
export const processItem = async (
ctx: IExecuteFunctions,
itemIndex: number,
): Promise<Record<string, unknown>> => {
const model = (await ctx.getInputConnectionData(
NodeConnectionTypes.AiLanguageModel,
0,
)) as BaseLanguageModel;
const retriever = (await ctx.getInputConnectionData(
NodeConnectionTypes.AiRetriever,
0,
)) as BaseRetriever;
let query;
if (ctx.getNode().typeVersion <= 1.2) {
query = ctx.getNodeParameter('query', itemIndex) as string;
} else {
query = getPromptInputByType({
ctx,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
}
if (query === undefined) {
throw new NodeOperationError(ctx.getNode(), 'The query parameter is empty.');
}
const options = ctx.getNodeParameter('options', itemIndex, {}) as {
systemPromptTemplate?: string;
};
let templateText = options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE;
// Replace legacy input template key for versions 1.4 and below
if (ctx.getNode().typeVersion < 1.5) {
templateText = templateText.replace(
`{${LEGACY_INPUT_TEMPLATE_KEY}}`,
`{${INPUT_TEMPLATE_KEY}}`,
);
}
// Create prompt template based on model type and user configuration
let promptTemplate;
if (isChatInstance(model)) {
// For chat models, create a chat prompt template with system and human messages
const messages = [
SystemMessagePromptTemplate.fromTemplate(templateText),
HumanMessagePromptTemplate.fromTemplate('{input}'),
];
promptTemplate = ChatPromptTemplate.fromMessages(messages);
} else {
// For non-chat models, create a text prompt template with Question/Answer format
const questionSuffix =
options.systemPromptTemplate === undefined ? '\n\nQuestion: {input}\nAnswer:' : '';
promptTemplate = new PromptTemplate({
template: templateText + questionSuffix,
inputVariables: ['context', 'input'],
});
}
// Create the document chain that combines the retrieved documents
const combineDocsChain = await createStuffDocumentsChain({
llm: model,
prompt: promptTemplate,
});
// Create the retrieval chain that handles the retrieval and then passes to the combine docs chain
const retrievalChain = await createRetrievalChain({
combineDocsChain,
retriever,
});
// Execute the chain with tracing config
const tracingConfig = getTracingConfig(ctx);
return await retrievalChain
.withConfig(tracingConfig)
.invoke({ input: query }, { signal: ctx.getExecutionCancelSignal() });
};
@@ -0,0 +1,343 @@
import { Document } from '@langchain/core/documents';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseRetriever } from '@langchain/core/retrievers';
import { FakeChatModel, FakeLLM, FakeRetriever } from '@langchain/core/utils/testing';
import get from 'lodash/get';
import type { IDataObject, IExecuteFunctions, NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError, UnexpectedError } from 'n8n-workflow';
import { ChainRetrievalQa } from '../ChainRetrievalQa.node';
const createExecuteFunctionsMock = (
parameters: IDataObject,
fakeLlm: BaseLanguageModel,
fakeRetriever: BaseRetriever,
version: number,
) => {
return {
getExecutionCancelSignal() {
return new AbortController().signal;
},
getNodeParameter(parameter: string) {
return get(parameters, parameter);
},
getNode() {
return {
typeVersion: version,
};
},
getInputConnectionData(type: NodeConnectionType) {
if (type === NodeConnectionTypes.AiLanguageModel) {
return fakeLlm;
}
if (type === NodeConnectionTypes.AiRetriever) {
return fakeRetriever;
}
return null;
},
getInputData() {
return [{ json: {} }];
},
getWorkflow() {
return {
name: 'Test Workflow',
};
},
getExecutionId() {
return 'test_execution_id';
},
continueOnFail() {
return false;
},
logger: { debug: jest.fn() },
} as unknown as IExecuteFunctions;
};
describe('ChainRetrievalQa', () => {
let node: ChainRetrievalQa;
const testDocs = [
new Document({
pageContent: 'The capital of France is Paris. It is known for the Eiffel Tower.',
}),
new Document({
pageContent:
'Paris is the largest city in France with a population of over 2 million people.',
}),
];
const fakeRetriever = new FakeRetriever({ output: testDocs });
beforeEach(() => {
node = new ChainRetrievalQa();
});
it.each([1.3, 1.4, 1.5, 1.6])(
'should process a query using a chat model (version %s)',
async (version) => {
// Mock a chat model that returns a predefined answer
const mockChatModel = new FakeChatModel({});
const params = {
promptType: 'define',
text: 'What is the capital of France?',
options: {},
};
const result = await node.execute.call(
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
);
// Check that the result contains the expected response (FakeChatModel returns the query as response)
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.response).toBeDefined();
let responseText = result[0][0].json.response;
if (version < 1.5 && typeof responseText === 'object') {
responseText = (responseText as { text: string }).text;
}
expect(responseText).toContain('You are an assistant for question-answering tasks'); // system prompt
expect(responseText).toContain('The capital of France is Paris.'); // context
expect(responseText).toContain('What is the capital of France?'); // query
},
);
it.each([1.3, 1.4, 1.5, 1.6])(
'should process a query using a text completion model (version %s)',
async (version) => {
// Mock a text completion model that returns a predefined answer
const mockTextModel = new FakeLLM({ response: 'Paris is the capital of France.' });
const modelCallSpy = jest.spyOn(mockTextModel, '_call');
const params = {
promptType: 'define',
text: 'What is the capital of France?',
options: {},
};
const result = await node.execute.call(
createExecuteFunctionsMock(params, mockTextModel, fakeRetriever, version),
);
// Check model was called with the correct query
expect(modelCallSpy).toHaveBeenCalled();
expect(modelCallSpy.mock.calls[0][0]).toEqual(
expect.stringContaining('Question: What is the capital of France?'),
);
// Check that the result contains the expected response
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
if (version < 1.5) {
expect((result[0][0].json.response as { text: string }).text).toContain(
'Paris is the capital of France.',
);
} else {
expect(result[0][0].json).toEqual({
response: 'Paris is the capital of France.',
});
}
},
);
it.each([1.3, 1.4, 1.5, 1.6])(
'should use a custom system prompt if provided (version %s)',
async (version) => {
const customSystemPrompt = `You are a geography expert. Use the following context to answer the question.
----------------
Context: {context}`;
// The chat model will return a response indicating it received the custom prompt
const mockChatModel = new FakeChatModel({});
const params = {
promptType: 'define',
text: 'What is the capital of France?',
options: {
systemPromptTemplate: customSystemPrompt,
},
};
const result = await node.execute.call(
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
if (version < 1.5) {
expect((result[0][0].json.response as { text: string }).text).toContain(
'You are a geography expert.',
);
} else {
expect(result[0][0].json.response).toContain('You are a geography expert.');
}
},
);
it.each([1.3, 1.4, 1.5, 1.6])(
'should throw an error if the query is undefined (version %s)',
async (version) => {
const mockChatModel = new FakeChatModel({});
const params = {
promptType: 'define',
text: undefined, // undefined query
options: {},
};
await expect(
node.execute.call(
createExecuteFunctionsMock(params, mockChatModel, fakeRetriever, version),
),
).rejects.toThrow(NodeOperationError);
},
);
it.each([1.3, 1.4, 1.5, 1.6])(
'should add error to json if continueOnFail is true (version %s)',
async (version) => {
// Create a model that will throw an error
class ErrorLLM extends FakeLLM {
async _call(): Promise<string> {
throw new UnexpectedError('Model error');
}
}
const errorModel = new ErrorLLM({});
const params = {
promptType: 'define',
text: 'What is the capital of France?',
options: {},
};
// Override continueOnFail to return true
const execMock = createExecuteFunctionsMock(params, errorModel, fakeRetriever, version);
execMock.continueOnFail = () => true;
const result = await node.execute.call(execMock);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toHaveProperty('error');
expect(result[0][0].json.error).toContain('Model error');
},
);
it('should process items in batches', async () => {
const mockChatModel = new FakeLLM({ response: 'Paris is the capital of France.' });
const items = [
{ json: { input: 'What is the capital of France?' } },
{ json: { input: 'What is the capital of France?' } },
{ json: { input: 'What is the capital of France?' } },
];
const execMock = createExecuteFunctionsMock(
{
promptType: 'define',
text: '={{ $json.input }}',
options: {
batching: {
batchSize: 2,
delayBetweenBatches: 0,
},
},
},
mockChatModel,
fakeRetriever,
1.6,
);
execMock.getInputData = () => items;
const result = await node.execute.call(execMock);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(3);
result[0].forEach((item) => {
expect(item.json.response).toBeDefined();
});
expect(result[0][0].json.response).toContain('Paris is the capital of France.');
expect(result[0][1].json.response).toContain('Paris is the capital of France.');
expect(result[0][2].json.response).toContain('Paris is the capital of France.');
});
it('should handle errors in batches with continueOnFail', async () => {
class ErrorLLM extends FakeLLM {
async _call(): Promise<string> {
throw new UnexpectedError('Model error');
}
}
const errorModel = new ErrorLLM({});
const items = [
{ json: { input: 'What is the capital of France?' } },
{ json: { input: 'What is the population of Paris?' } },
];
const execMock = createExecuteFunctionsMock(
{
promptType: 'define',
text: '={{ $json.input }}',
options: {
batching: {
batchSize: 2,
delayBetweenBatches: 0,
},
},
},
errorModel,
fakeRetriever,
1.6,
);
execMock.getInputData = () => items;
execMock.continueOnFail = () => true;
const result = await node.execute.call(execMock);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2);
result[0].forEach((item) => {
expect(item.json.error).toContain('Model error');
});
});
it('should respect delay between batches', async () => {
const mockChatModel = new FakeChatModel({});
const items = [
{ json: { input: 'What is the capital of France?' } },
{ json: { input: 'What is the population of Paris?' } },
{ json: { input: 'What is France known for?' } },
];
const execMock = createExecuteFunctionsMock(
{
promptType: 'define',
text: '={{ $json.input }}',
options: {
batching: {
batchSize: 2,
delayBetweenBatches: 100,
},
},
},
mockChatModel,
fakeRetriever,
1.6,
);
execMock.getInputData = () => items;
const startTime = Date.now();
await node.execute.call(execMock);
const endTime = Date.now();
// Should take at least 100ms due to delay between batches
expect(endTime - startTime).toBeGreaterThanOrEqual(100);
});
});