{
  "id": "Ltr0qlNZ6EyR12Tl",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Rank resumes by job description similarity with Gemini embeddings",
  "tags": [],
  "nodes": [
    {
      "id": "5198c266-d677-42d7-8cc5-da8879415644",
      "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": "2fe21d29-6afa-4641-aea5-0c64780d6378",
      "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": "29f370bc-0c18-40c1-ac7d-7b3bf3cc4c5d",
      "name": "Show Results",
      "type": "n8n-nodes-base.form",
      "position": [
        1488,
        -80
      ],
      "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": "c7e52a97-87b6-4413-a8e8-868a81bea511",
      "name": "Convert PDF to Base64",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        592,
        -80
      ],
      "parameters": {
        "options": {},
        "operation": "binaryToPropery",
        "destinationKey": "base64",
        "binaryPropertyName": "resume"
      },
      "typeVersion": 1.1
    },
    {
      "id": "c6495fcc-0d28-4f2b-93b6-bb38024fdb64",
      "name": "Rank Resumes by Similarity",
      "type": "n8n-nodes-base.code",
      "position": [
        1248,
        -80
      ],
      "parameters": {
        "jsCode": "// 1. Grab the Job Description vector\nconst jdEmbedding = $('Create Job Description Embedding').first().json.embedding.values;\n\n// 2. Define the Cosine Similarity math engine\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\n// 3. Map through the parallel resume items and calculate their scores\nconst rankedItems = $input.all().map((item, index) => {\n\tconst resumeEmbedding = item.json.embedding.values;\n\tconst fileName = $('Split Resume PDFs').all()[index].binary.resume.fileName;\n\n\treturn {\n\t\tjson: {\n\t\t\tfileName,\n\t\t\tscore: cosineSimilarity(jdEmbedding, resumeEmbedding),\n\t\t},\n\t};\n});\n\n// 4. Sort the array from highest similarity score to lowest\nrankedItems.sort((a, b) => b.json.score - a.json.score);\n\n// 5. Output the cleanly indexed final rankings\nreturn rankedItems.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": "60b79173-c0d5-4e25-9c05-2a8113802beb",
      "name": "Create Job Description Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        128,
        -304
      ],
      "parameters": {
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"models/gemini-embedding-2\",\n  \"content\": {\n    \"parts\": [\n      {\n        \"text\": {{ JSON.stringify($('Resume Upload Form').item.json['Job Description']) }}\n      }\n    ]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googlePalmApi"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": false,
      "typeVersion": 4.4
    },
    {
      "id": "0252844a-930b-4197-91aa-404aa36c811c",
      "name": "Create Resume Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        848,
        -80
      ],
      "parameters": {
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"content\": {\n    \"parts\": [\n      {\n        \"inline_data\": {\n          \"mime_type\": \"application/pdf\",\n          \"data\": \"{{ $json.base64 }}\"\n        }\n      }\n    ]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googlePalmApi"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "48b9283e-b0f9-4985-93fa-edc9bb89f6b6",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1024,
        -816
      ],
      "parameters": {
        "width": 768,
        "height": 1376,
        "content": "## **Rank resumes by job description similarity with Gemini embeddings**\n\nThis workflow sorts uploaded resume PDFs by semantic similarity to a Job Description using Google Gemini multimodal embeddings.\n\nUnlike text-extraction workflows, this version does not extract resume text first and does not use a separate OCR branch. It converts each uploaded PDF into base64, sends the PDF content directly to Gemini Embedding 2, compares each resume vector against the Job Description vector with cosine similarity, and returns a ranked list of filenames.\n\nIt is useful for recruiters, HR teams, and hiring teams who want a simple PDF-to-vector resume sorter before manual review.\n\n## **Common use cases**\n\n- Sort resume PDFs against one Job Description\n- Compare text-based and scanned/image-based PDFs with one simpler flow\n- Prioritize manual resume review\n- Build a lightweight Gemini-native resume sorting workflow\n- Avoid LLM-based candidate scoring or explanation generation\n\n## **How it works**\n\n- User uploads resume PDFs and pastes a Job Description\n- The Job Description is embedded with `gemini-embedding-2`\n- Each resume PDF is converted into base64\n- Each PDF is sent directly to Gemini multimodal embeddings\n- Cosine similarity compares each resume vector to the Job Description vector\n- The workflow returns filenames sorted by similarity\n\n## **Setup steps**\n\n- Get a free Gemini API key from [Google AI Studio](https://aistudio.google.com/api-keys)\n- Create or connect a Google Gemini credential in n8n\n- Use that credential on:\n  - **Create Job Description Embedding**\n  - **Create Resume Embedding**\n- Make sure your Google account/API key can use `gemini-embedding-2`\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\nThis workflow is a sorter, not an automated hiring decision system. It does not accept, reject, explain, or score candidates for the user. Similarity values are used internally for sorting.\n\nThe final output shows only:\n- Rank\n- File Name\n\n## **Need Help?**\nHave questions or want to connect? Reach me on [LinkedIn](https://www.linkedin.com/in/athaahsan/)."
      },
      "typeVersion": 1
    },
    {
      "id": "a87d8aac-a300-4ebd-8fa2-5d5d420fe5b3",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        -816
      ],
      "parameters": {
        "width": 624,
        "height": 928,
        "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: **Google Gemini**.\n\nYou can get a free Gemini API key from [Google AI Studio](https://aistudio.google.com/api-keys).\n\nSet the Google Gemini credential on:\n- **Create Job Description Embedding**\n- **Create Resume Embedding**\n\nThe Job Description is embedded once using `gemini-embedding-2`.\n\nThe Split Resume PDFs node turns uploaded files into separate resume items while preserving the original filename."
      },
      "typeVersion": 1
    },
    {
      "id": "ce268823-702e-42ee-94d8-f88f31675829",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        464,
        -512
      ],
      "parameters": {
        "width": 608,
        "height": 624,
        "content": "## 2. Convert each PDF directly into an embedding\n\nEach uploaded resume PDF is converted into a base64 string.\n\nThe base64 PDF content is then sent directly to Gemini Embedding 2 as `inline_data` with MIME type `application/pdf`.\n\nThis version does not use:\n- PDF text extraction\n- OCR branching\n- resume summarization\n- LLM candidate review\n\nGemini creates one embedding vector for each resume PDF."
      },
      "typeVersion": 1
    },
    {
      "id": "0702022b-1ced-4198-9a91-0fdcc429aa75",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1104,
        -512
      ],
      "parameters": {
        "width": 624,
        "height": 624,
        "content": "## 3. Compare vectors and return sorted filenames\n\nThe Code node compares:\n- Job Description embedding\n- Each resume PDF embedding\n\nIt uses cosine similarity to sort resumes from most similar to least similar.\n\nThe final result only shows:\n- Rank\n- File Name\n\nThe similarity score is used internally for sorting and is not shown in the final form response."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "c6745b78-6163-4b06-af86-bd08e6a18e43",
  "nodeGroups": [],
  "connections": {
    "Split Resume PDFs": {
      "main": [
        [
          {
            "node": "Convert PDF to Base64",
            "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
          }
        ]
      ]
    },
    "Convert PDF to Base64": {
      "main": [
        [
          {
            "node": "Create Resume Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Resume Embedding": {
      "main": [
        [
          {
            "node": "Rank Resumes by Similarity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Resumes by Similarity": {
      "main": [
        [
          {
            "node": "Show Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Job Description Embedding": {
      "main": [
        []
      ]
    }
  }
}