File size: 9,558 Bytes
481fbb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/usr/bin/env python3
"""Frozen document encoder for the Memory-LoRA hypernetwork.

Forked from Code2LoRA's ``create_dataset/embed_repos.py`` chunk/pool
pipeline (mean-pool chunks -> file vector -> weighted-mean+max repo vector),
retargeted from "repo files" to "document sections":

* Code2LoRA:   file_i        -> chunks -> mean-pool -> file vector
               repo          -> weighted-mean+max over file vectors
* Memory-LoRA: doc_section_i -> chunks -> mean-pool -> section vector
               doc           -> weighted-mean+max over section vectors

Same frozen encoder as the paper (Qwen3-Embedding-0.6B), same reasoning for
the weighting (content-distinctiveness via cosine-distance-from-mean +
log-size normalization) -- multi-section documents (e.g. the Code2LoRA paper
chunked into abstract/method/results/limitations) benefit from it exactly
the way multi-file repos did. Single-section synthetic fact-sheets degenerate
gracefully to a near-uniform weighting over their own chunks.

No gradient ever flows through this encoder; embeddings are precomputed once
and cached to parquet by ``scripts/build_doc_embeddings.py``.
"""

from __future__ import annotations

import re
from typing import List, Optional, Tuple

import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer

DEFAULT_EMBED_MODEL = "Qwen/Qwen3-Embedding-0.6B"

# Section-name heuristics (loose analogue of Code2LoRA's path up/down-weight
# lists). Neutral by default for synthetic single-section documents; the
# upweighted names matter for the multi-section Code2LoRA-paper document.
SECTION_UPWEIGHT = [
    r"abstract", r"result", r"conclusion", r"contribution",
]
SECTION_DOWNWEIGHT = [
    r"acknowledg", r"reference", r"appendix",
]

MIN_CHARS_FOR_FULL_WEIGHT = 200  # sections shorter than this are downweighted


# ---------------------------------------------------------------------------
# Chunking
# ---------------------------------------------------------------------------

def chunk_token_ids(token_ids: List[int], chunk_tokens: int, overlap: int) -> List[List[int]]:
    """Produce overlapping token windows (identical to Code2LoRA's version)."""
    if chunk_tokens <= 0:
        raise ValueError("chunk_tokens must be > 0")
    if overlap >= chunk_tokens:
        raise ValueError("chunk_overlap must be < chunk_tokens")
    chunks: List[List[int]] = []
    step = chunk_tokens - overlap
    n = len(token_ids)
    if n == 0:
        return chunks
    for start in range(0, n, step):
        end = min(start + chunk_tokens, n)
        window = token_ids[start:end]
        if len(window) < 16:
            continue
        chunks.append(window)
        if end >= n:
            break
    return chunks


# ---------------------------------------------------------------------------
# Embedding model wrapper
# ---------------------------------------------------------------------------

@torch.inference_mode()
def embed_texts(
    model: AutoModel,
    tokenizer: AutoTokenizer,
    texts: List[str],
    device: str,
    batch_size: int,
    max_length: int,
) -> torch.Tensor:
    """Return embeddings [N, D] using mean pooling over last_hidden_state."""
    all_vecs = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        enc = tokenizer(
            batch, padding=True, truncation=True,
            max_length=max_length, return_tensors="pt",
        )
        enc = {k: v.to(device) for k, v in enc.items()}
        out = model(**enc)
        last = out.last_hidden_state  # [B, T, H]
        mask = enc["attention_mask"].unsqueeze(-1)  # [B, T, 1]
        mean = (last * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
        all_vecs.append(mean.detach().cpu())
    if not all_vecs:
        return torch.empty((0, model.config.hidden_size))
    return torch.cat(all_vecs, dim=0)


# ---------------------------------------------------------------------------
# Pooling: chunks -> section -> document
# ---------------------------------------------------------------------------

def pool_section_embeddings(chunk_embs: torch.Tensor) -> Optional[torch.Tensor]:
    """chunk_embs [K, D] -> section_emb [D]"""
    if chunk_embs.numel() == 0:
        return None
    return chunk_embs.mean(dim=0)


def _section_name_bonus(section_name: str) -> float:
    s = section_name.lower()
    bonus = 0.0
    for pat in SECTION_DOWNWEIGHT:
        if re.search(pat, s):
            bonus -= 0.25
            break
    for pat in SECTION_UPWEIGHT:
        if re.search(pat, s):
            bonus += 0.15
            break
    return bonus


def compute_section_weights(
    section_embs: torch.Tensor,          # [S, D]
    section_char_counts: torch.Tensor,   # [S]
    section_names: List[str],
    a_distinct: float,
    b_size: float,
    tau: float,
) -> torch.Tensor:
    """
    Whole-doc, all-sections weighting:
      distinct_i = 1 - cos(s_i, mean_s)
      size_i     = normalized log(1+chars)
      score_i    = a_distinct * distinct_i + b_size * size_i + name_bonus_i + tiny_section_penalty
      w          = softmax(score / tau)
    Returns: w [S] sum=1
    """
    f_norm = F.normalize(section_embs, p=2, dim=-1)
    mean_f = F.normalize(f_norm.mean(dim=0, keepdim=True), p=2, dim=-1)
    cos = (f_norm * mean_f).sum(dim=-1).clamp(-1, 1)
    distinct = 1.0 - cos

    chars = section_char_counts.float().clamp(min=1)
    log_chars = torch.log1p(chars)
    if log_chars.numel() > 1:
        lo, hi = log_chars.min(), log_chars.max()
        size01 = (log_chars - lo) / (hi - lo + 1e-8)
    else:
        size01 = torch.ones_like(log_chars)

    name_bonus = torch.tensor([_section_name_bonus(n) for n in section_names],
                               dtype=torch.float32)
    tiny_scale = (chars / float(MIN_CHARS_FOR_FULL_WEIGHT)).clamp(max=1.0)
    tiny_bonus = torch.log(tiny_scale + 1e-6)

    score = (a_distinct * distinct.cpu() + b_size * size01.cpu()
             + name_bonus + 0.15 * tiny_bonus.cpu())
    return torch.softmax(score / max(tau, 1e-6), dim=0)


def pool_doc_embedding_weighted(
    section_embs: torch.Tensor,          # [S, D]
    section_char_counts: torch.Tensor,   # [S]
    section_names: List[str],
    a_distinct: float = 1.0,
    b_size: float = 0.5,
    tau: float = 0.5,
    alpha_mean: float = 1.0,
    beta_max: float = 1.0,
) -> Optional[torch.Tensor]:
    """Aggregate section embeddings into one document vector:
    concat(alpha_mean * weighted_mean, beta_max * max) -> [2D]. No final
    L2 normalization (matches Code2LoRA's repo-vector convention)."""
    if section_embs.numel() == 0:
        return None
    w = compute_section_weights(
        section_embs, section_char_counts, section_names,
        a_distinct, b_size, tau,
    ).to(section_embs.dtype)
    wmean = (section_embs * w.unsqueeze(-1)).sum(dim=0)
    vmax = section_embs.max(dim=0).values
    return torch.cat([alpha_mean * wmean, beta_max * vmax], dim=0)


# ---------------------------------------------------------------------------
# Main pipeline per document
# ---------------------------------------------------------------------------

def embed_document(
    sections: List[Tuple[str, str]],   # [(section_name, section_text), ...]
    model: AutoModel,
    tokenizer: AutoTokenizer,
    device: str,
    chunk_tokens: int = 4096,
    chunk_overlap: int = 512,
    batch_size: int = 4,
    a_distinct: float = 1.0,
    b_size: float = 0.5,
    tau: float = 0.5,
    alpha_mean: float = 1.0,
    beta_max: float = 1.0,
) -> Optional[torch.Tensor]:
    """One document = list of (name, text) sections (single-element for a
    plain synthetic fact-sheet; multi-element for the chunked paper).
    Returns a [2D] embedding, or None if the document had no usable text."""
    section_vectors: List[torch.Tensor] = []
    section_names: List[str] = []
    section_char_counts: List[int] = []

    for name, text in sections:
        text = (text or "").strip()
        if not text:
            continue
        ids = tokenizer.encode(text, add_special_tokens=False)
        windows = chunk_token_ids(ids, chunk_tokens=chunk_tokens, overlap=chunk_overlap)
        if not windows:
            continue
        chunks = [tokenizer.decode(w, skip_special_tokens=True) for w in windows]
        chunk_embs = embed_texts(
            model=model, tokenizer=tokenizer, texts=chunks,
            device=device, batch_size=batch_size, max_length=chunk_tokens,
        )
        svec = pool_section_embeddings(chunk_embs)
        if svec is None:
            continue
        section_vectors.append(svec)
        section_names.append(name)
        section_char_counts.append(len(text))

    if not section_vectors:
        return None

    section_embs = torch.stack(section_vectors, dim=0)
    char_t = torch.tensor(section_char_counts, dtype=torch.int64)
    return pool_doc_embedding_weighted(
        section_embs, char_t, section_names,
        a_distinct=a_distinct, b_size=b_size, tau=tau,
        alpha_mean=alpha_mean, beta_max=beta_max,
    )


def load_encoder(model_name: str = DEFAULT_EMBED_MODEL, device: str = "mps"):
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
    model.to(device)
    model.eval()
    return model, tokenizer


__all__ = [
    "DEFAULT_EMBED_MODEL",
    "chunk_token_ids",
    "embed_texts",
    "pool_section_embeddings",
    "compute_section_weights",
    "pool_doc_embedding_weighted",
    "embed_document",
    "load_encoder",
]