# Building a Personal Notes Assistant with RAG, Amazon Bedrock, and Pinecone

> Source: <https://dev.to/d3vjamal/-building-a-personal-notes-assistant-with-rag-amazon-bedrock-and-pinecone-4jg3>
> Published: 2026-08-26 05:34:19+00:00

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)
