⌂ 全部教程 easyaioffer.github.io ⭐ Star

rag_embedding.py

配套教程:RAG + 向量检索 · EN

RAG + Embedding / Retrieval - minimal runnable implementation

在 GitHub 查看 原始 .py python rag_embedding.py
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
"""
RAG + Embedding / Retrieval - minimal runnable implementation
=============================================================

Educational PyTorch reference for the contrastive-embedding + retrieval pieces
of a RAG pipeline. Standalone script: runs six sanity checks on CPU in seconds.

Pairs with: docs/tutorials/rag_embedding_retrieval_tutorial.md (concept reference).

What it demonstrates:
    DualEncoder        - toy bi-encoder, L2-normalized -> dot product = cosine
    info_nce_in_batch  - InfoNCE with in-batch negatives (diagonal = positive)
    info_nce_with_hard - InfoNCE with explicit hard negatives
    bm25_scores / rrf  - sparse retrieval + reciprocal-rank fusion
    mini-RAG retrieval - hybrid (dense + BM25) fused recall

    Sanity checks:
      [a] in-batch InfoNCE: with d_pos == q, each row's argmax is its own index
      [b] encoder output is L2-normalized (||v|| ~ 1), dot in [-1, 1]
      [c] temperature: smaller tau -> lower softmax entropy (sharper)
      [d] info_nce_with_hard: finite loss, no NaN, logits shape [B, B+2]
      [e] RRF is monotone: improving a doc's rank never lowers its RRF score
      [f] hybrid recall keeps an exact-entity chunk that pure dense may miss

Run:
    python rag_embedding.py
"""
import hashlib
import math

import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)


# ---------- embedding side (contrastive) ----------

class DualEncoder(nn.Module):
    """Toy bi-encoder; real systems use a BERT-like tower. L2-normalized output."""

    def __init__(self, vocab, dim=128):
        super().__init__()
        self.emb = nn.EmbeddingBag(vocab, dim, mode="mean")
        self.proj = nn.Linear(dim, dim)

    def encode(self, ids, offsets):
        return F.normalize(self.proj(self.emb(ids, offsets)), dim=-1)


def info_nce_in_batch(q, d_pos, tau=0.05):
    """q, d_pos: [B, dim] normalized. In-batch negatives; diagonal is the positive."""
    logits = (q @ d_pos.t()) / tau                    # [B, B]
    labels = torch.arange(q.size(0), device=q.device)
    return F.cross_entropy(logits, labels)


def info_nce_with_hard(q, d_pos, d_hard, tau=0.05):
    """Append each query's own hard negative; positive sits at column 0."""
    pos = (q * d_pos).sum(-1, keepdim=True)           # [B, 1]
    in_batch = q @ d_pos.t()                          # [B, B]
    in_batch.fill_diagonal_(float("-inf"))            # drop self-positive
    hard = (q * d_hard).sum(-1, keepdim=True)         # [B, 1]
    logits = torch.cat([pos, in_batch, hard], dim=1) / tau   # [B, B+2]
    labels = torch.zeros(q.size(0), dtype=torch.long, device=q.device)
    return F.cross_entropy(logits, labels), logits


# ---------- retrieval side ----------

def toy_encode(text, dim=64):
    """Deterministic bag-of-words hashing encoder (no training needed)."""
    v = torch.zeros(dim)
    for w in text.lower().split():
        v[int(hashlib.md5(w.encode()).hexdigest(), 16) % dim] += 1.0
    return F.normalize(v, dim=-1)


def bm25_scores(query, docs, k1=1.5, b=0.75):
    toks = [d.lower().split() for d in docs]
    avgdl = sum(len(t) for t in toks) / len(toks)
    N = len(docs)
    df = {}
    for t in toks:
        for w in set(t):
            df[w] = df.get(w, 0) + 1
    out = []
    for d in toks:
        s, dl = 0.0, len(d)
        for w in set(query.lower().split()):
            if w not in df:
                continue
            idf = math.log(1 + (N - df[w] + 0.5) / (df[w] + 0.5))
            f = d.count(w)
            s += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * dl / avgdl))
        out.append(s)
    return out


def rrf(rank_lists, k=60):
    score = {}
    for ranks in rank_lists:
        for rank, idx in enumerate(ranks, start=1):
            score[idx] = score.get(idx, 0.0) + 1.0 / (k + rank)
    return sorted(score, key=score.get, reverse=True)


def softmax_entropy(logits):
    p = F.softmax(logits, dim=-1)
    return -(p * (p + 1e-12).log()).sum(-1).mean().item()


def main():
    B, dim = 6, 32

    # [a] in-batch InfoNCE: d_pos == q -> argmax on diagonal AND aligned loss is
    #     strictly lower than a misaligned (shuffled-positive) batch -> the CE/label
    #     wiring actually responds to alignment (not just the raw sim matrix).
    q = F.normalize(torch.randn(B, dim), dim=-1)
    d_pos = q.clone()
    logits = q @ d_pos.t()
    perm = torch.tensor([1, 2, 3, 4, 5, 0])            # deterministic shuffle
    loss_aligned = info_nce_in_batch(q, d_pos).item()
    loss_shuffled = info_nce_in_batch(q, d_pos[perm]).item()
    a_ok = bool((logits.argmax(1) == torch.arange(B)).all()) and loss_aligned < loss_shuffled
    print(f"[a] in-batch InfoNCE: argmax==idx, loss aligned {loss_aligned:.4f} < shuffled "
          f"{loss_shuffled:.4f}  {'OK' if a_ok else 'FAIL'}")
    assert a_ok

    # [b] the REAL DualEncoder output is L2-normalized; dot in [-1, 1]
    enc = DualEncoder(vocab=50, dim=dim)
    ids = torch.tensor([3, 7, 9, 2, 5, 1, 4, 8])       # flattened tokens
    offsets = torch.tensor([0, 3, 5])                  # 3 "documents"
    ev = enc.encode(ids, offsets)                      # [3, dim]
    b_ok = torch.allclose(ev.norm(dim=-1), torch.ones(ev.size(0)), atol=1e-5) \
        and logits.abs().max() <= 1 + 1e-5
    print(f"[b] DualEncoder ||v|| = {ev.norm(dim=-1).mean():.4f} (~1), max|dot| = "
          f"{logits.abs().max():.4f} (<=1)  {'OK' if b_ok else 'FAIL'}")
    assert b_ok

    # [c] temperature: smaller tau -> sharper softmax -> lower entropy
    row = (q @ d_pos.t())[0]
    e_sharp, e_soft = softmax_entropy(row / 0.05), softmax_entropy(row / 0.5)
    c_ok = e_sharp < e_soft
    print(f"[c] entropy(tau=0.05) = {e_sharp:.4f} < entropy(tau=0.5) = {e_soft:.4f}  "
          f"{'OK' if c_ok else 'FAIL'}")
    assert c_ok

    # [d] info_nce_with_hard: finite loss, no NaN, logits shape [B, B+2]
    d_hard = F.normalize(torch.randn(B, dim), dim=-1)
    loss_d, logits_d = info_nce_with_hard(q, d_pos, d_hard)
    d_ok = torch.isfinite(loss_d) and logits_d.shape == (B, B + 2)
    print(f"[d] with-hard loss = {loss_d.item():.4f} finite, logits {tuple(logits_d.shape)} == (B, B+2)  "
          f"{'OK' if d_ok else 'FAIL'}")
    assert d_ok

    # [e] RRF monotone: move doc 7 earlier in one list -> its RRF score must not drop
    base = [[3, 7, 1, 9], [5, 7, 2, 8]]
    better = [[7, 3, 1, 9], [5, 7, 2, 8]]            # doc 7 rank 2 -> 1 in list-1
    def rrf_score(lists, doc):
        return sum(1.0 / (60 + (lst.index(doc) + 1)) for lst in lists if doc in lst)
    # doc 7 is the only doc in BOTH lists -> rrf() must rank it first
    e_ok = rrf_score(better, 7) >= rrf_score(base, 7) and rrf(base)[0] == 7
    print(f"[e] RRF(doc7): base = {rrf_score(base,7):.5f} -> better = {rrf_score(better,7):.5f} "
          f"(non-decreasing); rrf top = {rrf(base)[0]}  {'OK' if e_ok else 'FAIL'}")
    assert e_ok

    # [f] hybrid recall keeps an exact-entity chunk pure dense may miss
    corpus = [
        "the cat sat on the warm mat in the sun",
        "device model Zephyrnaut9000 supports 128 channels at 9 ghz",
        "a quick brown fox jumps over the lazy dog near the river",
    ]
    query = "Zephyrnaut9000 channel spec"
    dv = torch.stack([toy_encode(t) for t in corpus])
    qv = toy_encode(query)
    dense_rank = (dv @ qv).topk(len(corpus)).indices.tolist()
    bm25_rank = sorted(range(len(corpus)), key=lambda i: bm25_scores(query, corpus)[i],
                       reverse=True)
    fused = rrf([dense_rank, bm25_rank])
    entity_idx = 1                                    # the Zephyrnaut9000 chunk
    f_ok = entity_idx == bm25_rank[0] and entity_idx in fused[:2]
    print(f"[f] BM25 top = {bm25_rank[0]} (entity chunk {entity_idx}); fused top2 = {fused[:2]}  "
          f"{'OK' if f_ok else 'FAIL'}")
    assert f_ok

    print("\nall RAG / embedding sanity checks passed ✓")


if __name__ == "__main__":
    main()