All articles

Building a searchable knowledge base in Go: 500-word chunking, PDF text extraction, and why overlapping windows beat fixed splits

Suman Akkisetty
Founder, KaryoSpace
March 2026
10 min read

When I first built the knowledge base, I split documents into 3000-character chunks. The chunks crossed sentence boundaries, broke mid-paragraph, and produced retrieval results that confused the LLM because critical context was always just outside the returned window. The fix was to switch to word-count chunking with overlap.

This article covers the knowledge base in backend/go/knowledge/: the chunking algorithm, PDF text extraction, store design, and the visibility model that separates personal notes from org-wide documents.

The chunking algorithm

const (
    ChunkWords   = 500   // target words per chunk
    OverlapWords = 50    // overlap between consecutive chunks
    MaxDocChars  = 50000 // hard cap on input text
)

func SplitIntoChunks(text string) []string {
    words := strings.Fields(text)
    if len(words) == 0 { return []string{} }
    if len(words) <= ChunkWords { return []string{strings.Join(words, " ")} }
    stride := ChunkWords - OverlapWords // = 450
    var result []string
    for i := 0; i < len(words); i += stride {
        end := i + ChunkWords
        if end > len(words) { end = len(words) }
        result = append(result, strings.Join(words[i:end], " "))
        if end == len(words) { break }
    }
    return result
}

Why word counts instead of character counts: words map cleanly to tokens. A 500-word chunk is roughly 650-700 tokens, well within context window limits and MongoDB text index efficiency. Character-based chunking breaks mid-word at the boundary, corrupting the last token of every chunk.

Why 50-word overlap: a sentence that spans a chunk boundary would be split in half without overlap. The 50-word overlap ensures both chunks contain the complete sentence, so a search for a concept at the boundary can hit either chunk. Retrieval quality improved measurably after adding this.

Why MaxDocChars = 50000: without a cap, a user uploading a 500-page PDF would block the chunker goroutine for several seconds. The 50k char limit corresponds to roughly 35-40 pages, which covers virtually all realistic knowledge documents.

PDF text extraction

func ExtractText(data []byte, mimeType string) (string, error) {
    switch mimeType {
    case "text/plain", "text/markdown", "text/x-markdown":
        return cleanText(string(data)), nil
    case "application/pdf":
        return extractPDFText(data)
    default:
        return "", fmt.Errorf("unsupported file type: %s", mimeType)
    }
}

func extractPDFText(data []byte) (string, error) {
    r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
    // iterate: r.Page(i).GetPlainText(nil)
    // fallback: raw-byte scan if text layer is empty
    // error if < 100 chars extracted (image-only PDF)
}

The library used is github.com/ledongthuc/pdf. Page iteration includes a nil guard: if page.V.IsNull() { continue }. Without this, scanned PDFs with blank pages panic the reader. The 100-character minimum check surfaces a clear error to the uploader: "This PDF has no extractable text layer."

MongoDB indexes and store design

func (s *KnowledgeStore) ensureIndexes() error {
    _, err := s.coll.Indexes().CreateMany(ctx, []mongo.IndexModel{
        // Fast sort: user's documents, pinned first, newest last
        {Keys: bson.D{
            {Key: "user_id", Value: 1},
            {Key: "org_id", Value: 1},
            {Key: "visibility", Value: 1},
            {Key: "created_at", Value: -1},
        }},
        // Tag filter: user_id + tags array
        {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "tags", Value: 1}}},
        // Full-text search scoped to user
        {Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "text", Value: "text"}}},
    })
    return err
}
Index design principle

The text index is scoped to user_id. This means full-text search only returns results for the querying user. Organization-wide documents require a separate search with org_id scope.

Visibility model

Every knowledge document has a visibility field with three values:

VisibilityWho can readRAG scope
personalOwner onlyUser-scoped queries only
orgAll org membersAll org members' queries
departmentSame departmentDepartment-filtered queries

The RAG retriever enforces visibility at query time, not at upload time. When a user asks the AI a question, the retriever builds a query with the appropriate visibility filter before searching. Personal documents never appear in another user's search results, even if they search for the exact title.

Versioning

Each edit to a knowledge document creates a new version record:

func (s *KnowledgeStore) Update(id, userID, text string, tags []string) error {
    // Save current version to knowledge_versions before overwriting
    _, _ = s.versionColl.InsertOne(ctx, VersionRecord{
        DocID:    id,
        Text:     currentText,
        Version:  currentVersion,
        SavedAt:  time.Now(),
    })
    // Then update the main document
    _, err = s.coll.UpdateOne(ctx, filter, bson.M{"$set": bson.M{
        "text":    text,
        "tags":    tags,
        "version": currentVersion + 1,
    }})
    return err
}

Users can restore any previous version from the document history view. The version trail also allows the AI to answer questions like "what did this document say before the last edit?"

What I got wrong first

The first implementation stored tags as nil when the user submitted an empty tag list. MongoDB stored this as null, which means array operators like $in: [] matched nothing and tag-filter queries broke silently. The fix was a one-liner: initialize tags to []string{} before inserting, so MongoDB always stores an empty array, never null.

The first PDF extractor did not handle image-only PDFs. When a scanned document produced zero extracted text, the chunker stored empty strings. Those empty chunks matched every query and polluted retrieval results. Adding the 100-character minimum check and returning a clear error to the user fixed both the symptom and the root cause.


The knowledge base is the least glamorous part of the platform and one of the most used. Documents upload, chunk, and index in under two seconds for typical files. The chunking algorithm is 30 lines of Go with no external dependencies. Getting the fundamentals right here is what makes the RAG pipeline useful.

Get in touch
Message sent