AutomationFlowsAI & RAG › Rank Resumes by Job Description Similarity Using Openrouter Embeddings

Rank Resumes by Job Description Similarity Using Openrouter Embeddings

ByAtha Ahsan Xavier Haris @athaahsan on n8n.io

This workflow collects resume PDFs and a job description through an n8n form, extracts text (with OCR for scanned PDFs), generates embeddings via OpenRouter, and ranks the resumes by cosine similarity to the job description, returning a CSV-style list of ranked filenames.…

Event trigger★★★★☆ complexity19 nodesForm TriggerHTTP RequestForm
AI & RAG Trigger: Event Nodes: 19 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16681 — we link there as the canonical source.

This workflow follows the Form → Form Trigger 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": "jPLJrXma8ONDTEjT",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Rank resumes by job description similarity with OpenRouter embeddings",
  "tags": [],
  "nodes": [
    {
      "id": "3d4b0ff6-bcc1-46a5-a7a0-58539408f0fd",
      "name": "Resume Upload Form",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        -128,
        -80
      ],
      "parameters": {
        "options": {
          "buttonLabel": "Sort Resumes"
        },
        "formTitle": "Sort Resumes by Job Description Similarity",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "Resumes",
              "requiredField": true,
              "acceptFileTypes": ".pdf"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Job Description",
              "requiredField": true
            }
          ]
        },
        "responseMode": "lastNode",
        "formDescription": "Upload resume PDFs and paste the job description. The workflow will return a CSV-formatted ranking of resumes by semantic similarity."
      },
      "typeVersion": 2.6
    },
    {
      "id": "5667dc28-a232-406f-b95a-11f41d11c53c",
      "name": "Create Job Description Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        128,
        -272
      ],
      "parameters": {
        "url": "https://openrouter.ai/api/v1/embeddings",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"openai/text-embedding-3-large\",\n  \"input\": {{ JSON.stringify($('Resume Upload Form').item.json['Job Description']) }},\n  \"encoding_format\": \"float\"\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "HTTP-Referer",
              "value": "https://n8n.io"
            },
            {
              "name": "X-Title",
              "value": "Sort Resumes by Job Description Similarity"
            }
          ]
        },
        "nodeCredentialType": "openRouterApi"
      },
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": false,
      "typeVersion": 4.4
    },
    {
      "id": "4181a904-ce90-4a32-b1b4-30d1a27a69ce",
      "name": "Split Resume PDFs",
      "type": "n8n-nodes-base.code",
      "position": [
        128,
        -80
      ],
      "parameters": {
        "jsCode": "const formItem = $input.first();\n\nconst binary = formItem.binary || {};\n\nconst resumeKeys = Object.keys(binary).filter((key) =>\n\tkey === 'Resumes' ||\n\tkey.startsWith('Resumes_') ||\n\tkey.startsWith('Resumes.')\n);\n\nif (resumeKeys.length === 0) {\n\tthrow new Error('No resume PDFs were uploaded.');\n}\n\nreturn resumeKeys.map((key) => {\n\tconst file = binary[key];\n\n\tconst index = Number(key.replace('Resumes_', ''));\n\n\treturn {\n\t\tjson: {\n\t\t\tfileName: formItem.json.Resumes[index].filename,\n\t\t},\n\t\tbinary: {\n\t\t\tresume: file,\n\t\t},\n\t};\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "cf44b2cc-751e-4fca-b5e9-ff051c9ec69d",
      "name": "Process Each Resume",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        480,
        -80
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "323cfc1a-9dfd-4c44-9d1d-1ae69c6b3403",
      "name": "Convert PDF to Base64",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        720,
        -64
      ],
      "parameters": {
        "options": {},
        "operation": "binaryToPropery",
        "destinationKey": "base64",
        "binaryPropertyName": "resume"
      },
      "typeVersion": 1.1
    },
    {
      "id": "0e9981b9-2497-4a8a-8ff3-9595aa4966ae",
      "name": "Restore PDF Binary",
      "type": "n8n-nodes-base.code",
      "position": [
        944,
        -64
      ],
      "parameters": {
        "jsCode": "const item = $input.first();\n\nreturn [\n  {\n    binary: {\n      resume: {\n        data: item.json.base64,\n        mimeType: 'application/pdf',\n        fileName: item.json.fileName || $('Process Each Resume').item.json.fileName || 'resume.pdf',\n        fileExtension: 'pdf',\n      },\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "d789d08a-8946-4330-916b-10f3c78cdd78",
      "name": "Extract PDF Text",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        1168,
        -64
      ],
      "parameters": {
        "options": {},
        "operation": "pdf",
        "binaryPropertyName": "resume"
      },
      "typeVersion": 1.1
    },
    {
      "id": "df43bfa0-0e3b-41b6-9648-4c4507e5c0bd",
      "name": "Is Scanned PDF?",
      "type": "n8n-nodes-base.if",
      "position": [
        1376,
        -64
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "7188d932-d63c-412f-a3cc-c8545624b57b",
              "operator": {
                "type": "number",
                "operation": "lt"
              },
              "leftValue": "={{ ($json.text || '').trim().length }}",
              "rightValue": 128
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "86b62467-66da-4c24-93b0-90d53a7218a0",
      "name": "OCR Scanned PDF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1584,
        -224
      ],
      "parameters": {
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "method": "POST",
        "options": {},
        "jsonBody": "={{\n{\n  model: \"google/gemma-4-31b-it\",\n  messages: [\n    {\n      role: \"user\",\n      content: [\n        {\n          type: \"text\",\n          text: \"Extract all readable text from this resume PDF exactly as written. Return only the extracted text. Do not summarize, explain, or add comments.\"\n        },\n        {\n          type: \"file\",\n          file: {\n            filename: $('Process Each Resume').item.json.fileName,\n            file_data: \"data:application/pdf;base64,\" + $('Convert PDF to Base64').item.json.base64\n          }\n        }\n      ]\n    }\n  ],\n  plugins: [\n    {\n      id: \"file-parser\",\n      pdf: {\n        engine: \"mistral-ocr\"\n      }\n    }\n  ]\n}\n}}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi"
      },
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "046324cf-cd44-40c8-8803-8435e86cd0bd",
      "name": "Create Resume Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1888,
        -48
      ],
      "parameters": {
        "url": "https://openrouter.ai/api/v1/embeddings",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"openai/text-embedding-3-large\",\n  \"input\": {{ JSON.stringify($json.text) }},\n  \"encoding_format\": \"float\"\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "HTTP-Referer",
              "value": "https://n8n.io"
            },
            {
              "name": "X-Title",
              "value": "Sort Resumes by Job Description Similarity"
            }
          ]
        },
        "nodeCredentialType": "openRouterApi"
      },
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "dbeee453-17a4-4558-bace-8481d595cd08",
      "name": "Create Resume Embedding (OCR)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1888,
        -224
      ],
      "parameters": {
        "url": "https://openrouter.ai/api/v1/embeddings",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"openai/text-embedding-3-large\",\n  \"input\": {{ JSON.stringify($json.choices[0].message.content) }},\n  \"encoding_format\": \"float\"\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "HTTP-Referer",
              "value": "https://n8n.io"
            },
            {
              "name": "X-Title",
              "value": "Sort Resumes by Job Description Similarity"
            }
          ]
        },
        "nodeCredentialType": "openRouterApi"
      },
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "261a831c-43cb-49da-9340-ceb3524730f2",
      "name": "Rank Resumes by Similarity",
      "type": "n8n-nodes-base.code",
      "position": [
        2160,
        -48
      ],
      "parameters": {
        "jsCode": "const jdEmbedding = $('Create Job Description Embedding').first().json.data[0].embedding;\n\nfunction cosineSimilarity(a, b) {\n\tlet dot = 0;\n\tlet normA = 0;\n\tlet normB = 0;\n\n\tfor (let i = 0; i < a.length; i++) {\n\t\tdot += a[i] * b[i];\n\t\tnormA += a[i] * a[i];\n\t\tnormB += b[i] * b[i];\n\t}\n\n\treturn dot / (Math.sqrt(normA) * Math.sqrt(normB));\n}\n\nreturn $input.all().map((item) => {\n\tconst resumeEmbedding = item.json.data[0].embedding;\n\tconst fileName = $('Process Each Resume').item.json.fileName;\n\n\treturn {\n\t\tjson: {\n\t\t\tfileName,\n\t\t\tscore: cosineSimilarity(jdEmbedding, resumeEmbedding),\n\t\t},\n\t};\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "dc2319b7-76d2-4225-acf1-653bcb0992ca",
      "name": "Generate Ranking",
      "type": "n8n-nodes-base.code",
      "position": [
        608,
        -464
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\n\nitems.sort((a, b) => b.json.score - a.json.score);\n\nreturn items.map((item, index) => ({\n\tjson: {\n\t\trank: index + 1,\n\t\tfileName: item.json.fileName,\n\t\tscore: item.json.score,\n\t},\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "6e5a95f4-5c0e-45df-98e7-62ae63734d73",
      "name": "Show Results",
      "type": "n8n-nodes-base.form",
      "position": [
        832,
        -464
      ],
      "parameters": {
        "options": {},
        "operation": "completion",
        "completionTitle": "Resume Ranking Complete",
        "completionMessage": "={{\n[\n  'Rank,File Name',\n  ...$input.all().map(item =>\n    `${item.json.rank},\"${item.json.fileName}\"`\n  )\n].join('\\n')\n}}"
      },
      "typeVersion": 2.5
    },
    {
      "id": "2e01ee79-7a79-4151-8604-6e37f459b2d2",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -928,
        -816
      ],
      "parameters": {
        "width": 672,
        "height": 1504,
        "content": "## **Rank resumes by job description similarity with OpenRouter embeddings**\n\nThis workflow sorts uploaded resume PDFs by semantic similarity to a Job Description using OpenAI-compatible embeddings through OpenRouter.\n\nIt does not use an LLM to review, score, explain, accept, or reject candidates. The workflow only extracts resume text, creates embeddings, compares each resume embedding against the Job Description embedding with cosine similarity, and returns a ranked list of filenames.\n\nIt is useful for recruiters, HR teams, and hiring teams who want a fast first-pass resume sorter before manual review.\n\n## **Common use cases:**\n\n- Sort multiple resume PDFs against one Job Description\n- Prioritize manual resume review\n- Compare text-based and scanned resume PDFs\n- Build a lightweight semantic resume sorting tool\n- Avoid LLM-based candidate scoring or explanations\n\n## **How it works**\n\n- User uploads resume PDFs and pastes a Job Description\n- The Job Description is converted into an embedding\n- Each resume PDF is processed one by one\n- Text-based PDFs are extracted directly\n- Scanned/image-based PDFs use OCR fallback\n- Each resume text is converted into an embedding\n- Cosine similarity compares each resume to the Job Description\n- The workflow returns filenames sorted by similarity\n\n## **Setup steps**\n\n- Create or connect one OpenRouter credential in n8n\n- Use that same OpenRouter credential on these nodes:\n  - **Create Job Description Embedding**\n  - **OCR Scanned PDF**\n  - **Create Resume Embedding**\n  - **Create Resume Embedding (OCR)**\n- Make sure your OpenRouter account can use:\n  - `openai/text-embedding-3-large` for embeddings\n  - `google/gemma-4-31b-it` for OCR text extraction\n  - OpenRouter's `file-parser` plugin with `mistral-ocr` for scanned PDFs\n- Upload one or more resume PDFs\n- Paste the Job Description\n- Run the workflow\n- Review the sorted filename list\n\n## **Notes**\n\nThe workflow is a sorter, not an automated hiring decision system. It does not show candidate scores by default and should be used only to support manual review.\n\nOCR is used only to extract text from scanned PDFs. It is not used to judge candidates.\n\n## **Need Help?**  \nHave questions or want to connect? Reach me on [LinkedIn](https://www.linkedin.com/in/athaahsan/)."
      },
      "typeVersion": 1
    },
    {
      "id": "4c25b7c9-2127-4934-b211-55b6c54a2387",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        -944
      ],
      "parameters": {
        "width": 560,
        "height": 1040,
        "content": "## 1. Upload resumes and embed the Job Description\n\nThe form collects:\n- One or more resume PDFs\n- One Job Description text\n\nOnly one credential is required for this workflow: **OpenRouter**.\n\nSet the OpenRouter credential on:\n- **Create Job Description Embedding**\n- **OCR Scanned PDF**\n- **Create Resume Embedding**\n- **Create Resume Embedding (OCR)**\n\nThe Job Description is embedded once using OpenRouter with `openai/text-embedding-3-large`.\n\nThe **Split Resume PDFs** node turns uploaded files into separate resume items while keeping:\n- file name\n- PDF binary data\n\nThe workflow uses a loop because each uploaded resume needs to keep its own filename, PDF data, extracted text, and embedding result together."
      },
      "typeVersion": 1
    },
    {
      "id": "d00c378d-4f61-4bac-9439-f41ba2ffe728",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        384,
        -272
      ],
      "parameters": {
        "width": 1392,
        "height": 1056,
        "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n## 2. Extract resume text, with OCR fallback\n\nEach resume is processed one at a time.\n\nStep by step:\n1. **Convert PDF to Base64** keeps a base64 copy for OCR.\n2. **Restore PDF Binary** recreates a normal binary field named `resume`.\n3. **Extract PDF Text** uses n8n's built-in PDF text extraction.\n4. **Is Scanned PDF?** checks whether the extracted text is shorter than 128 characters.\n5. Short or empty text is treated as scanned/image-based and sent to **OCR Scanned PDF**.\n6. Readable text skips OCR and goes directly to resume embedding.\n\nThe base64 + restore-binary step is intentional. It keeps a base64 copy available for the OCR request while also giving the PDF extraction node a normal binary file to read.\n\nThe OCR fallback uses OpenRouter's chat completions endpoint with:\n- model: `google/gemma-4-31b-it`\n- plugin: `file-parser`\n- OCR engine: `mistral-ocr`\n\nThis OCR branch sends the base64 PDF to OpenRouter and asks the model to return only the readable text from the resume.\n\nThe OCR step is only used to extract readable text from scanned PDFs. It is not used to evaluate candidates, generate feedback, score resumes, or make hiring decisions.\n\nThe original filename is kept throughout the loop so each embedding can be matched back to the uploaded PDF."
      },
      "typeVersion": 1
    },
    {
      "id": "91d14235-9099-4d51-b326-be322c682608",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        -272
      ],
      "parameters": {
        "width": 624,
        "height": 688,
        "content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n## 3. Embed resumes and compare similarity\n\nEach resume text is converted into an embedding using OpenRouter with `openai/text-embedding-3-large`.\n\nThe workflow compares each resume embedding against the Job Description embedding using cosine similarity.\n\nThe similarity score is used internally only for sorting. It is not shown in the final output by default."
      },
      "typeVersion": 1
    },
    {
      "id": "79b090de-8585-4b38-98b4-b78c7a303407",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        384,
        -816
      ],
      "parameters": {
        "width": 800,
        "height": 528,
        "content": "## 4. Return sorted filenames\n\nAfter all resumes are processed, the workflow sorts them from most similar to least similar.\n\nThe final result only shows:\n- Rank\n- File Name\n\nNo candidate names are extracted.\nNo explanations are generated.\nNo hiring decision is made.\nNo candidate is accepted or rejected automatically.\n\n\n\n\n\n\n\n"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "8e921498-5c85-4e11-b98c-a4298a0b9edd",
  "nodeGroups": [],
  "connections": {
    "Is Scanned PDF?": {
      "main": [
        [
          {
            "node": "OCR Scanned PDF",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Resume Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OCR Scanned PDF": {
      "main": [
        [
          {
            "node": "Create Resume Embedding (OCR)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract PDF Text": {
      "main": [
        [
          {
            "node": "Is Scanned PDF?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Ranking": {
      "main": [
        [
          {
            "node": "Show Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Resume PDFs": {
      "main": [
        [
          {
            "node": "Process Each Resume",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore PDF Binary": {
      "main": [
        [
          {
            "node": "Extract PDF Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resume Upload Form": {
      "main": [
        [
          {
            "node": "Split Resume PDFs",
            "type": "main",
            "index": 0
          },
          {
            "node": "Create Job Description Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Each Resume": {
      "main": [
        [
          {
            "node": "Generate Ranking",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Convert PDF to Base64",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert PDF to Base64": {
      "main": [
        [
          {
            "node": "Restore PDF Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Resume Embedding": {
      "main": [
        [
          {
            "node": "Rank Resumes by Similarity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Resumes by Similarity": {
      "main": [
        [
          {
            "node": "Process Each Resume",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Resume Embedding (OCR)": {
      "main": [
        [
          {
            "node": "Rank Resumes by Similarity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Job Description Embedding": {
      "main": [
        []
      ]
    }
  }
}

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

This workflow collects resume PDFs and a job description through an n8n form, extracts text (with OCR for scanned PDFs), generates embeddings via OpenRouter, and ranks the resumes by cosine similarity to the job description, returning a CSV-style list of ranked filenames.…

Source: https://n8n.io/workflows/16681/ — 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

This workflow collects resume PDFs and a job description via an n8n form, generates embeddings with Google Gemini, calculates cosine similarity between each resume and the job description, and returns

Form Trigger, Form, HTTP Request
AI & RAG

AI-powered workflow that transforms any article URL into platform-optimized social media posts for LinkedIn, Twitter (X), and Reddit. Uses Mozilla Readability for content extraction, multi-agent AI wi

OpenAI Chat, Supabase Vector Store, OpenAI Embeddings +8
AI & RAG

Inquiry-Agent. Uses @digitalocean/n8n-nodes-digitalocean-gradient-serverless-inference, stopAndError, googleDocs, gmail. Event-driven trigger; 88 nodes.

@Digitalocean/N8N Nodes Digitalocean Gradient Serverless Inference, Stop And Error, Google Docs +12
AI & RAG

Inquiry-Agent. Uses @digitalocean/n8n-nodes-digitalocean-gradient-serverless-inference, stopAndError, googleDocs, gmail. Event-driven trigger; 80 nodes.

@Digitalocean/N8N Nodes Digitalocean Gradient Serverless Inference, Stop And Error, Google Docs +11
AI & RAG

Inquiry-Agent. Uses @digitalocean/n8n-nodes-digitalocean-gradient-serverless-inference, stopAndError, googleDocs, gmail. Event-driven trigger; 77 nodes.

@Digitalocean/N8N Nodes Digitalocean Gradient Serverless Inference, Stop And Error, Google Docs +10