DEV Community

Cover image for Como criar uma "busca inteligente" para seu Wiki utilizando Go e ElasticSearch
José Paulo Marinho
José Paulo Marinho

Posted on

Como criar uma "busca inteligente" para seu Wiki utilizando Go e ElasticSearch

Hoje em dia, existem diversos motores de busca para diferentes finalidades. Como exemplo: o Google. O Google se destaca com técnicas avançadas de rastreamento, indexação e outras tecnologias que tornam suas buscas extremamente eficientes.

Neste post, vamos aprender a criar uma busca inteligente para o seu repositório Wiki utilizando arquivos Markdown. Não será tão complexo quanto o Google, mas será rápido, útil e bastante eficiente, utilizando o ElasticSearch.

Vamos lá?

Para começarmos, é necessário que os seguintes softwares estejam instalados e em execução no seu sistema:

  • Go
  • Elasticsearch

Observação: neste artigo, não abordaremos como instalar ou configurar esses softwares. Assumimos que eles já estão prontos para uso.

Estarei utilizando como repositório o ObsidianNotes - Cycro, um repositório recheado de conhecimento coletado e armazenado em arquivos .md.

Antes de começarmos a implementar, vou explicar como estruturaremos o motor de busca.

Motor de busca

1 - Leitura dos arquivos Markdown: Percorreremos todos os arquivos .md do repositório e extrairemos o conteúdo relevante(título, corpo, tags, etc).
2 - Indexação no ElasticSearch - Cada arquivo será transformado em um documento JSON e enviado ao ElasticSearch, que fará a indexação para permitir buscas rápidas e eficientes.
3 - Busca e retorno dos resultados - Implementaremos uma funcão simples que consulta o ElasticSearch com palavras-chave e retorna os arquivos mais relevantes.

Preparando o ambiente Go

Certifique-se de ter o Golang instalado e execute os seguintes comandos em um diretório de sua escolha:

mkdir go-md-search
cd go-md-search
go mod init go-md-search
Enter fullscreen mode Exit fullscreen mode

Em seguida, vamos instalar o cliente oficial do ElasticSearch para Go:

go get github.com/elastic/go-elasticsearch/v9
Enter fullscreen mode Exit fullscreen mode

Lendo arquivos Markdown

Podemos criar um script simples que percorre uma pasta e lê todos os arquivos .md:

package main

import (
    "fmt"
    "log"
    "os"
    "path/filepath"
)

func main() {
    files, err := filepath.Glob("~/ObsidianNotes/**/*.md") // sua pasta onde os arquivos estão
    if err != nil {
        log.Fatal(err)
    }
    for _, file := range files {
        content, err := os.ReadFile(file)
        if err != nil {
            log.Println("Erro ao ler arquivo:", err)
            continue
        }
        fmt.Println("Arquivo:", file)
        fmt.Println(string(content[:100]), "...")
    }
}
Enter fullscreen mode Exit fullscreen mode

Note que alguns arquivos podem estar vazios de conteúdo. Para ignora-los, podemos tratar facilmente no Go antes de enviar ao ElasticSearch:

package main

import (
    "fmt"
    "log"
    "os"
    "path/filepath"
    "strings"
    "unicode/utf8"
)

func main() {
    files, err := filepath.Glob("~/ObsidianNotes/**/*.md") // sua pasta onde os arquivos estão
    if err != nil {
        log.Fatal(err)
    }

    for _, file := range files {
        content, err := os.ReadFile(file)
        if err != nil {
            log.Printf("Erro ao ler arquivo %s: %v\n", file, err)
            continue
        }

        text := strings.TrimSpace(string(content))

        if len(text) == 0 || !utf8.Valid(content) {
            log.Printf("Ignorando arquivo vazio ou inválido: %s\n", file)
            continue
        }

        clean := strings.ReplaceAll(text, "\x00", "")

        fmt.Printf("Arquivo: %s\n", file)
        fmt.Println(clean[:min(200, len(clean))], "...")
    }
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

Indexando os arquivos no ElasticSearch

Agora que já conseguimos ler e limpar nossos arquivos, o próximo passo é enviar cada documento para o ElasticSearch. Cada arquivo Markdown será representado como um documento JSON, contendo:

  • Caminho do arquivo
  • Nome do arquivo
  • Conteúdo do arquivo

Segue implementação:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "path/filepath"
    "strings"
    "unicode/utf8"

    elasticsearch8 "github.com/elastic/go-elasticsearch/v9"
)

type MarkdownDoc struct {
    Path    string `json:"path"`
    Name    string `json:"name"`
    Content string `json:"content"`
}

func main() {
    cfg := elasticsearch8.Config{
        Addresses: []string{"http://localhost:9200"},
        Username:  "username",
        Password:  "password",
    }

    es, err := elasticsearch8.NewClient(cfg)

    if err != nil {
        log.Fatal(err)
    }

    res, err := es.Indices.Create("wiki")
    if err != nil {
        log.Fatalf("Erro ao criar índice: %v", err)
    }
    defer res.Body.Close()

    if res.IsError() {
        log.Printf("Aviso: índice 'wiki' pode já existir: %s\n", res.String())
    } else {
        fmt.Println("Índice 'wiki' criado com sucesso.")
    }

    files, err := filepath.Glob("/Users/josepaulomarinho/Documents/estudos/ObsidianNotes/**/**.md")
    if err != nil {
        log.Fatal(err)
    }

    for _, file := range files {
        content, err := os.ReadFile(file)
        if err != nil {
            continue
        }

        text := strings.TrimSpace(string(content))
        if len(text) == 0 || !utf8.Valid(content) {
            continue
        }

        clean := strings.ReplaceAll(text, "\x00", "")

        doc := MarkdownDoc{
            Path:    file,
            Name:    filepath.Base(file),
            Content: clean,
        }

        data, _ := json.Marshal(doc)

        res, err := es.Index("wiki", bytes.NewReader(data))
        if err != nil {
            log.Printf("Erro ao indexar %s: %v\n", file, err)
            continue
        }
        defer res.Body.Close()

        fmt.Println("Indexado:", file)
    }
}
Enter fullscreen mode Exit fullscreen mode

Note que estabelecemos uma conexão utilizando autenticação básica via usuário e senha. Logo após criamos o índice, caso não haja criado, filtramos os arquivos, ignorando os inválidos e indexamos no índice wiki.

Para conferir se o conteúdo foi indexado, execute o seguinte comando com CURL no terminal do seu sistema operacional:

http://localhost:9200/wiki/_search?pretty
Enter fullscreen mode Exit fullscreen mode

Realizando buscas no elasticsearch

Agora que nossos arquivos Markdown foram indexados no índice wiki, podemos testar buscas diretamente pelo conteúdo. O elasticsearch permite consultas poderosas, não apenas por palavras exatas, mas também por relevância e similaridade.

1. Consulta simples por palavra-chave

Podemos criar uma query simples que busca um termo em todos os documentos:

query := map[string]interface{}{
    "query": map[string]interface{}{
        "multi_match": map[string]interface{}{
            "query":  "locks", // termo de busca
            "fields": []string{"content"}, // campos a pesquisar
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

No caso estarei buscando somente pelo conteúdo a palavra locks.

  • multi_match - permite buscar em vários campos.
  • query - é o termo que o usuário quer encontrar
  • fields - especifica quais campos do documento serão pesquisados(title, content, etc).

2. Executando a busca em Go

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"

    elasticsearch8 "github.com/elastic/go-elasticsearch/v9"
)

func main() {
    cfg := elasticsearch8.Config{
        Addresses: []string{"http://localhost:9200"},
        Username:  "username",
        Password:  "password",
    }

    es, err := elasticsearch8.NewClient(cfg)

    query := map[string]interface{}{
        "query": map[string]interface{}{
            "multi_match": map[string]interface{}{
                "query":  "locks",
                "fields": []string{"content"},
            },
        },
    }

    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(query); err != nil {
        log.Fatalf("Erro ao codificar query: %s", err)
    }

    res, err := es.Search(
        es.Search.WithContext(context.Background()),
        es.Search.WithIndex("wiki"),
        es.Search.WithBody(&buf),
        es.Search.WithTrackTotalHits(true),
    )
    if err != nil {
        log.Fatalf("Erro ao buscar: %s", err)
    }
    defer res.Body.Close()

    var r map[string]interface{}
    if err := json.NewDecoder(res.Body).Decode(&r); err != nil {
        log.Fatalf("Erro ao decodificar resposta: %s", err)
    }

    fmt.Printf("Resultados encontrados: %d\n", int(r["hits"].(map[string]interface{})["total"].(map[string]interface{})["value"].(float64)))
    for _, hit := range r["hits"].(map[string]interface{})["hits"].([]interface{}) {
        source := hit.(map[string]interface{})["_source"].(map[string]interface{})
        fmt.Printf("- %s\n", source["name"])
    }
}
Enter fullscreen mode Exit fullscreen mode

A saída será a seguinte:

Resultados encontrados: 1
- DistributedLocks.md
Enter fullscreen mode Exit fullscreen mode

O código exibe quantos documentos foram encontrados e os nomes dos arquivos correspondentes.

Para tornar a busca mais inteligente, poderíamos utilizar alguns recursos do Elasticsearch para refinar os resultados, como: Boost por campo(peso aos títulos), Fuzzy search(permite erros de digitação), Highlight(mostra trechos do conteúdo que bateram com a busca).

Um exemplo com fuzzy search:

"multi_match": {
  "query": "locks",
  "fields": ["title^2", "content"],
  "fuzziness": "AUTO"
}
Enter fullscreen mode Exit fullscreen mode

title^2 - dá peso maior para o campo title
AUTO - corrige pequenas variações de ou erros de digitação

Servidor WEB

Abaixo encontra-se um programa expondo um servidor web mínimo para executar o que foi aprendido acima:

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    elasticsearch8 "github.com/elastic/go-elasticsearch/v9"
)

const MediaTypeApplicationJson = "application/json"

type MarkdownDoc struct {
    Path    string `json:"path"`
    Name    string `json:"name"`
    Content string `json:"content"`
}

type MultiMatch struct {
    Query  string   `json:"query"`
    Fields []string `json:"fields"`
}

type Query struct {
    MultiMatch MultiMatch `json:"multi_match"`
}

type SearchQuery struct {
    Query Query `json:"query"`
}

var es *elasticsearch8.Client

func searchHandle(w http.ResponseWriter, r *http.Request) {
    term := r.URL.Query().Get("q")
    if term == "" {
        http.Error(w, "Informe o parâmetro 'q' para busca", http.StatusBadRequest)
        return
    }

    sq := SearchQuery{
        Query: Query{
            MultiMatch: MultiMatch{
                Query:  term,
                Fields: []string{"name", "content"},
            },
        },
    }

    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(sq); err != nil {
        http.Error(w, "Erro ao codificar query", http.StatusInternalServerError)
        return
    }

    res, err := es.Search(
        es.Search.WithContext(context.Background()),
        es.Search.WithIndex("wiki"),
        es.Search.WithBody(&buf),
        es.Search.WithTrackTotalHits(true),
    )
    if err != nil {
        http.Error(w, fmt.Sprintf("Erro na busca: %s", err), http.StatusInternalServerError)
        return
    }
    defer res.Body.Close()

    var rJSON map[string]interface{}
    if err := json.NewDecoder(res.Body).Decode(&rJSON); err != nil {
        http.Error(w, "Erro ao decodificar resposta do ElasticSearch", http.StatusInternalServerError)
        return
    }

    results := []MarkdownDoc{}
    for _, hit := range rJSON["hits"].(map[string]interface{})["hits"].([]interface{}) {
        source := hit.(map[string]interface{})["_source"].(map[string]interface{})
        doc := MarkdownDoc{
            Name:    source["name"].(string),
            Content: source["content"].(string),
            Path:    source["path"].(string),
        }
        results = append(results, doc)
    }

    w.Header().Set("Content-Type", MediaTypeApplicationJson)
    json.NewEncoder(w).Encode(results)
}

func main() {
    cfg := elasticsearch8.Config{
        Addresses: []string{"http://localhost:9200"},
        Username:  "username",
        Password:  "password",
    }

    var err error
    es, err = elasticsearch8.NewClient(cfg)
    if err != nil {
        log.Fatalf("Erro ao criar cliente: %s", err)
    }

    http.HandleFunc("/search", searchHandle)
    fmt.Println("API de busca rodando em http://localhost:8080/search?q=termo")
    log.Fatal(http.ListenAndServe("localhost:8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Virá uma resposta como esta:

[
  {
    "path": "~/ObsidianNotes/Concurrency/DistributedLocks.md",
    "name": "DistributedLocks.md",
    "content": "When we talk about Distributed Locks, we are mainly talking about distributed systems, before commenting on distributed Locks, we will talk about distributed systems.\n\n A distributed system is a collection of **independent  computers** (nodes) that work together and appear to the user as a single coherent system. Key characteristics:\n - Network Communication: nodes exchanges messages (they don't share memory directly)\n- Concurrency: multiple machines can execute tasks simultaneously\n- Partial failures: one machine can fail while others keep running (different from centralized systems).\n- Consistency and coordination: since data may be spread across nodes, the system must handle synchronization, consensus, and fault tolerante(e.g., Paxos or Raft algorithms)\n- Examples: distributes databases(Cassandra, MongoDb Cluster), distributed file systems (HDFS), coordination services (Zookeeper, Etcd), and cloud plataforms (AWS, Google Cloud)\n\nIn short:\n\u003e That’s a distributed system: **many people (computers) working together as if they were one.**\n\nNow, back to Distributed Locks. Distributed locks are sophisticated techniques used to synchronize access to shared resources in distributed environments, ensuring that only one process or node can perform a critical operation at a time, preventing conflicts, inconsistencies, and possible data corruption.\n\nIn distributed systems, multiple processes/nodes may attempt to access the same critical resource at the same time (e.g., updating a bank account balance, processing the same queue message, writing a file, start a schedule, job). Local locks (mutexes, semaphores) are insufficient because they only control concurrency within a single process or machine. For this we need a locking mechanism that works across multiple machines connected over the network.\n\n**A distributed lock must provide:**\n1. Mutual Exclusion\n\tOnly one client at a time can hold the lock.\n2. Deadlock prevention\n\tIf the client holding the lock crashes, the lock should eventually expire(lease or TTL)\n3. Availability\n\tEven under failures, the system should continue granting locks.\n4. Consistency\n\tNo two clients should ever believe they both hold the same lock.\n5. Fairness(**optional**)\n\tLocks can be granted in the order of requests (queue-based fairness).\n\n**Commons implementations**\n\n###### *Redis* (e.g., Redlock Algorithm)\n- Each client sets a key with a TTL.\n- If the client can successfully set the key in a majority of Redis nodes, it assumes the lock.\n- TTL ensures auto-release if the client crashes.\n\n**ASCII flow**:\n\n```

sh\nClient A tries to lock \"resource-1\"\n ├──\u003e Redis Node 1: OK\n ├──\u003e Redis Node 2: OK\n └──\u003e Redis Node 3: FAIL (timeout)\n\nMajority acquired (2/3) → Client A holds the lock\n

```\n\n###### *Zookeeper*\n- Uses ephemeral znodes: each client creates a temporary node.\n- The client with the smallest sequence number holds the lock.\n- If the client disconnects, its znode disappears automatically.\n\n**ASCII flow**:\n\n```

sh\n/locks/resource-1/\n  ├── lock-0001 (Client A)  \u003c-- LOCK OWNER\n  ├── lock-0002 (Client B)\n  └── lock-0003 (Client C)\n

```\n\n\u003e If the **Client A** crashes, Zookeeper deletes lock-0001, and **Client B** becomes the new owner\n\n###### *Etcd*\n- Uses leases (time-limited key ownership).\n- Clients acquire a key with PUT key --lease=10s.\n- When the leases expires (or client crases), the key is removed\n- Watchers notify other clients that the lock is free.\n\n\n##### *General Architecture*\n\n![[architecture-distributed-locks-system.png]]\n\n\u003e- All clients attempt to acquire a lock on resource X. \n\u003e- Only one  succeeds at a time.\n\u003e- The lock expires unless it is renewed (lease).\n\n\n**Summary**\n\nDistributed locks are a coordination mechanism in distributed systems, typically implemented with a middleware like Redis, Zookeeper, Etcd, or Consul. They guarantee **mutual exclusion**, **fault tolerance, and consistency** when multiple processes compete for shared resources."
  }
]
Enter fullscreen mode Exit fullscreen mode

Chegamos ao final de mais um post. Estarei deixando alguns links de referência:

Web Server
Go Elasticsearch client
Documentação Oficial do Elasticsearch
JSON Handling in Go
Type Assertions in Go

Top comments (0)