Skip to content

feat: add two-stage embedding retrieval and candidate ranking - #19

Merged
CoreyLeath-code merged 8 commits into
mainfrom
agent/two-stage-embedding-retrieval
Aug 11, 2026
Merged

feat: add two-stage embedding retrieval and candidate ranking#19
CoreyLeath-code merged 8 commits into
mainfrom
agent/two-stage-embedding-retrieval

Conversation

@CoreyLeath-code

@CoreyLeath-code CoreyLeath-code commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a real, backward-compatible two-stage serving boundary: exact embedding retrieval produces a bounded candidate pool, then the existing sequence model ranks only those candidates. It also adds an 8-minute diagram-based architecture walkthrough grounded in the implementation.

Architecture impact

  • Retrieval: ExactEmbeddingRetriever uses normalized item-embedding similarity over the current in-memory catalogue and excludes already seen items.
  • Ranking: DeepSequenceModel.rank_candidates runs the existing bidirectional-LSTM attention model and gathers scores only for the retrieval result.
  • Serving: the existing API controls remain in place; retrieval and candidate ranking run inside the existing admission-controlled inference path.
  • Reasoning boundary: no runtime reasoning or explanation service is claimed. The walkthrough states that this remains issue enhancement: add an evidence-backed recommendation explanation contract #16, pending a permitted-evidence and privacy design.

Files changed

  • app/core/retrieval.py — new exact, bounded candidate-retrieval contract.
  • app/core/model.py — candidate-only ranking method, preserving the existing full-catalogue recommend method for training compatibility.
  • app/api/routes.py — calls retrieval before candidate ranking.
  • app/core/config.py — adds RETRIEVAL_CANDIDATE_POOL_SIZE with a safe default.
  • tests/test_retrieval.py — validates seen-item exclusion, bounded candidate ordering, candidate-only ranking, and impossible requests.
  • docs/architecture-walkthrough.md — 8-minute diagram/script explaining two-stage retrieval vs. ranking, the absent reasoning boundary, and tradeoffs.

Validation performed

  • Compared the branch with main; the diff is limited to the retrieval/ranking boundary, config, focused tests, and implementation-grounded walkthrough.
  • No local test result is claimed: this environment cannot create a usable Git checkout and its local gh credentials are stale.
  • GitHub Actions on this draft PR are the required clean-runner validation gate.

Risks

  • The default retriever is an exact in-memory vector scan. It establishes the correct interface but is not FAISS/ANN and should not be presented as scalable retrieval.
  • Retrieval recall, ranking quality, and latency are not measured by this PR; no new performance or quality metric is claimed.
  • RETRIEVAL_CANDIDATE_POOL_SIZE must be calibrated against a leakage-safe evaluation set before a production configuration change.

Follow-up recommendations

  1. Add a versioned ANN index backend only after index build/refresh, compatibility, and retrieval-recall evaluation are implemented.
  2. Compare end-to-end two-stage quality against the existing popularity baseline on temporal splits.
  3. Implement issue enhancement: add an evidence-backed recommendation explanation contract #16 as a separate privacy-reviewed explanation contract; do not expose hidden-state or chain-of-thought reasoning.

Refs #13, #16

Summary by CodeRabbit

  • New Features
    • Added a two-stage recommendation flow that retrieves relevant candidates before ranking them.
    • Added configurable candidate-pool sizing, defaulting to 100 items.
    • Recommendations continue to exclude previously viewed items and honor requested result limits.
  • Documentation
    • Added an architecture walkthrough describing retrieval, ranking, configuration, and preserved API safeguards.
  • Bug Fixes
    • Improved validation for invalid sequences, candidate limits, and unavailable recommendations.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The recommendation API now uses exact embedding retrieval to build a bounded candidate pool. DeepSequenceModel ranks only those candidates. Configuration, validation, tests, and architecture documentation cover the two-stage flow.

Changes

Recommendation pipeline

Layer / File(s) Summary
Exact embedding retrieval
app/core/retrieval.py, tests/test_retrieval.py
Adds validated exact retrieval with normalized history embeddings, exclusion handling, candidate bounds, and deterministic retrieval tests.
Candidate ranking and API integration
app/core/model.py, app/core/config.py, app/api/routes.py, docs/architecture-walkthrough.md, tests/test_retrieval.py
Adds candidate-only ranking, configures the retrieval pool, wires retrieval before ranking in the API, and documents the two-stage sequence with ranking validation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RecommendationAPI
  participant ExactEmbeddingRetriever
  participant DeepSequenceModel
  RecommendationAPI->>ExactEmbeddingRetriever: retrieve candidates with history, top_k, and exclusions
  ExactEmbeddingRetriever-->>RecommendationAPI: return candidate IDs
  RecommendationAPI->>DeepSequenceModel: rank candidate IDs
  DeepSequenceModel-->>RecommendationAPI: return ranked recommendation IDs
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the two-stage embedding retrieval and candidate ranking added by the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/two-stage-embedding-retrieval

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@CoreyLeath-code
CoreyLeath-code marked this pull request as ready for review August 11, 2026 21:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71ce8cd91c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/core/retrieval.py
Comment on lines +63 to +66
query = model.embedding(history_ids[known_mask]).mean(dim=0)
query = F.normalize(query, dim=0, eps=1e-12)
catalogue = F.normalize(
model.embedding.weight[1 : model.num_items + 1],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate the retrieval objective before using it in serving

When the catalogue exceeds the default 100-candidate pool, this cosine search can discard the model's highest-scoring items because training only optimizes the independent output_proj logits; it does not align averaged input embeddings with next-item retrieval. Moreover, src/training/train.py::model_predictions still evaluates and promotes bundles through full-catalogue model.recommend, so the quality metrics do not cover the path now used by the API. Evaluate the complete retrieval/ranking path for promotion or train an aligned retrieval objective before enabling this truncation.

Useful? React with 👍 / 👎.

Comment thread app/core/model.py
if not 1 <= top_k <= len(unique_candidates):
raise ValueError("top_k must not exceed the candidate pool")

logits = self.forward(item_seq)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict the output projection to retrieved candidates

For large catalogues, self.forward(item_seq) still computes output_proj logits for every item before the method gathers candidate scores, so the configured candidate-pool size does not bound ranking work. Since retrieval also scans the entire embedding table, this serving path adds another catalogue-linear pass without eliminating the original one and can increase latency or trigger the existing latency fallback. Compute the sequence context separately and apply only the selected rows of the projection to make candidate ranking actually bounded.

Useful? React with 👍 / 👎.

@CoreyLeath-code
CoreyLeath-code merged commit 30e99de into main Aug 11, 2026
12 of 13 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/core/model.py`:
- Around line 106-113: The rank_candidates flow in app/core/model.py (lines
106-113) should reuse the encoder context and apply the output projection only
to unique_candidates instead of calling forward to compute all item logits;
preserve candidate_scores and top_k behavior. Update
docs/architecture-walkthrough.md (lines 9-13) to describe the candidate-only
projection implementation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e52631b9-9fee-4031-a10f-3682d1970a05

📥 Commits

Reviewing files that changed from the base of the PR and between efbe5f4 and 71ce8cd.

📒 Files selected for processing (6)
  • app/api/routes.py
  • app/core/config.py
  • app/core/model.py
  • app/core/retrieval.py
  • docs/architecture-walkthrough.md
  • tests/test_retrieval.py

Comment thread app/core/model.py
Comment on lines +106 to +113
logits = self.forward(item_seq)
candidates = torch.tensor(
unique_candidates,
dtype=torch.long,
device=logits.device,
)
candidate_scores = logits[0, candidates]
positions = torch.topk(candidate_scores, k=top_k).indices

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'def forward|output_proj|def rank_candidates|candidate_scores' app/core/model.py
rg -n -C 4 'dense output projection|richer scoring capacity|candidate' \
  docs/architecture-walkthrough.md

Repository: CoreyLeath-code/DeepSequence-Recommender

Length of output: 5293


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- model.py ---'
sed -n '1,155p' app/core/model.py

printf '%s\n' '--- rank_candidates call sites ---'
rg -n -C 5 'rank_candidates\(' app tests 2>/dev/null || true

printf '%s\n' '--- model tests and projection assumptions ---'
rg -n -C 4 'DeepSequenceModel|output_proj|rank_candidates|recommend\(' app tests 2>/dev/null || true

Repository: CoreyLeath-code/DeepSequence-Recommender

Length of output: 23908


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

model = ast.parse(Path("app/core/model.py").read_text(encoding="utf-8"))
routes = ast.parse(Path("app/api/routes.py").read_text(encoding="utf-8"))

def method(name):
    for node in ast.walk(model):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
            return node
    raise SystemExit(f"missing method: {name}")

forward = method("forward")
rank = method("rank_candidates")

def calls(node, attr):
    return [
        n for n in ast.walk(node)
        if isinstance(n, ast.Call)
        and isinstance(n.func, ast.Attribute)
        and n.func.attr == attr
    ]

forward_projection = [
    n for n in ast.walk(forward)
    if isinstance(n, ast.Call)
    and isinstance(n.func, ast.Attribute)
    and n.func.attr == "output_proj"
]
rank_forward = calls(rank, "forward")
rank_candidate_index = [
    n for n in ast.walk(rank)
    if isinstance(n, ast.Subscript)
    and isinstance(n.value, ast.Name)
    and n.value.id == "logits"
]
route_rank = [
    n for n in ast.walk(routes)
    if isinstance(n, ast.Call)
    and isinstance(n.func, ast.Attribute)
    and n.func.attr == "rank_candidates"
]

print({
    "forward_output_projection_calls": len(forward_projection),
    "rank_candidates_forward_calls": len(rank_forward),
    "rank_candidates_logits_subscripts": len(rank_candidate_index),
    "api_rank_candidates_calls": len(route_rank),
    "projection_output_width": "num_items + 1",
    "candidate_validation": "1 <= candidate <= num_items",
})
PY

Repository: CoreyLeath-code/DeepSequence-Recommender

Length of output: 424


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

model = ast.parse(Path("app/core/model.py").read_text(encoding="utf-8"))
routes = ast.parse(Path("app/api/routes.py").read_text(encoding="utf-8"))

classes = {
    node.name: node
    for node in model.body
    if isinstance(node, ast.ClassDef)
}
deep_sequence = classes["DeepSequenceModel"]
methods = {
    node.name: node
    for node in deep_sequence.body
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
forward = methods["forward"]
rank = methods["rank_candidates"]

def attr_calls(node, name):
    return [
        n for n in ast.walk(node)
        if isinstance(n, ast.Call)
        and isinstance(n.func, ast.Attribute)
        and n.func.attr == name
    ]

projection_calls = [
    n for n in ast.walk(forward)
    if isinstance(n, ast.Call)
    and isinstance(n.func, ast.Attribute)
    and n.func.attr == "output_proj"
]
rank_logits_subscripts = [
    n for n in ast.walk(rank)
    if isinstance(n, ast.Subscript)
    and isinstance(n.value, ast.Name)
    and n.value.id == "logits"
]
route_rank_calls = [
    n for n in ast.walk(routes)
    if isinstance(n, ast.Call)
    and isinstance(n.func, ast.Attribute)
    and n.func.attr == "rank_candidates"
]

print({
    "deep_sequence_forward_output_projection_calls": len(projection_calls),
    "rank_candidates_forward_calls": len(attr_calls(rank, "forward")),
    "rank_candidates_logits_subscripts": len(rank_logits_subscripts),
    "api_rank_candidates_calls": len(route_rank_calls),
    "projection_output_width": "num_items + 1",
    "candidate_validation": "1 <= candidate <= num_items",
})
PY

Repository: CoreyLeath-code/DeepSequence-Recommender

Length of output: 438


Restrict output projection to candidate IDs.

rank_candidates calls forward, which computes num_items + 1 logits before gathering candidates. Reuse the encoder context and project only the candidate rows. Update docs/architecture-walkthrough.md to match the implementation until this change is complete.

📍 Affects 2 files
  • app/core/model.py#L106-L113 (this comment)
  • docs/architecture-walkthrough.md#L9-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/model.py` around lines 106 - 113, The rank_candidates flow in
app/core/model.py (lines 106-113) should reuse the encoder context and apply the
output projection only to unique_candidates instead of calling forward to
compute all item logits; preserve candidate_scores and top_k behavior. Update
docs/architecture-walkthrough.md (lines 9-13) to describe the candidate-only
projection implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant