From 7e9baeed74eb67c6ac4f8d1de378a638b9e0339c Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Wed, 12 Aug 2026 19:41:32 -0400 Subject: [PATCH] Add shared library for APIs/chunking --- .env.example | 6 + .gitignore | 4 + README.md | 66 +++++++ ingest.js | 35 ++-- lib/anthropic.js | 154 +++++++++++++++ lib/chunk.js | 32 ++++ lib/chunk.test.js | 27 +++ lib/scoring.js | 77 ++++++++ lib/voyage.js | 66 +++++++ package-lock.json | 478 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 17 ++ query.js | 101 ++++++---- 12 files changed, 1011 insertions(+), 52 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/anthropic.js create mode 100644 lib/chunk.js create mode 100644 lib/chunk.test.js create mode 100644 lib/scoring.js create mode 100644 lib/voyage.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0537e9e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +VOYAGE_API_KEY= +ANTHROPIC_API_KEY= +PINECONE_API_KEY= +PINECONE_INDEX_NAME= +# rerank | rerank_nli (default rerank_nli) +GROUNDEDNESS_MODE=rerank_nli diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c7ddf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +.DS_Store +secrets \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d721ad9 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# RAG Lambdas - Reliable, Source Grounded Serverless Retrevial Augmented Generation Pipeline + +AWS Lambda functions for source-grounded Q&A: ingest documents from S3 to Pinecone, answer questions with citations and a groundedness score. + +Embeddings and reranking use the Voyage API; generation and NLI use Anthropic. Lambdas need in/outbound HTTPS to `api.voyageai.com` and `api.anthropic.com` for REST API interactions. + +## Pipeline + +``` +ingest: + S3 object + → overlapping ~2000-char chunks + → Voyage voyage-3 (input_type=document) + → Pinecone upsert { text, source, chunkIndex } + +query: + question + → Voyage voyage-3 (input_type=query) + → Pinecone top-20 (cosine) + → Voyage rerank-2 → top-5 + → Claude opus-4-6 (JSON answer + citations + follow-ups) + → strip citation indices that are not in the retrieved set + → groundedness: + A. per sentence, rerank-2(sentence, cited chunk texts) → max score + B. optional Haiku NLI: supported | contradicted | unsupported + C. linear ramp on rerank scores (0.2–0.8, not cosine 0.35–0.65) + → { answer, citations, groundednessScore, followUpQuestions, contextUsed } +``` + +Note: Groundedness is not bi-encoder cosine. Cosine is used only for Pinecone ANN retrieval. The badge is Voyage `rerank-2` over cited chunk text, with an optional Haiku entailment pass. + +## Optional Rerank + +`GROUNDEDNESS_MODE=rerank` skips the Haiku LLM-judge (less expensive/lower latency). `rerank_nli` (default) runs both. + +Raw per-sentence rerank scores are logged as `sentence_groundedness` (for planned isotonic calibrator fit to labeled pairs - future update). + +## Pinecone index + +Voyage-3 embeddings are **1024-dimensional**. Create a new index, then ingest. + +- metric: `cosine` +- dimension: `1024` + +## Environment + +Copy `.env.example` and set: + +| Variable | Purpose | +|---|---| +| `VOYAGE_API_KEY` | voyage-3 embed + rerank-2 | +| `ANTHROPIC_API_KEY` | opus-4-6 generation + Haiku NLI | +| `PINECONE_API_KEY` | vector store | +| `PINECONE_INDEX_NAME` | 1024-d cosine index | +| `GROUNDEDNESS_MODE` | `rerank` or `rerank_nli` | + +## Lambdas + +- `ingest.js` — S3 trigger. Chunks, embeds, upserts. +- `query.js` — API Gateway / HTTP. Body: `{ "question": "..." }`. + +Shared code lives in `lib/`. Package both handlers with `node_modules` (Node 18+, ESM: `"type": "module"` in `package.json`). + +```bash +npm install +``` diff --git a/ingest.js b/ingest.js index 554b713..03c4879 100644 --- a/ingest.js +++ b/ingest.js @@ -1,10 +1,11 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; -import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; import { Pinecone } from "@pinecone-database/pinecone"; +import { chunkText } from "./lib/chunk.js"; +import { embedTexts } from "./lib/voyage.js"; const s3 = new S3Client({}); -const bedrock = new BedrockRuntimeClient({}); const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY }); +const UPSERT_BATCH_SIZE = 100; export const handler = async (event) => { const bucket = event.Records[0].s3.bucket.name; @@ -13,28 +14,22 @@ export const handler = async (event) => { const s3Response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); const rawText = await s3Response.Body.transformToString(); - const chunks = rawText.match(/[\s\S]{1,500}/g) || []; + const chunks = chunkText(rawText); + if (chunks.length === 0) { + return { status: "Success", processedChunks: 0 }; + } + const embeddings = await embedTexts(chunks, "document"); const index = pc.index(process.env.PINECONE_INDEX_NAME); - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; + const vectors = chunks.map((chunk, i) => ({ + id: `${key}_chunk_${i}`, + values: embeddings[i], + metadata: { text: chunk, source: key, chunkIndex: i }, + })); - const bedrockResponse = await bedrock.send(new InvokeModelCommand({ - modelId: "amazon.titan-embed-text-v1", - contentType: "application/json", - accept: "application/json", - body: JSON.stringify({ inputText: chunk }) - })); - - const { embedding } = JSON.parse(new TextDecoder().decode(bedrockResponse.body)); - - // 4. Upsert into Vector Database - await index.upsert([{ - id: `${key}_chunk_${i}`, - values: embedding, - metadata: { text: chunk, source: key } - }]); + for (let i = 0; i < vectors.length; i += UPSERT_BATCH_SIZE) { + await index.upsert(vectors.slice(i, i + UPSERT_BATCH_SIZE)); } return { status: "Success", processedChunks: chunks.length }; diff --git a/lib/anthropic.js b/lib/anthropic.js new file mode 100644 index 0000000..684d513 --- /dev/null +++ b/lib/anthropic.js @@ -0,0 +1,154 @@ +import Anthropic from "@anthropic-ai/sdk"; + +const GENERATION_MODEL = "claude-opus-4-6"; +const NLI_MODEL = "claude-haiku-4-5"; + + +const RESPONSE_SCHEMA = { + type: "object", + properties: { + answer: { + type: "string", + description: + "The answer with inline numeric citations like [1], [2] matching the source document numbers provided", + }, + citedSourceIndices: { + type: "array", + items: { type: "integer" }, + description: "The document numbers cited in the answer", + }, + followUpQuestions: { + type: "array", + items: { type: "string" }, + description: "Exactly 3 follow-up questions the user might ask", + }, + }, + required: ["answer", "citedSourceIndices", "followUpQuestions"], + additionalProperties: false, +}; + +const NLI_SCHEMA = { + type: "object", + properties: { + label: { + type: "string", + enum: ["supported", "contradicted", "unsupported"], + description: "Whether the claim is entailed, contradicted, or neither by the passages", + }, + confidence: { + type: "number", + description: "Confidence in the label, from 0 to 1", + }, + }, + required: ["label", "confidence"], + additionalProperties: false, +}; + +function getClient() { + const key = process.env.ANTHROPIC_API_KEY; + if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); + return new Anthropic({ apiKey: key }); +} + +function textFromMessage(message) { + const block = message.content[0]; + return block && "text" in block ? block.text : ""; +} + +function buildPrompt(query, sources) { + const today = new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + const sourceBlock = sources + .map((s) => `[Source ${s.docIndex}] (${s.name})\n${s.text}`) + .join("\n\n---\n\n"); + + return `You are a research assistant. Today's date is ${today}. Answer the user's question using ONLY the source documents provided below. Follow these rules strictly: + +1. Ground every claim in a specific source document. Cite sources inline using numeric notation like [1], [2], etc., matching the source document numbers below. +2. If the sources do not contain enough information to answer, say so honestly. +3. After your answer, suggest exactly 3 follow-up questions the user might ask based on the sources. + +--- SOURCE DOCUMENTS --- + +${sourceBlock} + +--- USER QUESTION --- + +${query}`; +} + +function stripInvalidCitations(answer, validIndices) { + return answer + .replace(/\[(\d+)\]/g, (match, n) => (validIndices.has(Number(n)) ? match : "")) + .replace(/[ \t]{2,}/g, " ") + .trim(); +} + +export async function generate(query, sources) { + const client = getClient(); + const message = await client.messages.create({ + model: GENERATION_MODEL, + max_tokens: 2048, + messages: [{ role: "user", content: buildPrompt(query, sources) }], + output_config: { + format: { + type: "json_schema", + schema: RESPONSE_SCHEMA, + }, + }, + }); + + const parsed = JSON.parse(textFromMessage(message) || "{}"); + const validIndices = new Set(sources.map((s) => s.docIndex)); + const citedSourceIndices = [ + ...new Set((parsed.citedSourceIndices || []).filter((idx) => validIndices.has(idx))), + ]; + + return { + answer: stripInvalidCitations(parsed.answer || "", validIndices), + citedSourceIndices, + followUpQuestions: parsed.followUpQuestions || [], + }; +} + +export async function judgeEntailment(sentence, chunkTexts) { + const client = getClient(); + const passages = chunkTexts.map((t, i) => `[Passage ${i + 1}]\n${t}`).join("\n\n"); + + const prompt = `You are an NLI judge. Decide whether the CLAIM is supported, contradicted, or unsupported by the SOURCE PASSAGES. + +- supported: the passages entail the claim +- contradicted: the passages contradict the claim +- unsupported: the passages neither support nor contradict the claim + +--- CLAIM --- +${sentence} + +--- SOURCE PASSAGES --- +${passages}`; + + const message = await client.messages.create({ + model: NLI_MODEL, + max_tokens: 256, + messages: [{ role: "user", content: prompt }], + output_config: { + format: { + type: "json_schema", + schema: NLI_SCHEMA, + }, + }, + }); + + const parsed = JSON.parse(textFromMessage(message) || "{}"); + const label = ["supported", "contradicted", "unsupported"].includes(parsed.label) + ? parsed.label + : "unsupported"; + const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0; + + return { label, confidence }; +} diff --git a/lib/chunk.js b/lib/chunk.js new file mode 100644 index 0000000..d542a19 --- /dev/null +++ b/lib/chunk.js @@ -0,0 +1,32 @@ +const CHUNK_SIZE = 2000; +const CHUNK_OVERLAP = 200; + +export function chunkText(text) { + const cleaned = String(text || "").replace(/\r\n/g, "\n").trim(); + if (!cleaned) return []; + if (cleaned.length <= CHUNK_SIZE) return [cleaned]; + + const chunks = []; + let start = 0; + + while (start < cleaned.length) { + let end = Math.min(start + CHUNK_SIZE, cleaned.length); + + if (end < cleaned.length) { + const slice = cleaned.slice(start, end); + const lastBreak = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" ")); + if (lastBreak > CHUNK_SIZE * 0.5) { + end = start + lastBreak; + } + } + + const chunk = cleaned.slice(start, end).trim(); + if (chunk) chunks.push(chunk); + if (end >= cleaned.length) break; + + const nextStart = end - CHUNK_OVERLAP; + start = nextStart <= start ? end : nextStart; + } + + return chunks; +} diff --git a/lib/chunk.test.js b/lib/chunk.test.js new file mode 100644 index 0000000..8996797 --- /dev/null +++ b/lib/chunk.test.js @@ -0,0 +1,27 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { chunkText } from "./chunk.js"; + +describe("chunkText", () => { + it("returns empty array for blank input", () => { + assert.deepEqual(chunkText(""), []); + assert.deepEqual(chunkText(" "), []); + }); + + it("keeps short text as a single chunk", () => { + const text = "A short paragraph."; + assert.deepEqual(chunkText(text), [text]); + }); + + it("splits long text into overlapping chunks under 2000 chars", () => { + const word = "word "; + const text = word.repeat(900); + const chunks = chunkText(text); + assert.ok(chunks.length > 1); + for (const chunk of chunks) { + assert.ok(chunk.length <= 2000); + } + const overlap = chunks[0].slice(-50); + assert.ok(chunks[1].includes(overlap.trim().slice(0, 20))); + }); +}); diff --git a/lib/scoring.js b/lib/scoring.js new file mode 100644 index 0000000..b1eb0ed --- /dev/null +++ b/lib/scoring.js @@ -0,0 +1,77 @@ +import { rerank } from "./voyage.js"; +import { judgeEntailment } from "./anthropic.js"; + +// These bounds are a starting ramp for rerank-2; log raw scores and adjust. +const RERANK_FLOOR = 0.2; +const RERANK_CEILING = 0.8; +const MIN_SENTENCE_LENGTH = 20; +const MAX_SENTENCES = 12; +const MAX_CITED_CHUNKS = 5; + +function splitIntoSentences(text) { + const cleaned = text.replace(/\[\d+\]/g, "").trim(); + const raw = cleaned.split(/(?<=[.!?])\s+/); + return raw + .map((s) => s.trim()) + .filter((s) => s.length >= MIN_SENTENCE_LENGTH) + .slice(0, MAX_SENTENCES); +} + +function calibrate(rawScore) { + const scaled = (rawScore - RERANK_FLOOR) / (RERANK_CEILING - RERANK_FLOOR); + return Math.max(0, Math.min(1, scaled)); +} + +function groundednessMode() { + const mode = (process.env.GROUNDEDNESS_MODE || "rerank_nli").toLowerCase(); + return mode === "rerank" ? "rerank" : "rerank_nli"; +} + +export async function computeGroundedness(answerText, citedChunkTexts) { + if (!citedChunkTexts || citedChunkTexts.length === 0) return 0; + + const sentences = splitIntoSentences(answerText); + if (sentences.length === 0) return 0; + + const chunks = citedChunkTexts.filter(Boolean).slice(0, MAX_CITED_CHUNKS); + if (chunks.length === 0) return 0; + + const mode = groundednessMode(); + + const perSentence = await Promise.all( + sentences.map(async (sentence) => { + const ranked = await rerank(sentence, chunks); + const raw = + ranked.length === 0 + ? 0 + : Math.max(...ranked.map((r) => r.relevanceScore)); + const calibrated = calibrate(raw); + + let nli = null; + if (mode === "rerank_nli") { + nli = await judgeEntailment(sentence, chunks); + } + + console.log( + JSON.stringify({ + event: "sentence_groundedness", + sentence: sentence.slice(0, 80), + rawRerank: Number(raw.toFixed(4)), + calibrated: Number(calibrated.toFixed(4)), + nli, + mode, + }) + ); + + return { raw, calibrated, nli }; + }) + ); + + if (perSentence.some((s) => s.nli && s.nli.label === "contradicted")) { + console.log(JSON.stringify({ event: "groundedness_floored", reason: "contradicted" })); + return 0; + } + + const total = perSentence.reduce((sum, s) => sum + s.calibrated, 0); + return total / perSentence.length; +} diff --git a/lib/voyage.js b/lib/voyage.js new file mode 100644 index 0000000..fa7f2ad --- /dev/null +++ b/lib/voyage.js @@ -0,0 +1,66 @@ +const VOYAGE_API_URL = "https://api.voyageai.com/v1"; +const EMBED_MODEL = "voyage-3"; +const RERANK_MODEL = "rerank-2"; +const EMBED_BATCH_SIZE = 32; + +function getApiKey() { + const key = process.env.VOYAGE_API_KEY; + if (!key) throw new Error("VOYAGE_API_KEY is not set"); + return key; +} + +async function voyagePost(path, body) { + const res = await fetch(`${VOYAGE_API_URL}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getApiKey()}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Voyage ${path} failed (${res.status}): ${text}`); + } + + return res.json(); +} + +// Embed text - voyage-3. inputType must be "document" (ingest) or "query" (search). + +export async function embedTexts(texts, inputType = "document") { + if (texts.length === 0) return []; + + const embeddings = []; + for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) { + const batch = texts.slice(i, i + EMBED_BATCH_SIZE); + const json = await voyagePost("/embeddings", { + model: EMBED_MODEL, + input: batch, + input_type: inputType, + }); + const ordered = (json.data || []) + .slice() + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); + embeddings.push(...ordered.map((d) => d.embedding)); + } + return embeddings; +} + +export async function rerank(query, documents) { + if (!documents || documents.length === 0) return []; + + const texts = documents.map((d) => (typeof d === "string" ? d : d.text)); + const json = await voyagePost("/rerank", { + model: RERANK_MODEL, + query, + documents: texts, + }); + + return (json.data || []).map((item) => ({ + index: item.index, + document: documents[item.index], + relevanceScore: item.relevance_score, + })); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1e3deb7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,478 @@ +{ + "name": "rag-lambdas", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rag-lambdas", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@aws-sdk/client-s3": "^3.864.0", + "@pinecone-database/pinecone": "^6.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", + "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1109.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1109.0.tgz", + "integrity": "sha512-iPWzBeGkAe5H5+dBBGCOdIT4uMhpu12OK+nFnDzjvOChVnHIws4LeoG7Yl0kJHSRWZnNEkVcF4vQYTJny0e5xA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.27", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-node": "^3.972.79", + "@aws-sdk/middleware-sdk-s3": "^3.972.73", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", + "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@pinecone-database/pinecone": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@pinecone-database/pinecone/-/pinecone-6.1.4.tgz", + "integrity": "sha512-wkipvpkBYNGYDeYj4azVVyCzSrekJE3Pgo0HImkbw80SGnTo9gz5kSbDCXCfVvFnhqC2q4nGikdTUvFCDiuruA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5acc6ce --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "rag-lambdas", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "node --test" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@aws-sdk/client-s3": "^3.864.0", + "@pinecone-database/pinecone": "^6.1.2" + } +} diff --git a/query.js b/query.js index e0365f7..77170ca 100644 --- a/query.js +++ b/query.js @@ -1,52 +1,89 @@ -import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; import { Pinecone } from "@pinecone-database/pinecone"; +import { embedTexts, rerank } from "./lib/voyage.js"; +import { generate } from "./lib/anthropic.js"; +import { computeGroundedness } from "./lib/scoring.js"; -const bedrock = new BedrockRuntimeClient({}); const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY }); +const TOP_K_SEARCH = 20; +const TOP_K_RERANK = 5; export const handler = async (event) => { - const { question } = JSON.parse(event.body); + const { question } = JSON.parse(event.body || "{}"); + if (!question || typeof question !== "string") { + return { + statusCode: 400, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ error: "question is required" }), + }; + } - // 1. Embed user query - const embedResponse = await bedrock.send(new InvokeModelCommand({ - modelId: "amazon.titan-embed-text-v1", - contentType: "application/json", - accept: "application/json", - body: JSON.stringify({ inputText: question }) - })); - const { embedding } = JSON.parse(new TextDecoder().decode(embedResponse.body)); - - // 2. Query Vector DB for relevant context + const [queryEmbedding] = await embedTexts([question], "query"); const index = pc.index(process.env.PINECONE_INDEX_NAME); const queryResponse = await index.query({ - vector: embedding, - topK: 3, - includeMetadata: true + vector: queryEmbedding, + topK: TOP_K_SEARCH, + includeMetadata: true, }); - const context = queryResponse.matches.map(match => match.metadata.text).join("\n\n"); + const candidates = (queryResponse.matches || []) + .filter((m) => m.metadata && m.metadata.text) + .map((m) => ({ + id: m.id, + text: m.metadata.text, + source: m.metadata.source || m.id, + chunkIndex: m.metadata.chunkIndex, + })); - const systemPrompt = `Use the following context to answer the question. If you don't know, say you don't know.\n\nContext:\n${context}`; + if (candidates.length === 0) { + return { + statusCode: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + answer: "No sources found. Ingest documents first.", + citations: [], + groundednessScore: null, + followUpQuestions: [], + contextUsed: 0, + }), + }; + } - const llmResponse = await bedrock.send(new InvokeModelCommand({ - modelId: "anthropic.claude-3-haiku-20240307-v1:0", // Fast & Cost-effective - contentType: "application/json", - accept: "application/json", - body: JSON.stringify({ - anthropic_version: "bedrock-2023-05-31", - max_tokens: 500, - messages: [ - { role: "user", content: `${systemPrompt}\n\nQuestion: ${question}` } - ] - }) + const ranked = await rerank(question, candidates); + const topChunks = ranked.slice(0, TOP_K_RERANK).map((r) => r.document); + + const sources = topChunks.map((chunk, i) => ({ + docIndex: i + 1, + name: chunk.source, + text: chunk.text, + id: chunk.id, })); - const result = JSON.parse(new TextDecoder().decode(llmResponse.body)); - const answer = result.content[0].text; + const { answer, citedSourceIndices, followUpQuestions } = await generate(question, sources); + + const citedChunks = citedSourceIndices + .map((idx) => sources.find((s) => s.docIndex === idx)) + .filter(Boolean); + + const groundednessScore = await computeGroundedness( + answer, + citedChunks.map((c) => c.text) + ); + + const citations = citedChunks.map((c) => ({ + sourceIndex: c.docIndex, + source: c.name, + chunkText: c.text, + })); return { statusCode: 200, headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ answer, contextUsed: queryResponse.matches.length }) + body: JSON.stringify({ + answer, + citations, + groundednessScore, + followUpQuestions, + contextUsed: topChunks.length, + }), }; };