Add shared library for APIs/chunking #1

Merged
kjannette merged 1 commits from FEAT-build-pipeline-features into master 2026-08-12 23:41:59 +00:00
12 changed files with 1011 additions and 52 deletions
Showing only changes of commit 7e9baeed74 - Show all commits

6
.env.example Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
.DS_Store
secrets

66
README.md Normal file
View File

@@ -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
```

View File

@@ -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 };

154
lib/anthropic.js Normal file
View File

@@ -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 };
}

32
lib/chunk.js Normal file
View File

@@ -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;
}

27
lib/chunk.test.js Normal file
View File

@@ -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)));
});
});

77
lib/scoring.js Normal file
View File

@@ -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;
}

66
lib/voyage.js Normal file
View File

@@ -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,
}));
}

478
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

17
package.json Normal file
View File

@@ -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"
}
}

101
query.js
View File

@@ -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,
}),
};
};