diff --git a/ee/app/services/sbom/ingestion/tasks/base.rb b/ee/app/services/sbom/ingestion/tasks/base.rb
index 65e1f9d982cb9c830483a3d259715b15533b065b..2e490be86345c8c685360d449215c8ec0c40e9d8 100644
--- a/ee/app/services/sbom/ingestion/tasks/base.rb
+++ b/ee/app/services/sbom/ingestion/tasks/base.rb
@@ -4,6 +4,9 @@ module Sbom
   module Ingestion
     module Tasks
       class Base
+        include Gitlab::Utils::StrongMemoize
+        include Gitlab::Ingestion::BulkInsertableTask
+
         def self.execute(pipeline, occurrence_maps)
           new(pipeline, occurrence_maps).execute
         end
@@ -13,15 +16,53 @@ def initialize(pipeline, occurrence_maps)
           @occurrence_maps = occurrence_maps
         end
 
-        def execute
-          raise NoMethodError, "Implement the `execute` template method!"
-        end
-
         private
 
         attr_reader :pipeline, :occurrence_maps
 
         delegate :project, to: :pipeline, private: true
+
+        def insertable_maps
+          occurrence_maps
+        end
+
+        def each_pair
+          validate_unique_by!
+
+          return_data.each do |row|
+            occurrence_maps_for_row(row).each { |map| yield map, row }
+          end
+        end
+
+        def occurrence_maps_for_row(row)
+          indexed_occurrence_maps[grouping_key_for_row(row)]
+        end
+
+        def indexed_occurrence_maps
+          insertable_maps.group_by { |map| grouping_key_for_map(map) }
+        end
+        strong_memoize_attr :indexed_occurrence_maps
+
+        def grouping_key_for_map(occurrence_map)
+          occurrence_map.to_h.values_at(*unique_by)
+        end
+
+        def unique_attr_indices
+          unique_by.map { |attr| uses.find_index(attr) }
+        end
+        strong_memoize_attr :unique_attr_indices
+
+        def grouping_key_for_row(row)
+          unique_attr_indices.map { |index| row[index] }
+        end
+
+        def validate_unique_by!
+          raise ArgumentError, '#each_pair can only be used with unique_by attributes' if unique_by.blank?
+
+          return if unique_by.all? { |attr| uses.include?(attr) }
+
+          raise ArgumentError, 'All unique_by attributes must be included in returned columns'
+        end
       end
     end
   end
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_component_versions.rb b/ee/app/services/sbom/ingestion/tasks/ingest_component_versions.rb
index 1753f2a09084376097bdf547524283d8bf1a22e1..70496c4368c52b21ad0fe16e075cf4eaf5db0abe 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_component_versions.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_component_versions.rb
@@ -4,8 +4,6 @@ module Sbom
   module Ingestion
     module Tasks
       class IngestComponentVersions < Base
-        include Gitlab::Ingestion::BulkInsertableTask
-
         COMPONENT_VERSION_ATTRIBUTES = %i[component_id version].freeze
 
         self.model = Sbom::ComponentVersion
@@ -15,33 +13,19 @@ class IngestComponentVersions < Base
         private
 
         def after_ingest
-          return_data.each do |component_version_id, component_id, version|
-            maps_with(component_id, version)&.each do |occurrence_map|
-              occurrence_map.component_version_id = component_version_id
-            end
+          each_pair do |occurrence_map, row|
+            occurrence_map.component_version_id = row.first
           end
         end
 
         def attributes
-          valid_occurrence_maps.map do |occurrence_map|
+          insertable_maps.map do |occurrence_map|
             occurrence_map.to_h.slice(*COMPONENT_VERSION_ATTRIBUTES)
           end
         end
 
-        def valid_occurrence_maps
-          @valid_occurrence_maps ||= occurrence_maps.filter(&:version_present?)
-        end
-
-        def maps_with(component_id, version)
-          grouped_maps[[component_id, version]]
-        end
-
-        def grouped_maps
-          @grouped_maps ||= valid_occurrence_maps.group_by do |occurrence_map|
-            report_component = occurrence_map.report_component
-
-            [occurrence_map.component_id, report_component.version]
-          end
+        def insertable_maps
+          super.filter(&:version_present?)
         end
       end
     end
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_components.rb b/ee/app/services/sbom/ingestion/tasks/ingest_components.rb
index a4ab246008439abc526cdedc556813803cd55aeb..280e86803ee1857aea4a4d39cb9c8d14c22e4570 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_components.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_components.rb
@@ -4,8 +4,6 @@ module Sbom
   module Ingestion
     module Tasks
       class IngestComponents < Base
-        include Gitlab::Ingestion::BulkInsertableTask
-
         COMPONENT_ATTRIBUTES = %i[name purl_type component_type].freeze
 
         self.model = Sbom::Component
@@ -15,28 +13,16 @@ class IngestComponents < Base
         private
 
         def after_ingest
-          return_data.each do |id, name, purl_type, component_type|
-            maps_with(name, purl_type, component_type)&.each do |occurrence_map|
-              occurrence_map.component_id = id
-            end
+          each_pair do |occurrence_map, row|
+            occurrence_map.component_id = row.first
           end
         end
 
         def attributes
-          occurrence_maps.map do |occurrence_map|
+          insertable_maps.map do |occurrence_map|
             occurrence_map.to_h.slice(*COMPONENT_ATTRIBUTES)
           end
         end
-
-        def maps_with(name, purl_type, component_type)
-          grouped_maps[[name, purl_type, component_type]]
-        end
-
-        def grouped_maps
-          @grouped_maps ||= occurrence_maps.group_by do |occurrence_map|
-            occurrence_map.to_h.values_at(:name, :purl_type, :component_type)
-          end
-        end
       end
     end
   end
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_occurrences.rb b/ee/app/services/sbom/ingestion/tasks/ingest_occurrences.rb
index 5be2c2bc9b7c010dbaa18f69d1735c191364ddfa..ba0aef6d424be018a54bcf9dec50f8c377b9b598 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_occurrences.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_occurrences.rb
@@ -4,18 +4,17 @@ module Sbom
   module Ingestion
     module Tasks
       class IngestOccurrences < Base
-        include Gitlab::Ingestion::BulkInsertableTask
         include Gitlab::Utils::StrongMemoize
 
         self.model = Sbom::Occurrence
-        self.unique_by = :uuid
-        self.uses = :id
+        self.unique_by = %i[uuid].freeze
+        self.uses = %i[id uuid].freeze
 
         private
 
         def after_ingest
-          return_data.each_with_index do |occurrence_id, index|
-            occurrence_maps[index].occurrence_id = occurrence_id
+          each_pair do |occurrence_map, row|
+            occurrence_map.occurrence_id = row.first
           end
         end
 
@@ -58,6 +57,10 @@ def uuid(occurrence_map)
           ::Sbom::OccurrenceUUID.generate(**uuid_attributes)
         end
 
+        def grouping_key_for_map(map)
+          [uuid(map)]
+        end
+
         def licenses
           Licenses.new(project, occurrence_maps)
         end
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_occurrences_vulnerabilities.rb b/ee/app/services/sbom/ingestion/tasks/ingest_occurrences_vulnerabilities.rb
index d74c4f1a8a420708f3abd2dc34ed3266ca054c4a..0c7e15d4eda1fe02283211b7ee3b4bdc2c6907f3 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_occurrences_vulnerabilities.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_occurrences_vulnerabilities.rb
@@ -4,15 +4,13 @@ module Sbom
   module Ingestion
     module Tasks
       class IngestOccurrencesVulnerabilities < Base
-        include Gitlab::Ingestion::BulkInsertableTask
-
         self.model = Sbom::OccurrencesVulnerability
-        self.unique_by = %i[sbom_occurrence_id vulnerability_id]
+        self.unique_by = %i[sbom_occurrence_id vulnerability_id].freeze
 
         private
 
         def attributes
-          occurrence_maps.flat_map do |occurrence_map|
+          insertable_maps.flat_map do |occurrence_map|
             occurrence_map.vulnerability_ids.map do |vulnerability_id|
               {
                 sbom_occurrence_id: occurrence_map.occurrence_id,
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_source_packages.rb b/ee/app/services/sbom/ingestion/tasks/ingest_source_packages.rb
index c2cd764e5e46c5e87aaa7607439d9859fd28b980..61a3b5be8a35408ba0939feadb02c62c19633d0b 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_source_packages.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_source_packages.rb
@@ -4,8 +4,6 @@ module Sbom
   module Ingestion
     module Tasks
       class IngestSourcePackages < Base
-        include Gitlab::Ingestion::BulkInsertableTask
-
         SOURCE_PACKAGE_ATTRIBUTES = %i[name purl_type].freeze
 
         self.model = Sbom::SourcePackage
@@ -15,15 +13,13 @@ class IngestSourcePackages < Base
         private
 
         def after_ingest
-          return_data.each do |source_package_id, source_package_name, purl_type|
-            maps_with(source_package_name, purl_type)&.each do |occurrence_map|
-              occurrence_map.source_package_id = source_package_id
-            end
+          each_pair do |occurrence_map, row|
+            occurrence_map.source_package_id = row.first
           end
         end
 
         def attributes
-          valid_occurrence_maps.map do |occurrence_map|
+          insertable_maps.map do |occurrence_map|
             {
               name: occurrence_map.source_package_name,
               purl_type: occurrence_map.purl_type
@@ -31,20 +27,12 @@ def attributes
           end
         end
 
-        def valid_occurrence_maps
-          @valid_occurrence_maps ||= occurrence_maps.filter(&:source_package_name)
-        end
-
-        def maps_with(source_package_name, purl_type)
-          grouped_maps[[source_package_name, purl_type]]
+        def insertable_maps
+          super.filter(&:source_package_name)
         end
 
-        def grouped_maps
-          @grouped_maps ||= valid_occurrence_maps.group_by do |occurrence_map|
-            report_component = occurrence_map.report_component
-
-            [report_component.source_package_name, report_component.purl_type]
-          end
+        def grouping_key_for_map(map)
+          [map.source_package_name, map.purl_type]
         end
       end
     end
diff --git a/ee/app/services/sbom/ingestion/tasks/ingest_sources.rb b/ee/app/services/sbom/ingestion/tasks/ingest_sources.rb
index 93061aad02854f1d68ac4e834a1988996928a387..c9e1cc8e9df6e5762c5dba34f54c8dd603a9f8cd 100644
--- a/ee/app/services/sbom/ingestion/tasks/ingest_sources.rb
+++ b/ee/app/services/sbom/ingestion/tasks/ingest_sources.rb
@@ -8,10 +8,8 @@ class IngestSources < Base
         # so all occurrence maps in the batch will use the same one.
         # This is likely to change in the future, so the interface
         # allows for multiple sources.
-        include Gitlab::Ingestion::BulkInsertableTask
-
         self.model = Sbom::Source
-        self.uses = :id
+        self.uses = %i[id].freeze
         self.unique_by = %i[source_type source].freeze
 
         private
diff --git a/ee/spec/services/sbom/ingestion/tasks/base_spec.rb b/ee/spec/services/sbom/ingestion/tasks/base_spec.rb
index 1d427a958c432a1409af3725adb0e09fdc529dd0..a6c17fb45841fc27811f2901832cbd7d85265650 100644
--- a/ee/spec/services/sbom/ingestion/tasks/base_spec.rb
+++ b/ee/spec/services/sbom/ingestion/tasks/base_spec.rb
@@ -5,9 +5,51 @@
 RSpec.describe Sbom::Ingestion::Tasks::Base, feature_category: :dependency_management do
   let(:pipeline) { instance_double('Ci::Pipeline') }
   let(:occurrence_maps) { [instance_double('Sbom::Ingestion::OccurrenceMap')] }
-  let(:implementation) { Class.new(described_class) }
 
-  it 'raises error when execute is not implemented' do
-    expect { implementation.execute(pipeline, occurrence_maps) }.to raise_error(NoMethodError)
+  describe '#each_pair' do
+    context 'when implementation does not have unique_by columns in uses' do
+      let(:implementation) do
+        Class.new(described_class) do
+          self.model = Sbom::ComponentVersion
+          self.unique_by = %i[component_id version].freeze
+          self.uses = %i[id].freeze
+
+          def execute
+            each_pair do |map, row|
+              map.id = row.first
+            end
+          end
+        end
+      end
+
+      it 'raises an ArgumentError' do
+        expect { implementation.execute(pipeline, occurrence_maps) }.to raise_error(
+          ArgumentError,
+          'All unique_by attributes must be included in returned columns'
+        )
+      end
+    end
+
+    context 'when implementation does not have unique_by' do
+      let(:implementation) do
+        Class.new(described_class) do
+          self.model = Sbom::ComponentVersion
+          self.uses = %i[id].freeze
+
+          def execute
+            each_pair do |map, row|
+              map.id = row.first
+            end
+          end
+        end
+      end
+
+      it 'raises an ArgumentError' do
+        expect { implementation.execute(pipeline, occurrence_maps) }.to raise_error(
+          ArgumentError,
+          '#each_pair can only be used with unique_by attributes'
+        )
+      end
+    end
   end
 end