Spaces:
Starting on Zero
Starting on Zero
File size: 5,991 Bytes
4a4df15 | 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 | from __future__ import annotations
import hashlib
import re
import secrets
from typing import Callable
import numpy as np
from .schemas import Chunk, ParsedCorpus, Topic
from .workers import CPUWorker
class Embedder:
def __init__(self, worker: CPUWorker, demo: bool = False):
self.worker, self.demo = worker, demo
def encode(self, texts: list[str], progress: Callable[[str], None] | None = None) -> np.ndarray:
if not texts:
return np.zeros((0, 0), dtype="float32")
if self.demo:
# Explicit demo mode ONLY. Never used as a silent semantic fallback.
out = np.zeros((len(texts), 256), dtype="float32")
for i, text in enumerate(texts):
for token in re.findall(r"\w+", text.casefold()):
out[i, int.from_bytes(hashlib.sha256(token.encode()).digest()[:4], "big") % 256] += 1
return out / np.maximum(np.linalg.norm(out, axis=1, keepdims=True), 1e-9)
vectors = []
for start in range(0, len(texts), 16):
if progress:
progress(f"Embedding passages {start+1}-{min(start+16, len(texts))} of {len(texts)} on CPU...")
result = self.worker.request("embed", {"texts": texts[start:start+16]}, timeout=480, progress=progress)
vectors.extend(result["vectors"])
out = np.asarray(vectors, dtype="float32")
if out.ndim != 2 or len(out) != len(texts) or not np.isfinite(out).all():
raise RuntimeError("Embedding model returned invalid vectors.")
return out / np.maximum(np.linalg.norm(out, axis=1, keepdims=True), 1e-9)
class CorpusIndex:
"""Per-session vectors; FAISS when installed, mathematically equivalent NumPy otherwise."""
def __init__(self, parsed: ParsedCorpus, vectors: np.ndarray):
if len(vectors) != len(parsed.chunks):
raise ValueError("Chunk/vector count mismatch.")
self.parsed = parsed
self.chunks = parsed.chunks
self.by_id = {c.id: c for c in self.chunks}
self.topics = {t.id: t for t in parsed.topics}
self.vectors = np.ascontiguousarray(vectors, dtype="float32")
self.faiss = None
try:
import faiss
self.faiss = faiss.IndexFlatIP(self.vectors.shape[1])
self.faiss.add(self.vectors)
except ImportError:
# The vectors are still real semantic embeddings; only the exact
# similarity-search implementation differs for small corpora.
pass
def options(self) -> list[tuple[str, str]]:
return [("Any primary topic (random selection)", "all")] + [(t.label, t.id) for t in self.parsed.topics]
def hits(self, vector: np.ndarray, *, role: str, ids: set[str] | None = None,
query: str = "", top_k: int = 6) -> list[Chunk]:
vector = np.asarray(vector, dtype="float32").reshape(-1)
if vector.shape[0] != self.vectors.shape[1]:
raise ValueError("Embedding dimension mismatch. Rebuild the corpus.")
# Filter scope BEFORE ranking; a globally similar chapter cannot starve
# a selected narrow topic of results.
candidates = [i for i, c in enumerate(self.chunks) if c.role == role and (ids is None or c.id in ids)]
if not candidates:
return []
if self.faiss is not None:
scores, rows = self.faiss.search(vector.reshape(1, -1), len(self.chunks))
score_map = dict(zip(map(int, rows[0]), map(float, scores[0])))
dense = np.asarray([score_map[i] for i in candidates])
else:
dense = self.vectors[candidates] @ vector
dense_order = np.argsort(-dense)
tokens = set(re.findall(r"\w+", query.casefold()))
lexical = [len(tokens & set(re.findall(r"\w+", self.chunks[i].text.casefold()))) for i in candidates]
lex_order = np.argsort(-np.asarray(lexical))
fused = np.zeros(len(candidates))
for rank, pos in enumerate(dense_order):
fused[pos] += 1 / (60 + rank)
if any(lexical):
for rank, pos in enumerate(lex_order):
fused[pos] += 0.35 / (60 + rank)
return [self.chunks[candidates[int(p)]] for p in np.argsort(-fused)[:top_k]]
def question_context(self, topic_id: str, focus: str, embedder: Embedder) -> tuple[Topic, list[Chunk]]:
if topic_id == "all":
# Select a coherent topic, rather than the first N pages of the corpus.
subtopics = [t for t in self.parsed.topics if "-T" in t.id]
topic = secrets.choice(subtopics or self.parsed.topics)
else:
if topic_id not in self.topics:
raise ValueError("Choose a topic from the current corpus.")
topic = self.topics[topic_id]
candidates = [self.by_id[sid] for sid in topic.source_ids]
if focus.strip():
vector = embedder.encode([focus])[0]
selected = self.hits(vector, role="primary", ids=set(topic.source_ids), query=focus, top_k=7)
# Neighbour passages remain within the selected topic.
nearby = {c.ordinal + d for c in selected for d in (-1, 0, 1)}
candidates = [c for c in candidates if c.ordinal in nearby][:12]
elif len(candidates) > 10:
start = secrets.randbelow(len(candidates) - 7)
candidates = candidates[start:start+8]
return topic, candidates
def pack_context(chunks: list[Chunk], max_chars: int) -> tuple[str, list[Chunk]]:
blocks, admitted, length = [], [], 0
seen: set[str] = set()
for c in chunks:
if c.id in seen:
continue
block = f"[SOURCE {c.id}; {c.role}; {c.filename}; {c.location}; section: {c.heading}]\n{c.text}"
if length + len(block) > max_chars:
continue
blocks.append(block)
admitted.append(c)
seen.add(c.id)
length += len(block) + 2
return "\n\n".join(blocks), admitted
|