|
1 | | -"""Embedding + generation adapters. Mock mode = deterministic hashed embeddings + |
2 | | -templated grounded answers (no model/GPU). Real mode swaps in sentence-transformers |
3 | | -+ an LLM client; the retrieval/graph/grounding contracts stay identical. |
| 1 | +"""Embedding + generation adapters. |
| 2 | +
|
| 3 | +Two modes, selected by env: |
| 4 | +- MOCK_MODE=true (default): deterministic hashed embeddings + templated grounded |
| 5 | + answers. Zero dependencies, runs anywhere, proves the retrieval/grounding contract. |
| 6 | +- MOCK_MODE=false: real sentence-transformers embedder + a pluggable LLM. |
| 7 | +
|
| 8 | +LLM providers (when not mock): |
| 9 | +- OPENAI_API_KEY set -> OpenAI chat completions |
| 10 | +- AWS configured -> Amazon Bedrock Converse API (Anthropic / Nova) |
| 11 | +If neither is configured, falls back to mock generation so the service still runs. |
| 12 | +
|
| 13 | +The retrieval / graph / grounding contracts in retrieval.py are identical regardless |
| 14 | +of which adapter is active -- that is the whole point of the seam. |
4 | 15 | """ |
5 | 16 | import hashlib |
| 17 | +import os |
6 | 18 | import re |
7 | 19 | from ragpilot.config import MOCK_MODE, EMBEDDING_DIM |
8 | 20 |
|
9 | 21 |
|
| 22 | +# --------------------------------------------------------------------------- embed |
10 | 23 | def embed(text: str) -> list[float]: |
11 | | - """Deterministic pseudo-embedding from text (stable, zero-dep, mock-safe).""" |
12 | | - if not MOCK_MODE: |
13 | | - raise NotImplementedError("Set MOCK_MODE=true or plug a real embedder.") |
| 24 | + """Return a dense vector for `text`. |
| 25 | +
|
| 26 | + Real path uses sentence-transformers when available; otherwise the |
| 27 | + deterministic hashed bag-of-words vector (so CI / zero-dep runs still work). |
| 28 | + """ |
| 29 | + if MOCK_MODE: |
| 30 | + return _mock_embed(text) |
| 31 | + try: |
| 32 | + from sentence_transformers import SentenceTransformer # lazy import |
| 33 | + model = SentenceTransformer(os.getenv("EMBED_MODEL", "all-MiniLM-L6-v2")) |
| 34 | + vec = model.encode(text, normalize_embeddings=True) |
| 35 | + return vec.tolist() |
| 36 | + except Exception: |
| 37 | + # degrade gracefully instead of crashing the ingest pipeline |
| 38 | + return _mock_embed(text) |
| 39 | + |
| 40 | + |
| 41 | +def _mock_embed(text: str) -> list[float]: |
14 | 42 | vec = [0.0] * EMBEDDING_DIM |
15 | | - # bag-of-words hashed into the vector -> lexical similarity emerges |
16 | 43 | for tok in re.findall(r"\w+", text.lower()): |
17 | 44 | h = int(hashlib.sha256(tok.encode()).hexdigest()[:8], 16) |
18 | 45 | vec[h % EMBEDDING_DIM] += 1.0 |
19 | 46 | norm = sum(x * x for x in vec) ** 0.5 |
20 | 47 | return [x / norm for x in vec] if norm else vec |
21 | 48 |
|
22 | 49 |
|
| 50 | +# ------------------------------------------------------------------------ generate |
23 | 51 | def generate(question: str, chunks: list) -> str: |
24 | | - """Grounded answer synthesis. Real mode: LLM w/ chunks as context. |
25 | | - Mock: concatenates supporting snippets (proves citation traceability).""" |
26 | | - if not MOCK_MODE: |
27 | | - raise NotImplementedError("Set MOCK_MODE=true or plug a real LLM client.") |
| 52 | + """Grounded answer synthesis from retrieved chunks. |
| 53 | +
|
| 54 | + Real mode calls an LLM with the chunks as context. Mock mode concatenates |
| 55 | + supporting snippets (proves citation traceability with no model/GPU). |
| 56 | + """ |
| 57 | + if MOCK_MODE: |
| 58 | + return _mock_generate(question, chunks) |
| 59 | + |
| 60 | + context = "\n\n".join( |
| 61 | + f"[{i+1}] ({c.entity or c.source_type}): {c.text[:400]}" |
| 62 | + for i, c in enumerate(chunks[:5]) |
| 63 | + ) |
| 64 | + prompt = ( |
| 65 | + "Answer the question using ONLY the provided context. " |
| 66 | + "If the context does not contain the answer, say you cannot answer. " |
| 67 | + "Cite sources by their [n] number.\n\n" |
| 68 | + f"Context:\n{context}\n\nQuestion: {question}\nAnswer:" |
| 69 | + ) |
| 70 | + answer = _llm_complete(prompt) |
| 71 | + return answer or _mock_generate(question, chunks) |
| 72 | + |
| 73 | + |
| 74 | +def _mock_generate(question: str, chunks: list) -> str: |
28 | 75 | if not chunks: |
29 | 76 | return "(no retrieved context — cannot answer without sources)" |
30 | 77 | parts = [] |
31 | 78 | for ch in chunks[:3]: |
32 | 79 | snippet = ch.text[:180].replace("\n", " ") |
33 | 80 | parts.append(f"{ch.entity or 'source'}: {snippet}") |
34 | 81 | return " | ".join(parts) |
| 82 | + |
| 83 | + |
| 84 | +def _llm_complete(prompt: str) -> str | None: |
| 85 | + """Try OpenAI, then Bedrock; return None if neither configured.""" |
| 86 | + # OpenAI |
| 87 | + if os.getenv("OPENAI_API_KEY"): |
| 88 | + try: |
| 89 | + from openai import OpenAI |
| 90 | + client = OpenAI() |
| 91 | + resp = client.chat.completions.create( |
| 92 | + model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), |
| 93 | + messages=[{"role": "user", "content": prompt}], |
| 94 | + temperature=0.1, |
| 95 | + ) |
| 96 | + return resp.choices[0].message.content.strip() |
| 97 | + except Exception: |
| 98 | + pass |
| 99 | + # Amazon Bedrock (Converse API) |
| 100 | + if os.getenv("AWS_REGION") and os.getenv("BEDROCK_MODEL"): |
| 101 | + try: |
| 102 | + import boto3 |
| 103 | + client = boto3.client("bedrock-runtime", region_name=os.getenv("AWS_REGION")) |
| 104 | + resp = client.converse( |
| 105 | + modelId=os.getenv("BEDROCK_MODEL"), |
| 106 | + messages=[{"role": "user", "content": [{"text": prompt}]}], |
| 107 | + inferenceConfig={"temperature": 0.1, "maxTokens": 512}, |
| 108 | + ) |
| 109 | + return resp["output"]["message"]["content"][0]["text"].strip() |
| 110 | + except Exception: |
| 111 | + pass |
| 112 | + return None |
0 commit comments