-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add two-stage embedding retrieval and candidate ranking #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
340d3bf
0c5f9c3
b11c936
08bcc36
27be5c1
317f97b
4911a52
71ce8cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,36 @@ def forward(self, item_seq: torch.Tensor) -> torch.Tensor: | |
| logits[:, self.padding_idx] = float("-inf") | ||
| return logits | ||
|
|
||
| @torch.no_grad() | ||
| def rank_candidates( | ||
| self, | ||
| item_seq: torch.Tensor, | ||
| candidate_ids: list[int], | ||
| *, | ||
| top_k: int, | ||
| ) -> list[int]: | ||
| """Apply the sequence ranker only to retrieval-stage candidates.""" | ||
|
|
||
| if item_seq.ndim != 2 or not item_seq.ne(self.padding_idx).any(): | ||
| raise ValueError("A recommendation requires at least one known item") | ||
| unique_candidates = list(dict.fromkeys(candidate_ids)) | ||
| if not unique_candidates: | ||
| raise ValueError("candidate_ids must not be empty") | ||
| if any(candidate < 1 or candidate > self.num_items for candidate in unique_candidates): | ||
| raise ValueError("candidate_ids must be known non-padding item IDs") | ||
| if not 1 <= top_k <= len(unique_candidates): | ||
| raise ValueError("top_k must not exceed the candidate pool") | ||
|
|
||
| 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 | ||
|
Comment on lines
+106
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.mdRepository: 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 || trueRepository: 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",
})
PYRepository: 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",
})
PYRepository: CoreyLeath-code/DeepSequence-Recommender Length of output: 438 Restrict output projection to candidate IDs.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| return candidates[positions].tolist() | ||
|
|
||
| @torch.no_grad() | ||
| def recommend( | ||
| self, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """Candidate retrieval for the two-stage recommendation serving path.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
|
|
||
| import torch | ||
| import torch.nn.functional as F | ||
|
|
||
| from app.core.model import DeepSequenceModel | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RetrievalResult: | ||
| """Candidate IDs emitted by a retriever before sequence-aware ranking.""" | ||
|
|
||
| candidate_ids: list[int] | ||
|
|
||
|
|
||
| class ExactEmbeddingRetriever: | ||
| """Retrieve a bounded candidate pool with normalized item-embedding similarity. | ||
|
|
||
| This is an exact in-memory retriever, not an ANN or FAISS implementation. It | ||
| establishes a replaceable retrieval boundary while keeping the default bundle | ||
| self-contained and deterministic for the current catalogue scale. | ||
| """ | ||
|
|
||
| def __init__(self, candidate_pool_size: int) -> None: | ||
| if candidate_pool_size < 1: | ||
| raise ValueError("candidate_pool_size must be at least one") | ||
| self.candidate_pool_size = candidate_pool_size | ||
|
|
||
| @torch.no_grad() | ||
| def retrieve( | ||
| self, | ||
| model: DeepSequenceModel, | ||
| item_sequence: torch.Tensor, | ||
| *, | ||
| top_k: int, | ||
| exclude_ids: list[int] | None = None, | ||
| ) -> RetrievalResult: | ||
| """Return eligible IDs ordered by embedding-similarity score.""" | ||
|
|
||
| if item_sequence.ndim != 2 or item_sequence.shape[0] != 1: | ||
| raise ValueError("Retrieval requires one padded recommendation sequence") | ||
| if not 1 <= top_k <= model.num_items: | ||
| raise ValueError(f"top_k must be between 1 and {model.num_items}") | ||
|
|
||
| history_ids = item_sequence[0] | ||
| known_mask = history_ids.ne(model.padding_idx) | ||
| if not known_mask.any(): | ||
| raise ValueError("Retrieval requires at least one known item") | ||
|
|
||
| excluded = { | ||
| item_id | ||
| for item_id in (exclude_ids or []) | ||
| if 1 <= item_id <= model.num_items | ||
| } | ||
| eligible_count = model.num_items - len(excluded) | ||
| if top_k > eligible_count: | ||
| raise ValueError("top_k exceeds the remaining eligible catalogue") | ||
|
|
||
| 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], | ||
|
Comment on lines
+63
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. |
||
| dim=1, | ||
| eps=1e-12, | ||
| ) | ||
| scores = torch.matmul(catalogue, query) | ||
| if excluded: | ||
| scores[[item_id - 1 for item_id in excluded]] = float("-inf") | ||
|
|
||
| candidate_count = min( | ||
| max(top_k, self.candidate_pool_size), | ||
| eligible_count, | ||
| ) | ||
| retrieved = torch.topk(scores, k=candidate_count).indices.add(1).tolist() | ||
| return RetrievalResult(candidate_ids=retrieved) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # Architecture Walkthrough: Two-Stage Serving | ||
|
|
||
| This is an 8-minute walkthrough script and diagram set for the current implementation. It describes | ||
| the code in this repository; it does not claim that an ANN index, external feature store, or runtime | ||
| reasoning service exists. | ||
|
|
||
| ## 0:00–1:00 — Problem and decision | ||
|
|
||
| The original serving path applied the sequence model's dense output projection to every catalogue | ||
| item. That is simple and correct for the current small bundle, but its work grows with catalogue | ||
| size and provides no replaceable candidate boundary. The two-stage design separates *recall* | ||
| from *precision*: retrieval produces a bounded set of plausible item IDs, then ranking spends the | ||
| sequence model's richer scoring capacity only on that set. | ||
|
|
||
| ```mermaid | ||
| flowchart LR | ||
| H["Known interaction history"] --> Q["Embedding query"] | ||
| Q --> R["Stage 1: exact embedding retrieval"] | ||
| R --> C["Bounded candidate IDs"] | ||
| C --> K["Stage 2: BiLSTM + attention ranking"] | ||
| H --> K | ||
| K --> O["Top-K recommendations"] | ||
| ``` | ||
|
|
||
| ## 1:00–3:00 — Retrieval is not ranking | ||
|
|
||
| `app/core/retrieval.py` implements `ExactEmbeddingRetriever`. It averages embeddings of known | ||
| history items, normalizes that query, compares it with normalized catalogue embeddings, excludes | ||
| items already seen, and returns a bounded candidate list. Its default is an exact in-memory vector | ||
| scan. That makes behavior reproducible and keeps model bundles self-contained, but it is **not** | ||
| FAISS, HNSW, or another approximate-nearest-neighbor index. | ||
|
|
||
| Retrieval optimizes candidate recall and cost. It can return items that are semantically or | ||
| behaviorally near the history, but it has no access to the full sequence-ordering signal used by | ||
| the ranker. The stable `RetrievalResult` contract is the seam where an ANN implementation can | ||
| later be added after index lifecycle, recall, freshness, and operational evidence are available. | ||
|
|
||
| ## 3:00–5:00 — Candidate-only ranking | ||
|
|
||
| `DeepSequenceModel.rank_candidates` runs the existing padding-aware bidirectional LSTM and | ||
| attention model, then gathers scores only for the retrieved IDs. This preserves the existing model | ||
| and training compatibility while making the ranking stage explicit. The API uses retrieval after | ||
| admission control and before decoding recommendations; cache, authentication, rate limits, and | ||
| fallback behavior remain unchanged. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant Client | ||
| participant API as FastAPI route | ||
| participant Retriever as ExactEmbeddingRetriever | ||
| participant Ranker as DeepSequenceModel | ||
|
|
||
| Client->>API: history + top_k | ||
| API->>API: validate, authorize, rate-limit, cache check | ||
| API->>Retriever: retrieve(history, exclusions, top_k) | ||
| Retriever-->>API: candidate_ids | ||
| API->>Ranker: rank_candidates(history, candidate_ids, top_k) | ||
| Ranker-->>API: ordered item IDs | ||
| API-->>Client: recommendations + model version + latency | ||
| ``` | ||
|
|
||
| The tradeoff is intentional: the first stage is logically separated but still scans all embeddings, | ||
| so it does not yet deliver the latency or memory profile of a production ANN system. Candidate-pool | ||
| size is configurable with `RETRIEVAL_CANDIDATE_POOL_SIZE`; it should be measured against | ||
| Recall@K and latency before changing it in production. | ||
|
|
||
| ## 5:00–6:30 — Reasoning and explanations | ||
|
|
||
| No runtime `reasoning/` package or per-recommendation explanation endpoint exists in the current | ||
| repository. That is deliberate in this walkthrough: an LSTM score is not a causal explanation, and | ||
| the service should not invent a user-facing reason from hidden states. Issue #16 records the | ||
| separate evidence-backed explanation contract, including privacy review and insufficient-history | ||
| handling. Until that work is implemented and tested, the only trustworthy response-level evidence | ||
| is model version, fallback state, cache state, and bounded request latency. | ||
|
|
||
| ## 6:30–8:00 — Engineering tradeoffs and next decisions | ||
|
|
||
| - **Exact retrieval now:** easy to test and bundle; unsuitable for large catalogues without an ANN | ||
| index and index-refresh lifecycle. | ||
| - **Sequence ranker retained:** preserves current training artifacts; a future ranker change needs | ||
| temporal evaluation against the popularity baseline. | ||
| - **No fabricated confidence:** offline ranking quality and per-recommendation confidence are | ||
| different measurements. | ||
| - **No hidden reasoning:** user-facing explanations must be constrained to permitted evidence, | ||
| not chain-of-thought or unvalidated causal language. | ||
| - **Safety preserved:** existing authentication, credential-derived rate limiting, admission | ||
| control, cache keying, fallback, and model-bundle checks remain the API boundary. | ||
|
|
||
| Before a large-catalogue deployment, add an evaluated ANN backend, version and validate its index | ||
| with the model bundle, measure candidate recall and end-to-end p95/p99 latency, and keep the | ||
| candidate-ranker contract stable during rollout. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import pytest | ||
| import torch | ||
|
|
||
| from app.core.model import DeepSequenceModel | ||
| from app.core.retrieval import ExactEmbeddingRetriever | ||
|
|
||
|
|
||
| def _model() -> DeepSequenceModel: | ||
| model = DeepSequenceModel( | ||
| num_items=4, | ||
| embedding_dim=2, | ||
| hidden_dim=2, | ||
| num_layers=1, | ||
| dropout=0.0, | ||
| ).eval() | ||
| with torch.no_grad(): | ||
| model.embedding.weight.copy_( | ||
| torch.tensor( | ||
| [ | ||
| [0.0, 0.0], | ||
| [1.0, 0.0], | ||
| [0.9, 0.1], | ||
| [0.0, 1.0], | ||
| [-1.0, 0.0], | ||
| ] | ||
| ) | ||
| ) | ||
| model.output_proj.weight.zero_() | ||
| model.output_proj.bias.copy_( | ||
| torch.tensor([float("-inf"), 0.1, 0.9, 0.2, 0.8]) | ||
| ) | ||
| return model | ||
|
|
||
|
|
||
| def test_embedding_retriever_excludes_history_and_bounds_candidate_pool() -> None: | ||
| retriever = ExactEmbeddingRetriever(candidate_pool_size=3) | ||
|
|
||
| result = retriever.retrieve( | ||
| _model(), | ||
| torch.tensor([[0, 0, 1]]), | ||
| top_k=1, | ||
| exclude_ids=[1], | ||
| ) | ||
|
|
||
| assert result.candidate_ids == [2, 3, 4] | ||
|
|
||
|
|
||
| def test_ranker_only_orders_retrieved_candidates() -> None: | ||
| ranked = _model().rank_candidates( | ||
| torch.tensor([[0, 0, 1]]), | ||
| [1, 3, 4], | ||
| top_k=2, | ||
| ) | ||
|
|
||
| assert ranked == [4, 3] | ||
|
|
||
|
|
||
| def test_retriever_rejects_an_impossible_request() -> None: | ||
| retriever = ExactEmbeddingRetriever(candidate_pool_size=2) | ||
|
|
||
| with pytest.raises(ValueError, match="remaining eligible"): | ||
| retriever.retrieve( | ||
| _model(), | ||
| torch.tensor([[0, 0, 1]]), | ||
| top_k=4, | ||
| exclude_ids=[1], | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For large catalogues,
self.forward(item_seq)still computesoutput_projlogits 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 👍 / 👎.