Text chunker com sobreposição para pipelines RAG
Em pipeline de RAG (Retrieval-Augmented Generation) o primeiro passo é quase sempre o mesmo: pegar um texto grande e quebrar em pedaços antes de vetorizar. Os pedaços não podem ser grandes demais, porque o modelo tem limite de contexto, nem pequenos demais, porque aí o embedding perde semântica. E precisam ter sobreposição entre vizinhos, senão uma resposta que cai bem na fronteira fica espremida entre dois chunks e o retriever erra.
O chunker precisa:
- Quebrar o texto em janelas de até N runas.
- Fazer sobreposição configurável.
- Não cortar uma palavra no meio.
- Ser consumível com um
for-range, sem subir a lista toda na memória.
O último item é trivial a partir do Go 1.23 com o pacote iter. A função devolve uma iter.Seq[string] e o consumidor itera como qualquer coisa:
for chunk := range Chunker(text, 60, 15) {
// embed, index, send to the LLM
}
A janela anda em runas, não em bytes.
Texto com caractere multibyte, emoji no meio do parágrafo: contar byte dá tamanho errado e ainda corre risco de partir um caractere ao meio.
Pago a conversão []rune(text) uma vez e trabalho com índice de runa daí pra frente.
O passo entre janelas é size - overlap. Se isso der <= 0 a configuração é absurda (overlap maior ou igual ao tamanho) e o iterador encerra sem emitir nada:
func Chunker(text string, size, overlap int) iter.Seq[string] {
return func(yield func(string) bool) {
runes := []rune(text)
n := len(runes)
step := size - overlap
if n == 0 || size <= 0 || step <= 0 {
return
}
// ...
}
}
Para não cortar palavra, é só recuar o fim da janela até a última fronteira de palavra (qualquer espaço). Se a palavra for tão longa que sozinha ocupa o chunk inteiro, aí corta no limite mesmo.
É a única forma de avançar:
cut := end
for cut > i && !unicode.IsSpace(runes[cut]) {
cut--
}
if cut == i {
cut = end
}
Emite o chunk e respeita o protocolo do iter.Seq: se o yield retornar false, o consumidor saiu do loop (break, return) e a gente sai junto:
if !yield(strings.TrimSpace(string(runes[i:cut]))) {
return
}
A próxima janela começa em cut - overlap. Esse índice pode cair no meio de uma palavra, então avanço até terminar a palavra parcial e depois pulo os espaços para começar limpo:
i = max(cut-overlap, 0)
if i > 0 && !unicode.IsSpace(runes[i-1]) {
for i < n && !unicode.IsSpace(runes[i]) {
i++
}
}
for i < n && unicode.IsSpace(runes[i]) {
i++
}
Quando o fim da janela passa do fim do texto, entrega o resto e encerra:
if end >= n {
if last := strings.TrimSpace(string(runes[i:n])); last != "" {
yield(last)
}
return
}
Rodando com size=60, overlap=15:
document: 174 runes | size=60 overlap=15
------------------------------------------------------------
[01] "The Go programming language favors standard libraries and"
[02] "libraries and simple tools. The use of iterators, introduced"
[03] "introduced in recent versions, simplifies the construction"
[04] "construction of complex pipelines."
Cada chunk começa repetindo o final do anterior.
É essa repetição que dá ao retriever uma chance de casar uma busca cuja resposta caiu bem em cima da fronteira de dois chunks.
O custo é mais vetores no índice e a chance da mesma passagem aparecer duas vezes no top-k. Para RAG de produção vale a pena. LangChain e LlamaIndex defaultam para algo nessa faixa: size de 500 a 1000 tokens, overlap de 10% a 20%.
Esse chunker é estupido de propósito. Não conhece frase, não conhece parágrafo, não conhece semântica. Para um pipeline sério você provavelmente vai querer cortar em fronteira de frase, ou ir num chunker semântico que olha embedding de sentença e quebra onde a similaridade cai.