Building a Personal Notes Assistant with RAG, Amazon Bedrock, and Pinecone A developer built a personal notes assistant using RAG with Amazon Bedrock and Pinecone, enabling users to upload .txt files and ask questions answered strictly from their notes. The system uses embeddings for semantic search and includes validation checks to prevent corrupted files from crashing downstream services. Have you ever saved hundreds of digital notes only to spend twenty minutes hunting for one tiny detail? That exact frustration led me to build a custom notes assistant. I wanted a private API where I could upload my personal files, ask a question in plain English, and get an answer drawn directly from my notes—not just a generic guess from a public AI model. In this guide, I’ll break down how I built this project using Python, Flask, Amazon Bedrock, Pinecone, S3, AWS Lambda, and API Gateway. I’ll keep the explanations clear and conversational while walking through the real code and key takeaways. Project Goal:Upload plain text .txt notes and receive accurate answers strictly grounded in their content. RAG stands for Retrieval-Augmented Generation . Normally, when you ask an AI model a question, it relies entirely on its training data. If your information is private, recent, or highly specific, the model won't know it. Question - Model - Answer Question - Search My Notes - Grab Relevant Snippets - Model - Answer By adding a retrieval step, we feed the model relevant passages from our own documents alongside our question. We aren't retraining the AI model; we're giving it an open-book test using documents we control. Imagine walking into a library and asking a librarian: "What do my project notes say about serverless memory limits?" The librarian doesn't read every book on the shelves from cover to cover. Instead, they: Here is how that physical library maps directly to our technical setup: | Physical Library | RAG System Component | |---|---| | Books on shelves | Original text files stored in Amazon S3 | | Individual pages | Document chunks | | Catalog cards | Embeddings numerical representations of meaning | | Searching the catalog | Pinecone similarity search | | Top three selected pages | Top three matching text chunks | | Librarian giving the answer | Amazon Nova Lite generating the response | An embedding is just a snippet of text converted into a list of numbers a vector that represents its core meaning. Words or phrases with similar meanings end up near each other in digital space. For instance, a traditional keyword search might miss the connection between these two sentences: Because they share few identical words, keyword search struggles. But a semantic search using embeddings recognizes that both sentences ask about file storage. The application handles two main workflows: Ingestion saving and indexing notes and Querying searching notes and answering questions . .txt file to the Flask backend. /ask endpoint.Security starts at the entry point. The /ingest endpoint accepts multipart form data and runs several checks before touching the rest of our system: Check for file presence and secure filename if "file" not in request.files: return error response "MISSING FILE", "No file was provided", 400 uploaded file = request.files "file" filename = secure filename uploaded file.filename or "" if not filename or os.path.splitext filename 1 .lower = ".txt": return error response "INVALID FILE TYPE", "Only UTF-8 .txt files are accepted", 415 Validate encoding and content readability content = uploaded file.read if not content: return error response "EMPTY FILE", "Uploaded text file is empty", 400 try: decoded content = content.decode "utf-8" except UnicodeDecodeError: return error response "INVALID TEXT ENCODING", "Text file must use UTF-8 encoding", 400 if not decoded content.strip or "\x00" in decoded content: return error response "INVALID TEXT CONTENT", "File must contain valid text", 400 Catching bad requests early with specific errors like INVALID FILE TYPE prevents corrupted files from crashing downstream services like Pinecone or S3. Once validated, the file goes to S3. To avoid accidental overwrites when uploading multiple files with generic names like notes.txt , the application generates a unique ID UUID for each storage key while keeping the real filename in S3 metadata: php def upload text file file path: str, original filename: str - tuple str, str : bucket = os.getenv "S3 DOCUMENT BUCKET" Create a unique path key key = f"uploads/{uuid4 .hex}.txt" boto3.client "s3" .upload file file path, bucket, key, ExtraArgs={ "ContentType": "text/plain; charset=utf-8", "Metadata": {"original-filename": Path original filename .name}, }, return bucket, key Sending entire long documents directly into vector search reduces precision. We split text into chunks using LangChain's RecursiveCharacterTextSplitter : text splitter = RecursiveCharacterTextSplitter chunk size=1000, chunk overlap=100, docs = text splitter.split documents documents Why include overlap? If a key idea gets split right at character 1,000, half of the context ends up in Chunk A and half in Chunk B. Overlapping neighboring chunks by 100 characters preserves complete sentences and context across boundaries. Next, we convert text chunks into numbers using Amazon Titan Text Embeddings V2 and save them into Pinecone: embedding = BedrockEmbeddings model id="amazon.titan-embed-text-v2:0", dimensions=512, normalize=True, region name="ap-south-1", PineconeVectorStore.from documents docs, index name=index name, embedding=embedding, namespace="default", Golden Rule: Your document chunks and your incoming search questions must use the exact same embedding model, dimension count, and normalization settings. Otherwise, vector distances become meaningless. When asking a question via /ask , Pinecone finds the three nearest chunks: Grab top 3 matching snippets documents = docsearch.as retriever search kwargs={"k": 3} .invoke question.strip context str = "\n\n".join doc.page content for doc in documents We pass those snippets into Amazon Nova Lite with strict prompt instructions: PROMPT = ChatPromptTemplate.from template """Answer the question using only the context below. Context: {context} Question: {question} """ llm = ChatBedrockConverse model id="amazon.nova-lite-v1:0", temperature=0.2, Low temperature keeps answers factual max tokens=512, chain = PROMPT | llm | StrOutputParser answer = chain.invoke {"question": question, "context": context str} For local development, Flask handles traditional HTTP calls. When deploying to AWS, we run Flask inside AWS Lambda behind an API Gateway using serverless-wsgi : python import serverless wsgi from server import app def handler event, context : return serverless wsgi.handle request app, event, context The underlying infrastructure is configured in AWS CloudFormation: /health , /ingest , /ask .My GitHub repo : my-rag-notes-app https://github.com/d3vjamal/my-notes-rag