All articles

Notes that know what you know: building AI-searchable personal notes with pinning, tags, and RAG integration

Suman Akkisetty
Founder, KaryoSpace
January 2026
9 min read

Notes are the simplest feature on the surface and the most carefully designed in the backend. A user writes a note. Later they ask the AI "what did I write about the authentication decision?" and the AI finds it. That experience requires the store, the text index, and the RAG integration to all agree on scope and sorting. Getting any one of them wrong breaks the experience silently.

The NoteStore

type NoteStore struct {
    coll *mongo.Collection // "user_notes"
}

func NewNoteStore(db *mongo.Database) (*NoteStore, error) {
    s := &NoteStore{coll: db.Collection("user_notes")}
    return s, s.ensureIndexes()
}

func (s *NoteStore) ensureIndexes() error {
    _, err := s.coll.Indexes().CreateMany(ctx, []mongo.IndexModel{
        // Sort: pinned first, then newest first
        {Keys: bson.D{
            {Key: "user_id", Value: 1},
            {Key: "pinned",  Value: -1},
            {Key: "created_at", Value: -1},
        }},
        // Tag filter
        {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "tags", Value: 1}}},
        // Full-text search, user-scoped
        {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "text", Value: "text"}}},
    })
    return err
}

Three indexes, each serving a different query pattern:

Save: the nil tags bug

func (s *NoteStore) Save(note *Note) error {
    if note.ID.IsZero() { note.ID = primitive.NewObjectID() }
    now := time.Now()
    note.CreatedAt = now
    note.UpdatedAt = now
    if note.Tags == nil {
        note.Tags = []string{} // must be empty array, not null
    }
    _, err := s.coll.InsertOne(ctx, note)
    return err
}

The nil check on Tags is not defensive programming. It is a fix for a real bug. When Go marshals a nil slice to BSON, MongoDB stores it as null. A $in query on a null field does not match, even with an empty array. Every tag-filter query that ran against notes created before the fix returned zero results. The fix is one line: initialize nil slices to empty slices before insertion.

Go to BSON pitfall

In Go, nil slice and empty slice are semantically equivalent. In MongoDB, null and empty array are not. Always initialize slice fields to []string{} before inserting into MongoDB.

Update: authorization at query level

func (s *NoteStore) Update(id primitive.ObjectID, userID, text string, tags []string, pinned bool) error {
    if tags == nil { tags = []string{} }
    _, err := s.coll.UpdateOne(ctx,
        // filter includes user_id — prevents cross-user edits even if ID is known
        bson.M{"_id": id, "user_id": userID},
        bson.M{"$set": bson.M{
            "text":       text,
            "tags":       tags,
            "pinned":     pinned,
            "updated_at": time.Now(),
        }},
    )
    return err
}

The filter always includes user_id. If a user knows someone else's note ID and calls the update endpoint with it, MongoDB finds zero documents matching {_id: X, user_id: currentUser} and returns successfully with 0 documents modified. The authorization is enforced at the database layer, not just in the handler. If the handler check is ever bypassed or misconfigured, the store is still safe.

RAG integration

Notes are a first-class RAG source. When the AI receives a query that the RAG classifier routes to personal notes, the retriever calls:

func SearchNotes(ctx context.Context, db *mongo.Database, userID, query string) ([]NoteContext, error) {
    coll := db.Collection("user_notes")
    filter := bson.M{
        "user_id": userID,                       // always scoped
        "$text":   bson.M{"$search": query},
    }
    opts := options.Find().
        SetSort(bson.D{{Key: "score", Value: bson.D{{Key: "$meta", Value: "textScore"}}}}).
        SetLimit(5)
    // ...
}

Personal notes are never returned in org-scope RAG queries. The scope filter is applied before the text search, so a user asking an org-wide question (searching company knowledge) cannot accidentally surface another user's personal notes in the context passed to the LLM.

Tags and pinning

Tags are free-form strings. There is no predefined taxonomy. This was a deliberate choice: enforcing a taxonomy requires administering it, and in a team of one to ten people, the taxonomy is always wrong. Users create their own tags organically and the AI can surface notes by tag on request: "show me everything I tagged as 'architecture'".

Pinning is stored as a boolean and is part of the compound sort index. The sort order is pinned DESC, created_at DESC. Pinned notes float to the top automatically. There is no separate "pinned notes" list, no drag-and-drop reordering, and no in-application sort logic. The database index handles it.

The AI search experience

1

User asks the AI

"What did I write about the authentication decision?"

2

RAG classifier routes to notes

IsRAGQuery() returns true. The query contains personal possessive ("I wrote") which routes to personal scope.

3

Retriever searches user_notes

$text search on {user_id, text} index. Returns top 5 matching note chunks by text score.

4

LLM answers with context

Note content passed as RAG context. LLM synthesizes the answer from what was actually written, not from its training data.

What I got wrong first

First version: text index covered all notes in the collection across all users. A search for "authentication" would return every user's notes containing that word. Fixed by adding user_id as the leading key in the text index.

Second version: pinning was implemented as a Go-level sort override. After fetching notes, the handler re-sorted results putting pinned items first. This broke when the list was paginated: page 2 might contain a pinned note that should have been on page 1. Fixed by moving pinning into the compound index and letting MongoDB handle the sort order natively.

Third version: tags stored as nil when the user submitted no tags. Fixed with the empty-slice initialization described above.


Notes are what make the AI feel personal rather than generic. The AI does not just know about company emails and Jira tickets. It knows what the user themselves wrote down. That distinction matters for trust: users are more likely to rely on an AI that can reflect their own thinking back to them than one that only knows official company information.

Get in touch
Message sent