From Pointwise to Generative: Recent NextRec Updates
6 min read
tl;dr:A record of recent NextRec updates and architectural refactors.
Source code for my recommender-systems project: NextRec
If you find it useful or interesting, a star would be greatly appreciated.
I did not update this blog much in 2026 because I spent a lot of time on product work and my own projects. As of April 12, 2026, the latest NextRec release is v0.6.7. This post covers the improvements and code changes I made during that period.
BaseModel and BaseMatchModel
NextRec originally followed a design similar to DeepCTR and torch-rechub, mainly for pointwise samples. DeepCTR has no logic designed for other settings and uses a separate DeepMatch package for pairwise scenarios. Torch-rechub instead changes the training paradigm through different trainers.
Based on those frameworks, NextRec started with BaseModel as the base class for pointwise CTR models and BaseMatchModel as the base class for two-tower retrieval models. The two differ mainly in sample construction and loss calculation. Pointwise training is simple: a single example can be taken from the batch. The latter must sample within the batch, build positive/negative pairs, and calculate similarity with two towers.
The early architecture was roughly as follows.
BaseModel:
get_input: fetch one samplecompile: configure the optimizer, loss function, and schedulercompute_loss: calculate lossfit: run complete trainingtrain_epoch: train one epoch
BaseMatchModel:
inbatch_logits: calculate a similarity matrix between every user embedding and item embedding in the batchcompute_similarity: output similarity for explicitly supplied user and item embeddings; unlike the former, this is not an all-pairs in-batch calculationcompute_loss: take positive and negative logits frominbatch_logits, then calculate loss
The design was logical, but it meant that each model could be assigned only one training paradigm. A DeepFM model inheriting from BaseModel, for example, could not be trained with a pairwise objective.
RQVAE and HSTU
Near the end of the year, I began introducing other modules. RQVAE is a representation-compression module: it combines reconstruction and quantization losses, produces discrete SIDs, and needs to persist its codebook. Because it could not fit the existing classes cleanly, I had to override forward and compute_loss just for it. That was the start of code duplication.
Later I added HSTU, which stacks multiple HSTU layers to output sequence IDs. It is a sequential recommender model and is incompatible with the earlier classification and regression tasks, so I had to add layers of patch logic in both the dataloader and BaseModel.
Refactor v1: Introducing Adapters
As the patch logic grew heavier, I tried an architectural refactor beginning with v0.6.1. Training configuration was split into two parameters:
training_mode: defines the optimization objective; supports pointwise, pairwise, and listwisesampling_mode: defines sample organization; supports explicit and in-batch sampling- at the model level, models stopped outputting probabilities for a task and instead returned raw logits
The main goal was to decouple models from training paradigms. This lets models such as DeepFM support contrastive learning. Every model can focus on its network architecture while the framework supports a broader range of experiments.
This refactor introduced adapters that add task-specific utilities to BaseModel: TrainingAdapter and TwoTowerAdapter. They support pointwise and two-tower scenarios, respectively, and implement format_model_output to normalize a model’s output into the format required by a task.
The rough structure was:
TrainingAdapter: adapter for conventional models
compute_loss: returnsNone, leaving loss calculation toBaseModelforward: uses the task-specificprediction_layerto standardize the model’s raw logits
TwoTowerAdapter: adapter for two-tower retrieval
prepare_list_input: turns raw input intolist_size,batch_size, andflat_input; the first two preserve the original structure whileflat_inputflattens tensors for the networkforward: restores the output using the previously recordedlist_sizeandbatch_sizesample_inbatch_negatives: returns all negative samples and usesmax_negativesto sample from them
In addition to adapters, I added GenerativeRetrievalHead to task heads for sequence-retrieval tasks.
BaseModel.__init__ then runs set_task_output to configure each task:
def set_task_output(self):
if self.training_modes[0] in {"pairwise", "listwise"} and self.sampling_mode == "explicit":
self.training_adapter = CandidateListAdapter()
else:
self.training_adapter = TrainingAdapter()
self.prediction_layer = None
if self.training_modes[0] != "pointwise":
return
task_type = self.task[0] if isinstance(self.task, list) else self.task
if task_type == "generative":
if not hasattr(self, "vocab_size"):
raise ValueError(
f"[{self.__class__.__name__}-head Error] task='generative' requires the model to define vocab_size before BaseModel initialization."
)
self.prediction_layer = GenerativeRetrievalHead(vocab_size=int(self.vocab_size), return_logits=True)
return
self.prediction_layer = TaskHead(task_type=self.task)
def format_model_output(self, raw_output: Any):
if self.training_modes[0] != "pointwise":
return raw_output
if isinstance(raw_output, torch.Tensor) and self.prediction_layer is not None:
return self.prediction_layer(raw_output)
return raw_output
After this refactor, a model could generally support different training paradigms. For some models, format_model_output still needs to be overridden to normalize the output.
For example, ESMM models CTCVR, so its logits must be processed before they are returned:
def format_model_output(self, raw_output):
if self.training_modes[0] != "pointwise":
return raw_output
preds = self.prediction_layer(raw_output)
ctr, cvr = preds.chunk(2, dim=1)
ctcvr = ctr * cvr
return torch.cat([ctr, ctcvr], dim=1)
Refactor v2: Further Decoupling
The first refactor reduced pressure on BaseModel by moving old patch logic into adapters, but BaseModel was still too heavy. The evaluator needed to accommodate different tasks; autoregressive tasks needed targets built from raw samples; and the data formats of intermediate layers needed consistent normalization. More importantly, the code was still ugly: redundant logic and internal protocols made it hard to read. Soon after v0.6.1, I began a second refactor.
Its core goals were:
- Manage training components separately: in addition to adapters, extract evaluators and losses.
- Standardize intermediate-layer protocols: use protocols to manage output fields for different task settings.
- Bring the training paradigms for pretrained representation models and sequential retrieval models into the framework.
To make this work, I wrote dedicated base classes for different model types rather than forcing everything to inherit from and override one BaseModel. I also established a uniform format for intermediate data.
The code has now been published to PyPI dev, but the problem of ugly code remains, so it needs another cleanup. That is largely a consequence of AI-generated code: the initial version used AI only sparingly, so its hierarchy and structure stayed relatively simple and readable. As I used AI more frequently, many pieces of logic were changed directly or indirectly. Resolving that is a major goal of this refactor.
Suzhou, April 12, 2026