Pular para o conteúdo

Trilha de RAG · parte 8 de 12

Ingestão de documentos: de PDFs e páginas a pedaços atualizados

Como transformar PDFs, páginas web e notas em texto limpo indexado, e como manter o índice atualizado quando os documentos mudam.

Publicada em · 60 min de leitura

Tamanho da fonte:
Largura da coluna:

Ingestão de documentos é o caminho que leva arquivos de formatos diferentes (PDF, HTML, DOCX, Markdown) até registros prontos no banco vetorial: extrai o texto, limpa, acrescenta metadados, corta, calcula embeddings e grava. Um corte bem feito depende de uma extração correta, e um documento que muda precisa ser reprocessado sem refazer tudo. Ao terminar, você deve entender por que a extração é o primeiro ponto de falha, como estruturar o pipeline em etapas, como tratar duplicatas e documentos que mudam, e como controlar permissões no que foi indexado.

A aula parte do zero, mas pressupõe o que é um banco vetorial (Banco de dados vetorial). A escada vai de uma analogia com a recepção de uma biblioteca até um pipeline incremental de 340 linhas, passando pelos defeitos reais de cada formato. Todo o código das seções 4 a 18 foi executado por mim, numa máquina com macOS e Python 3.12, e as saídas coladas são as que o programa imprimiu. O que muda com o tempo (opções das bibliotecas, comportamento do banco) vem da documentação consultada em 15 de outubro de 2026, e o que eu não consegui verificar está declarado na seção de fontes. As figuras são animadas e foram geradas com o ArchFlow.

Mapa da aula

ParteSeçõesPergunta que responde
1. Do arquivo ao registro1 a 3O que é ingestão, por que ela existe e que etapas tem?
2. Extração4 a 9Como tirar texto bom de cada formato e como saber se saiu bom?
3. Identidade10 e 11Que metadados guardar e como dar a cada documento e pedaço um id que não muda à toa?
4. Manter atualizado12 a 18Como reindexar só o necessário, apagar o que sumiu, deduplicar, sobreviver a falhas, trocar de modelo e respeitar permissões?
5. Fechar19 a 22Que erros evitar, para ir além, exercícios e glossário

Pré-requisito: Chunking. Os diagramas seguem um mapa de cores fixo, com uma cor a mais: terracota (o tema claude do ArchFlow) para a ingestão. Laranja continua sendo o que a pessoa controla (os arquivos, as edições, as perguntas), roxo é o corte, azul são vetores, índices e decisões, verde são pedaços e resultados bons, cinza é o que é neutro e rosa é o que dá errado, custa caro ou fica de fora.

Parte 1: Do arquivo ao registro

1. A recepção da biblioteca

Imagine uma biblioteca que recebe doações. Chegam caixas com livros, revistas encadernadas, fotocópias, recortes de jornal e pastas de folhas soltas. Antes de qualquer coisa ir para a estante, alguém precisa abrir a caixa, tirar o plástico e o grampo, descartar a propaganda que veio junto, conferir se a página não está em branco, colar uma etiqueta com origem e data e, principalmente, anotar no livro de registro o que já entrou, para que a mesma caixa não seja catalogada duas vezes e para que, quando um livro for retirado, a estante não continue apontando para um vazio. Essa recepção não é a parte glamourosa da biblioteca, mas é ela que decide se a estante tem livros legíveis ou lixo bem arrumado.

Ingestão de documentos é o nome técnico dessa recepção. É o conjunto de etapas que pega arquivos em formatos diferentes, extrai o texto, limpa, acrescenta metadados, passa pelo chunking, calcula os embeddings e grava os registros no banco vetorial. A segunda metade da definição pesa tanto quanto a primeira: a ingestão também é o processo que mantém o índice em dia, detectando o que foi criado, alterado ou removido desde a última vez.

onde entra
figura 1 · onde entra

A figura mostra o lugar da ingestão. Na linha de cima, o caminho de escrita: quatro fontes laranja (notas, PDFs, páginas HTML, DOCX) entram na ingestão (terracota, pulsando porque é o assunto de hoje), que entrega texto limpo ao chunking (roxo), seguido do embedding (azul) e do banco (cilindro verde). Embaixo está o que esta aula acrescenta: uma mudança laranja (editar, apagar ou acrescentar um arquivo) chega a um losango que compara os hashes com o manifesto, uma pequena lista do que já foi indexado. O losango manda para a ingestão só o que mudou (seta azul grossa) e manda ao banco, em pontilhado rosa, a ordem de apagar o que sumiu. Sem esse laço de baixo, o índice envelhece.

2. Por que a ingestão existe

Dá para montar um RAG de brinquedo sem pensar em ingestão: lê-se um arquivo de texto com open(), corta-se, embute-se, grava-se. Funciona na demonstração e quebra na primeira semana de uso real, por três razões que aparecem sempre.

A primeira é que os documentos não nascem em texto. Um manual em PDF guarda caracteres posicionados em coordenadas na página, sem noção de parágrafo ou título. Uma página web mistura o artigo com menu, aviso de cookies, lista de “mais lidos” e rodapé. Um DOCX é um pacote de XML com estilos. Uma nota do Obsidian é Markdown, mas com sintaxe própria (wikilinks, embeds, comentários). Cada formato esconde o texto de um jeito, e o corte por estrutura só funciona quando os títulos e as tabelas sobrevivem até o chunking.

A segunda é que o texto extraído é a matéria-prima de tudo o que vem depois. Um menu que entra no índice vira pedaço, ganha vetor e disputa posição com o conteúdo de verdade. Uma frase partida pela leitura de duas colunas misturadas não pode ser achada por nenhuma busca, porque deixou de existir. A seção 6 mede o primeiro caso e a seção 7, o segundo.

A terceira é que os documentos mudam. O prazo de arrependimento passa de 7 para 10 dias, a política de desconto é revogada, um PDF é substituído por outra versão. Se a única forma de atualizar o índice é apagar tudo e recomeçar, cada pequena edição custa o embedding do acervo inteiro, e se a atualização é feita sem cuidado, o índice acumula versões velhas ao lado das novas, e o modelo responde com o prazo antigo e o novo na mesma resposta, com igual confiança. Sem uma estratégia de atualização, o RAG se desatualiza em silêncio, e é o tipo de defeito que só aparece quando um cliente reclama.

3. As sete etapas e o laboratório desta aula

O pipeline tem sete etapas, e a ordem importa porque cada uma só pode usar o que a anterior entregou. Carregar é ler os bytes do arquivo e calcular o hash (uma impressão digital do conteúdo, que a seção 11 explica). Extrair devolve o texto do formato, por um extrator específico. Limpar normaliza o texto: remove espaços e caracteres invisíveis, junta palavras hifenizadas na quebra de linha, tira cabeçalhos e rodapés repetidos. Enriquecer anexa metadados (fonte, título, data, permissão). Cortar é o chunking (Chunking). Embutir calcula o vetor, mas só dos pedaços que o banco ainda não tem. Gravar faz o upsert no banco, atualiza os payloads e apaga o que ficou órfão.

pipeline
figura 2 · pipeline

A figura percorre as sete etapas de cima para baixo (a cor muda de terracota para roxo no corte, azul no embedding e verde na gravação). Duas saídas laterais mostram o que acontece quando o arquivo não passa: se a extração devolve texto vazio, o documento vai para “OCR pendente” (a seta é barrada com ✕, porque nada segue adiante), e se o texto é idêntico ao de outro arquivo, ele vira “duplicata” e também para. À direita embaixo, o manifesto só é atualizado depois da gravação, e essa ordem é o que torna o pipeline seguro contra quedas (seção 16).

O laboratório que uso é uma pasta com nove arquivos, gerados por um script meu para este texto, sobre o e-commerce fictício Mercado Aurora. A árvore:

./docx/politica-privacidade.docx
./notas/entregas.md
./notas/interno/politica-desconto.md
./notas/prime-copia.md
./notas/prime.md
./notas/trocas.md
./pdf/contrato-escaneado.pdf
./pdf/manual-vendedor.pdf
./site/frete-gratis.html

As três notas Markdown (trocas, entregas, prime) são o corpus de Chunking com frontmatter e wikilinks acrescentados. Há uma nota interna de política de desconto (acesso: interno) e uma cópia exata de prime.md, para exercitar a deduplicação. O HTML é uma página de ajuda com menu, aviso de cookies, “mais lidos” e rodapé. Há dois PDFs gerados com a biblioteca reportlab: um com texto (uma página em coluna única com tabela e outra em duas colunas, com cabeçalho e rodapé repetidos) e um escaneado, que é só uma imagem de texto dentro do PDF. O DOCX foi feito com python-docx, com títulos e uma tabela.

Para rodar tudo, usei um ambiente virtual do scratchpad da sessão, nada instalado fora dele. As bibliotecas novas, instaladas só nele: pypdf 6.20.1, pdfplumber 0.11.10, trafilatura 2.3.1, beautifulsoup4 4.15.0, python-docx 1.2.0, reportlab 5.0.1 e qdrant-client 1.19.1, além do sentence-transformers 6.1.0 que já estava lá. O banco é o Qdrant em modo local (uma pasta, sem servidor), e o modelo de embedding é o multilingual-e5-small , com o prefixo passage: aplicado à mão. O corte é o por_estrutura de Chunking, com pedaços de até 100 tokens. O modelo central dos dados é este:

@dataclass
class Documento:
    caminho: str                      # relativo à pasta de origem; é a identidade do documento
    tipo: str
    titulo: str = ""
    texto: str = ""
    meta: dict = field(default_factory=dict)
    avisos: list = field(default_factory=list)

    @property
    def doc_id(self):
        return str(uuid.uuid5(NS, self.caminho))

Um Documento é só isso: o caminho relativo (que é a identidade dele), o tipo, um título, o texto em Markdown simples, um dicionário de metadados e uma lista de avisos. Todos os extratores devolvem esse mesmo objeto, e é isso que permite ao resto do pipeline ignorar de que formato o texto veio.

Parte 2: Extração

4. Um extrator por formato

Todo formato tem uma pergunta própria que o extrator precisa responder. Num PDF, qual é a ordem de leitura? Numa página HTML, qual é o conteúdo e qual é a moldura? Num DOCX, o que é título e o que é tabela? Numa nota do Obsidian, que parte da sintaxe é conteúdo e qual é anotação do autor? Não existe uma função universal “arquivo para texto” que acerte todas, e as bibliotecas que prometem isso quase sempre escolhem uma dessas respostas por você.

extracao formatos
figura 3 · extracao formatos

A figura mostra quatro fontes laranja, cada uma com o seu extrator terracota e a biblioteca que usei como selo (pdfplumber, trafilatura, python-docx; o Markdown eu trato à mão). As quatro saem no mesmo formato: um “Documento” verde, com texto em Markdown, metadados e avisos. Em cima, a caixa rosa de avisos recebe duas coisas: o PDF sem camada de texto (seta barrada com ✕) e o embed de imagem de uma nota Markdown (seta pontilhada, porque é só um aviso). A escolha de Markdown como formato intermediário tem um motivo: títulos e tabelas em Markdown são exatamente o que o corte por estrutura de Chunking procura.

5. Markdown e notas do Obsidian

Markdown parece o formato fácil, e é, mas as notas do Obsidian trazem extras que um parser comum de Markdown não conhece. A documentação do Obsidian lista os links internos ([[Link]]), os embeds (![[Link]]), os comentários (%%texto%%) e os callouts (> [!note]) como extensões do formato, e diz que algumas delas, como os links internos e os comentários, são específicas do Obsidian. O frontmatter (o bloco YAML entre --- no começo do arquivo) é o lugar onde ficam as propriedades da nota. Deixar tudo isso passar crua para o índice produz pedaços com [[entregas|os prazos de entrega]] no meio da frase, comentários do autor que o leitor nunca deveria ver e ruído de sintaxe no vetor.

def md(caminho, bruto):
    import yaml
    meta, corpo = {}, bruto
    m = re.match(r"---\n(.*?)\n---\n", bruto, flags=re.S)
    if m:
        meta, corpo = yaml.safe_load(m.group(1)) or {}, bruto[m.end():]
    ligacoes = re.findall(r"(?<!!)\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]", corpo)
    avisos = []
    if re.search(r"!\[\[", corpo):
        avisos.append("embed de imagem/nota ignorado")
    corpo = re.sub(r"%%.*?%%", "", corpo, flags=re.S)                    # comentários do Obsidian
    corpo = re.sub(r"!\[\[[^\]]*\]\]", "", corpo)                        # embeds
    corpo = re.sub(r"\[\[[^\]|]*\|([^\]]*)\]\]", r"\1", corpo)           # [[alvo|texto]] -> texto
    corpo = re.sub(r"\[\[([^\]#]*)(?:#[^\]]*)?\]\]", r"\1", corpo)       # [[alvo]] -> alvo
    corpo = re.sub(r"^> \[!\w+\][+-]?\s*(.*)$", r"> \1", corpo, flags=re.M)  # callouts
    meta["ligacoes"] = sorted(set(ligacoes))
    meta = {k: (str(v) if hasattr(v, "isoformat") else v) for k, v in meta.items()}
    titulo = meta.get("titulo") or (re.search(r"^# (.+)$", corpo, flags=re.M) or [None, caminho])[1]
    return Documento(caminho, "md", titulo, normaliza(corpo), meta, avisos)

O extrator faz cinco coisas. Separa o frontmatter e o lê como YAML, porque ele vira metadado (título, acesso, tags, data) e não texto. Guarda os alvos dos wikilinks numa lista ligacoes, que depois vai para o payload: os links entre notas dizem quais documentos são vizinhos, e isso pode servir para expandir resultados. Troca [[alvo|texto]] por texto e [[alvo]] por alvo, para que a frase continue frase. Remove os comentários %% %%, que são notas do autor para si mesmo. E remove os embeds ![[...]], avisando que foram ignorados, porque a imagem em si não tem texto para indexar (indexar o conteúdo de uma nota embutida seria outra decisão, e eu não a tomei). O resultado na nota trocas.md, cujo arquivo tem frontmatter, wikilinks, um callout, um comentário e (no caso de prime.md) um embed:

== notas/trocas.md | titulo='Trocas e devoluções' | avisos=[]
meta: {'titulo': 'Trocas e devoluções', 'acesso': 'publico', 'tags': ['trocas', 'atendimento'], 'atualizado': '2026-09-01', 'ligacoes': ['entregas', 'prime']}
# Trocas e devoluções

Esta página explica como o Mercado Aurora trata trocas e devoluções de compras feitas no site e no aplicativo. Cada situação tem prazo e regra próprios, descritos nas seções abaixo. Em caso de dúvida, o atendimento responde pelo chat todos os dias.

## Arrependimento

...
obsidian
figura 4 · obsidian

A figura acompanha a nota trocas.md à esquerda e as cinco transformações no centro. O frontmatter (terracota) alimenta o payload verde com os metadados; o wikilink alimenta duas coisas, o texto limpo, onde vira prazos, e a lista ligacoes do payload (seta pontilhada); o callout também vai para o texto, com o marcador removido. As duas caixas rosa tracejadas (comentário e embed) levam ao “Fora do índice” com setas barradas. Repare que o payload do documento já traz o campo acesso, usado na seção 18.

6. HTML e o que descartar

Uma página web carrega muito mais bytes de moldura do que de conteúdo. No HTML de teste, o artigo “Frete grátis: como funciona” convive com um menu (Início, Ajuda, Entrar, Carrinho (0)), um aviso de cookies, uma lista de “Mais lidos” (Como rastrear o pedido, Cupom de boas-vindas, Cartão recusado) e um rodapé com CNPJ e links. Pegar o texto da página inteira, com get_text() do BeautifulSoup, entrega tudo isso ao chunking.

A biblioteca trafilatura existe para esse problema. Sua função extract roda um extrator baseado em regras e, quando o resultado sai curto demais (a documentação cita cerca de 110 caracteres), tenta os extratores alternativos readability e jusText. O formato de saída padrão é texto puro, e output_format="markdown" preserva títulos, listas e tabelas; a extração de tabelas vem ligada por padrão (include_tables). A documentação não descreve em detalhe como ela decide o que é menu, mas contrasta a extract com a função html2txt, que extrai o texto “incluindo navegação e rodapés”, e oferece favor_precision=True e prune_xpath para cortar mais. O código:

def html(caminho, bruto, ingenuo=False):
    from bs4 import BeautifulSoup
    sopa = BeautifulSoup(bruto, "lxml")
    titulo = sopa.title.get_text(strip=True) if sopa.title else caminho
    canon = sopa.find("link", rel="canonical")
    meta = {"url": canon["href"]} if canon else {}
    if ingenuo:                                   # o que NÃO fazer: todo o texto da página
        return Documento(caminho, "html", titulo, normaliza(sopa.get_text("\n", strip=True)), meta)
    import trafilatura
    texto = trafilatura.extract(bruto, output_format="markdown", include_tables=True, include_comments=False)
    avisos = [] if texto else ["trafilatura não achou o conteúdo principal"]
    return Documento(caminho, "html", titulo.split("|")[0].strip(), normaliza(texto or ""), meta, avisos)

Medi a diferença com duas buscas sobre a mesma página, uma com o texto ingênuo (cortado pelo recursivo de Chunking, em pedaços de 60 tokens, porque sem títulos o corte por estrutura não tem em que se apoiar) e outra com o texto do trafilatura (cortado por estrutura):

== ingênuo: 5 pedaços, 220 tokens
  'como rastrear o pedido': 0.880 | 'Frete grátis: como funciona | Central de ajuda Aurora\nInício\nAjuda\nEntrar\nCarrinho (0)\nUsamos cookies para mel'
  'quem tem frete grátis sempre': 0.897 | 'O frete é grátis em compras acima de R$ 199 para as regiões Sudeste e Centro-Oeste, e acima de R$ 249 para o S'
== trafilatura: 3 pedaços, 173 tokens
  'como rastrear o pedido': 0.838 | '## Quando o frete grátis não vale\n\nProdutos volumosos, como geladeiras e sofás, têm frete calculado à parte. E'
  'quem tem frete grátis sempre': 0.906 | '## Quem tem frete grátis sempre\n\nAssinantes do Prime não pagam frete em compras de qualquer valor. O benefício'

A versão ingênua gerou 5 pedaços e 220 tokens, e a limpa, 3 pedaços e 173 tokens, ou seja, mais de um quinto do texto indexado era moldura. O resultado mais revelador está na primeira pergunta, “como rastrear o pedido”, que não tem resposta na página. Com o texto ingênuo o primeiro lugar é o pedaço do menu e dos “mais lidos”, com cosseno 0,880, porque ele contém a frase “Como rastrear o pedido” como título de um link. Um cosseno de 0,880 parece confiante, e o conteúdo é inútil: o modelo receberia um menu como contexto. Com o texto limpo o topo cai para 0,838 e é um pedaço irrelevante, o que ao menos não promete nada. Na segunda pergunta, que tem resposta, o ingênuo achou o parágrafo de abertura (0,897) e o limpo achou a seção certa, “Quem tem frete grátis sempre” (0,906), uma diferença pequena que um teste com uma pergunta só não permite afirmar.

html boilerplate
figura 5 · html boilerplate

À esquerda, os quatro blocos de moldura (cinza tracejado) tentam entrar no filtro e são barrados com ✕. O artigo e a tabela (laranja, setas grossas) passam pelo trafilatura (terracota, pulsando) e saem como “Markdown limpo” (verde), com título, seções e tabela em |. A seta pontilhada rosa para “Descartado” resume a medida: de 5 pedaços para 3. A caixa rosa de baixo, à direita, guarda o caso de falha da leitura ingênua. Há limites que a medida não cobre: testei uma página só, escrita por mim, e páginas reais com carrossel, comentários, conteúdo carregado por JavaScript ou acordeões podem enganar qualquer extrator. Inspecione sempre uma amostra das suas páginas, como a seção 9 ensina.

7. PDF: camada de texto, colunas, tabelas

O PDF é o formato mais traiçoeiro, e a razão está na documentação do pypdf: “o texto dentro de um PDF é absolutamente posicionado” e os arquivos não contêm uma camada semântica. O PDF não sabe o que é parágrafo, título, cabeçalho de página ou célula de tabela, só que o caractere “A” está na posição (50, 700) com tal fonte. Toda estrutura que o leitor humano vê, o extrator precisa adivinhar a partir de posições e tamanhos. Quem acha que “extrair texto de PDF” é um problema resolvido ainda não abriu o PDF de um relatório com duas colunas.

Há três situações, e o fluxograma da figura seguinte as ordena como perguntas.

pdf decisoes
figura 6 · pdf decisoes

Primeiro, a página tem camada de texto? Se não tem (o losango de cima), o PDF é uma digitalização, uma imagem de páginas, e o caminho para o texto é OCR (reconhecimento óptico de caracteres). A seta barrada leva a “OCR pendente”. Segundo, as páginas têm duas colunas? Se sim, a leitura precisa recortar a página e ler uma coluna inteira antes da outra (a caixa roxa). Terceiro, há tabelas? Se sim, elas precisam virar Markdown com cabeçalho, para que uma linha como Moda | 14% | 14 dias continue dizendo de que categoria fala. No fim, duas limpezas: remover o cabeçalho e o rodapé que se repetem em cada página, e transformar fontes grandes em títulos. O código inteiro da página:

def duas_colunas(pg):
    pal = pg.extract_words()
    meio = pg.width / 2
    if len(pal) < 20 or any(p["x0"] < meio < p["x1"] for p in pal):
        return False
    return sum(p["x1"] <= meio for p in pal) > 8 and sum(p["x0"] >= meio for p in pal) > 8

def linhas_md(pg):
    """Parágrafos da página: junta as linhas de um mesmo parágrafo; fonte bem maior que o corpo vira título."""
    out, atual, ant = [], [], None
    for l in pg.extract_text_lines():
        tam = max(c["size"] for c in l["chars"])
        titulo = "# " if tam >= 17 else "## " if tam >= 12.5 else ""
        novo = bool(titulo) or ant is None or l["top"] - ant["bottom"] > 5 or ant.get("t")
        if novo and atual: out.append(" ".join(atual)); atual = []
        atual.append(titulo + l["text"]); ant = {**l, "t": bool(titulo)}
    return out + [" ".join(atual)] if atual else out

def pagina_md(pg):
    tabs = pg.find_tables()
    caixas = [t.bbox for t in tabs]
    fora = pg.filter(lambda o: not any(b[0] <= o.get("x0", 0) and o.get("x1", 0) <= b[2] and b[1] <= o.get("top", 0) and o.get("bottom", 0) <= b[3] for b in caixas)) if caixas else pg
    if duas_colunas(fora):
        meio = fora.width / 2
        partes = [fora.crop((0, 0, meio, fora.height)), fora.crop((meio, 0, fora.width, fora.height))]
        corpo = "\n\n".join(l for p in partes for l in linhas_md(p))
    else:
        corpo = "\n\n".join(linhas_md(fora))
    return corpo + "".join("\n\n" + tabela_md([[c or "" for c in r] for r in t.extract()]) for t in tabs)

def tira_cabecalho_rodape(paginas):
    """Linhas que se repetem (com os números trocados) na maioria das páginas são cabeçalho ou rodapé."""
    chave = lambda l: re.sub(r"\d+", "#", l.strip())
    if len(paginas) < 2: return paginas
    cont = {}
    for p in paginas:
        for k in {chave(l) for l in p.splitlines() if l.strip()}:
            cont[k] = cont.get(k, 0) + 1
    rep = {k for k, v in cont.items() if v >= max(2, len(paginas) * 0.6)}
    return ["\n".join(l for l in p.splitlines() if chave(l) not in rep) for p in paginas]

A detecção de colunas é deliberadamente simples: uma página tem duas colunas se nenhuma palavra cruza o meio da página e há mais de 8 palavras de cada lado. As linhas são agrupadas em parágrafos pela distância vertical (mais de 5 pontos de espaço entre linhas inicia um parágrafo novo) e uma linha com fonte de 17 pontos ou mais vira #, de 12,5 ou mais vira ##. Esses limiares valem para o meu PDF de teste e são exatamente o tipo de número que você vai ter de ajustar aos seus. As tabelas vêm de find_tables do pdfplumber, cujo README descreve também extract_text_lines (marcada como experimental), crop e filter, que usei. O cabeçalho e o rodapé saem por contagem: uma linha, com os números trocados por #, que aparece em pelo menos 60% das páginas (e em ao menos duas) é considerada moldura. Assim, “Página 1 de 2 | Confidencial” e “Página 2 de 2 | Confidencial” caem na mesma chave.

A página 2 do meu PDF tem duas colunas escritas, como fazem muitos geradores, linha a linha, alternando esquerda e direita. Veja o que o pypdf, que lê na ordem do fluxo de desenho, devolveu, e o que o pdfplumber com recorte devolveu, usando como teste uma frase-ouro que eu sei que existe contígua no texto:

===== pdf/manual-vendedor.pdf | ingênuo=True | chars=931
Mercado Aurora | Manual do vendedor | v3
Página 1 de 2 | Confidencial
Manual do vendedor
1. Comissões
A comissão padrão do Mercado Aurora é de 12% sobre o valor do produto vendido, sem contar o
frete. O repasse é feito 14 dias depois da entrega confirmada. Vendedores com reputação verde
recebem em 7 dias.
Categoria
Comissão
Repasse
Eletrônicos
10%
14 dias
Moda
14%
14 dias
Casa
12%
14 dias

Mercado Aurora | Manual do vendedor | v3
Página 2 de 2 | Confidencial
2. Prazos de envio
O vendedor tem até 2 dias úteis para
Para itens com envio pelo centro de
postar o pedido depois da aprovação do
distribuição Aurora, o vendedor
pagamento. Pedidos pagos até as 14h
entrega o lote em até 5 dias úteis. A

   teste: NÃO contém 'postar o pedido depois da aprovação do pagamento'
===== pdf/manual-vendedor.pdf | ingênuo=False | chars=850
# Manual do vendedor

## 1. Comissões

A comissão padrão do Mercado Aurora é de 12% sobre o valor do produto vendido, sem contar o frete. O repasse é feito 14 dias depois da entrega confirmada. Vendedores com reputação verde recebem em 7 dias.

| Categoria | Comissão | Repasse |
|---|---|---|
| Eletrônicos | 10% | 14 dias |
| Moda | 14% | 14 dias |
| Casa | 12% | 14 dias |

## 2. Prazos de envio

O vendedor tem até 2 dias úteis para postar o pedido depois da aprovação do pagamento. Pedidos pagos até as 14h devem sair no mesmo dia quando houver estoque. Atrasos reiterados reduzem a reputação da loja e podem suspender o anúncio.

Para itens com envio pelo centro de distribuição Aurora, o vende
   teste: contém 'postar o pedido depois da aprovação do pagamento'

O programa imprime só os primeiros 700 caracteres de cada versão, por isso os textos terminam no meio de uma palavra, e o teste da frase-ouro roda sobre o texto inteiro.

colunas
figura 7 · colunas

A figura compara as duas leituras da mesma página laranja. Em cima, a leitura ingênua (cinza) produz um texto misturado (rosa, tremendo), em que a frase da coluna esquerda é interrompida pela linha da direita, e o teste da frase-ouro falha. Embaixo, o losango pergunta se alguma palavra cruza o meio; nenhuma cruza, então a página é recortada em duas (roxo), lê-se a esquerda e depois a direita, e o texto sai na ordem (verde), com a frase inteira. Duas observações de honestidade. O pypdf tem um modo layout (extraction_mode="layout") que reproduz o desenho da página em texto de largura fixa, mas ele mantém as colunas lado a lado, o que ainda precisa de tratamento para virar parágrafos, e eu não o usei no pipeline. E o PDF da primeira página saiu muito bem no pdfplumber, com a tabela virando Markdown (| Eletrônicos | 10% | 14 dias |), enquanto no pypdf cada célula saiu numa linha própria, sem pistas de qual era a coluna de cada uma.

E o PDF escaneado? O extrator devolveu texto vazio e o pipeline o marcou como “OCR pendente”. As documentações são claras: o pypdf avisa que “nunca conseguirá extrair texto de imagens” e sugere uma ferramenta como o Tesseract, e o README do pdfplumber diz que ele funciona melhor com PDFs gerados por máquina e lista o OCR entre o que não faz. Eu não instalei OCR de sistema nesta aula, e portanto não verifiquei nenhuma rota de OCR (nem Tesseract, nem OCRmyPDF, nem modelos de visão, nem serviços em nuvem). O que verifiquei é a parte que antecede o OCR: detectar que a página não tem texto, que é o que impede o índice de receber um documento vazio como se fosse um documento real. Quando você tiver escaneados, o pipeline para de aceitar a falha em silêncio e passa a tratar “sem texto” como um estado, com um relatório para alguém decidir.

8. DOCX

O DOCX é um pacote ZIP com XML, e a biblioteca python-docx lê parágrafos, estilos e tabelas. O ponto de atenção é a ordem: doc.paragraphs e doc.tables são listas separadas, e quem as concatena perde o lugar de cada tabela no texto. O extrator percorre os elementos do corpo na ordem do documento, e escreve cada parágrafo com # conforme o estilo (Title e Heading 1 viram # e ##) e cada tabela em Markdown:

def docx(caminho, arq):
    import docx as d
    from docx.table import Table
    from docx.text.paragraph import Paragraph
    doc = d.Document(arq)
    saida = []
    for el in doc.element.body.iterchildren():
        if el.tag.endswith("}p"):
            p = Paragraph(el, doc)
            if not p.text.strip(): continue
            est = p.style.name
            nivel = 1 if est == "Title" else int(est.split()[-1]) + 1 if est.startswith("Heading") else 0
            saida.append(("#" * nivel + " " if nivel else "") + p.text.strip())
        elif el.tag.endswith("}tbl"):
            linhas = [[c.text.strip() for c in r.cells] for r in Table(el, doc).rows]
            saida.append(tabela_md(linhas))
    texto = "\n\n".join(saida)
    props = doc.core_properties
    meta = {"autor": props.author or None, "modificado": props.modified.isoformat() if props.modified else None}
    titulo = next((l[2:] for l in texto.splitlines() if l.startswith("# ")), caminho)
    return Documento(caminho, "docx", titulo, normaliza(texto), meta)
== docx/politica-privacidade.docx | titulo='Política de privacidade' | avisos=[]
meta: {'autor': 'python-docx', 'modificado': '2013-12-23T23:15:00+00:00'}
# Política de privacidade

## Dados que coletamos

Coletamos nome, e-mail, endereço de entrega e histórico de compras para processar pedidos e prevenir fraudes.

...

O resultado tem os títulos e a tabela no lugar. Repare também nos metadados do arquivo: autor saiu python-docx e modificado saiu 2013-12-23, porque o arquivo foi gerado a partir do modelo padrão da biblioteca. Metadados internos de arquivos Office costumam ser assim, herdados de modelos, de quem criou o primeiro rascunho ou da data do último salvamento por script, e por isso não devem ser a única fonte de “data de atualização” do seu índice. Prefira a data do sistema de arquivos, um campo que você controla ou o frontmatter, e trate o que vem de dentro do arquivo como pista.

9. Inspecionar a extração antes de confiar nela

A extração falha em silêncio. Nenhuma exceção avisa que duas colunas foram misturadas ou que o menu entrou no texto: o pipeline termina, o banco enche, a busca devolve algo, e o defeito só aparece quando alguém pergunta a coisa certa. A defesa é inspecionar, e dá para fazer com poucas verificações automáticas mais uma humana.

inspecao
figura 8 · inspecao

A figura mostra um documento extraído (terracota) alimentando cinco verificações azuis, em sequência, até um losango que decide: se algum teste falhou, o arquivo vai para quarentena (rosa, seta barrada) e aparece no relatório; se nenhum falhou, segue para o chunking (roxo, seta grossa). Os testes são estes. Caracteres por página: zero em uma página de PDF indica digitalização (no meu pipeline, menos de 20 caracteres na página vira aviso). Frase-ouro: uma frase que você sabe que existe no documento, inteira e contígua, e que o teste procura no texto extraído, como fiz na seção 7 e como mostra o código abaixo:

from extrair import extrair
frase = "postar o pedido depois da aprovação do pagamento"
for ingenuo in (True, False):
    d = extrair("fonte_limpa", "pdf/manual-vendedor.pdf", ingenuo=ingenuo)
    print(f"ingênuo={ingenuo}:", "contém" if frase in d.texto else "NÃO contém")
ingênuo=True: NÃO contém
ingênuo=False: contém

No pipeline real esse teste é uma asserção, que interrompe o processo ou manda o arquivo para a quarentena. Os outros testes seguem a mesma ideia. Lixo repetido: contar quantos pedaços contêm a mesma linha (menus, “Página # de #”) revela moldura que escapou. Estrutura: o texto extraído de um PDF ou DOCX ainda tem # e |? Se um manual de 80 páginas virou zero títulos, o corte por estrutura de Chunking não tem onde atuar. E a amostra lida por gente: imprimir 5 pedaços de cada formato e olhar de verdade leva dois minutos e acha o que nenhuma regra previu.

Gosto de tratar a frase-ouro como o teste de unidade da extração. Cada formato novo ou cada mudança de biblioteca ganha uma frase, e a mudança só é aceita se a frase continuar achável. É o mesmo princípio da avaliação de sistemas de busca, aplicado uma camada antes.

Parte 3: Identidade

10. Metadados úteis

Metadados são o que o pedaço sabe sobre si além do texto. No Banco de dados vetorial, o payload foi apresentado como o lugar do texto e dos campos para filtro. Aqui a pergunta é quais campos vale a pena guardar, e a resposta é: os que você vai precisar para citar, filtrar, atualizar e apagar. A tabela resume o que gravei em cada pedaço, de onde cada campo vem e para que serve.

CampoVem dePara que serve
fontecaminho relativo do arquivo (ou URL)citar a origem na resposta, abrir o arquivo, saber o que reindexar
doc_iduuid5 do caminhoagrupar os pedaços de um documento, apagar ou atualizar em bloco
titulo, secaotítulo do documento e título do pedaçocitação legível, prefixo de contexto de Chunking
tipoextensão (md, pdf, html, docx)filtrar, diagnosticar qual extrator produziu o defeito
atualizadofrontmatter ou data do arquivomostrar a data da informação, ordenar, expirar
acessofrontmatter, com padrão restritivofiltro de permissão (seção 18)
indiceposição do pedaço no documentoreconstruir a ordem, buscar vizinhos
pipelineversão do extrator e do cortereindexar quando o processo muda (seção 17)
textoo texto do pedaçoo que o modelo vai ler

O código que monta o payload é o mesmo que calcula os ids, e está na seção seguinte. O campo acesso merece um detalhe: quando o documento não tem esse campo, o padrão é interno, o mais restritivo, e não publico. A razão é a assimetria do erro. Um documento público marcado como interno é um incômodo que alguém percebe e corrige. Um documento interno marcado como público é um vazamento, e ninguém percebe até a pergunta errada ser feita pela pessoa errada.

Duas outras ressalvas. Metadado que você não vai usar não deveria ir para o banco, porque cada campo é espaço, e campo de texto livre grande no payload pesa na memória e nos backups. E metadado que muda com frequência (como “número de visitas”) não deve morar junto do vetor se atualizá-lo exigir mexer no vetor: no Qdrant, as operações de payload não tocam o vetor, e é por isso que a seção 13 consegue atualizar um campo de permissão sem recalcular nenhum embedding.

11. Ids determinísticos e hash de conteúdo

Um hash é uma função que recebe qualquer entrada e devolve uma sequência curta de tamanho fixo, de modo que a mesma entrada sempre dá a mesma saída e entradas diferentes quase certamente dão saídas diferentes. O SHA-256 devolve 64 caracteres hexadecimais. Usado sobre o conteúdo de um arquivo, ele funciona como impressão digital: se o hash não mudou, o arquivo não mudou, e não é preciso abri-lo para saber. Um id determinístico é um id calculado a partir do conteúdo ou do nome, em vez de sorteado ou numerado em sequência. Chamar o upsert duas vezes com o mesmo id e o mesmo conteúdo resulta no mesmo estado, e é isso que torna a operação segura de repetir.

ids
figura 9 · ids

A figura tem duas fileiras. Em cima, o id do documento: o caminho relativo (laranja) passa por uuid5 e vira o doc_id (verde, “estável”). A função uuid5, segundo a documentação do Python, gera um UUID a partir do hash SHA-1 de um namespace (um UUID fixo, que escolhi uma vez) e de um nome, e a mesma combinação produz o mesmo UUID em qualquer máquina. O Qdrant aceita como id de ponto inteiros sem sinal de 64 bits e UUIDs, de modo que o resultado serve direto. Embaixo, o id do pedaço: o título mais o texto do pedaço (roxo, o que de fato vira vetor) passam por sha256, e o hash, junto com o doc_id, entra no segundo uuid5, junto de um contador n que distingue dois pedaços idênticos no mesmo documento. O id resultante (verde, “por conteúdo”) só muda se o conteúdo mudar. Em rosa tracejado, a alternativa que evitei: usar a posição do pedaço no documento como id, que é a forma mais comum e a mais frágil, porque inserir uma seção no meio desloca o id de todos os pedaços seguintes, e todos passariam a parecer alterados.

def cortar(doc):
    """Texto -> pedaços (texto, id). O id vem do documento e do conteúdo a embutir, nunca da ordem."""
    spans = chunkers.por_estrutura(doc.texto, TAM)
    pedacos, vistos = [], {}
    for i, (a, b) in enumerate(spans):
        txt = doc.texto[a:b]
        emb = f"{doc.titulo}\n{txt}"
        h = sha(emb)
        vistos[h] = vistos.get(h, 0) + 1                     # dois pedaços idênticos no mesmo doc ganham ocorrências
        cid = str(uuid.uuid5(NS, f"{doc.doc_id}:{h}:{vistos[h]}"))
        secao = (re.match(r"#+ (.+)", txt) or [None, ""])[1]
        pedacos.append(dict(id=cid, emb=emb, hash=h, payload=dict(
            doc_id=doc.doc_id, fonte=doc.caminho, tipo=doc.tipo, titulo=doc.titulo, secao=secao, indice=i,
            texto=txt, acesso=doc.meta.get("acesso", "interno"),          # sem rótulo, o padrão é o mais restrito
            atualizado=str(doc.meta.get("atualizado", doc.meta.get("modificado", ""))), pipeline=VERSAO_PIPELINE)))
    return pedacos

Duas escolhas deste trecho merecem explicação. A primeira é que o hash é calculado sobre emb, o texto que vira vetor (título do documento mais o pedaço), e não sobre o pedaço sozinho. Se o título do documento mudar, o vetor deveria mudar, e com o hash sobre emb isso acontece sozinho: os ids mudam, os pedaços são reembutidos. A segunda é o contador vistos: se uma nota repete um parágrafo idêntico duas vezes, o hash seria o mesmo e os dois pedaços teriam o mesmo id, e um sobrescreveria o outro. Com a ocorrência n no id, os dois coexistem.

Um id por conteúdo tem uma consequência prática, mostrada na próxima parte: dá para saber quais pedaços são novos apenas comparando ids, sem reembutir nada.

Parte 4: Manter atualizado

12. Detectar o que mudou

Há duas formas de manter o índice em dia. A primeira é reconstruir tudo a cada execução, o que é simples e correto, e custa o embedding do acervo inteiro mesmo quando mudou uma vírgula. Para 52 pedaços isso é desprezível, para 5 milhões de pedaços é inaceitável. A segunda é a ingestão incremental: comparar o estado atual da origem com o estado registrado na última execução e processar só a diferença.

O registro é o manifesto, um arquivo JSON com uma entrada por documento (caminho, hash do arquivo, versão do pipeline, estado, hash do texto) e o nome do modelo de embedding. Em produção ele poderia ser uma tabela num banco, mas o princípio é o mesmo, e a escrita é atômica: o arquivo é gravado ao lado e renomeado, para que uma queda no meio da escrita nunca deixe um manifesto pela metade.

class Manifesto:
    """Um arquivo JSON por coleção: o que já foi indexado, com que hash, com que modelo."""
    def __init__(self, caminho, modelo_nome):
        self.caminho = pathlib.Path(caminho)
        d = json.loads(self.caminho.read_text()) if self.caminho.exists() else {"modelo": modelo_nome, "docs": {}}
        if d["modelo"] != modelo_nome:
            raise SystemExit(f"coleção feita com '{d['modelo']}', não com '{modelo_nome}': troca de modelo pede coleção nova")
        self.d = d

    def salvar(self):                                   # escrita atômica: grava ao lado e renomeia
        tmp = self.caminho.with_suffix(".tmp"); tmp.write_text(json.dumps(self.d, ensure_ascii=False, indent=1)); os.replace(tmp, self.caminho)

def varrer(raiz):
    raiz = pathlib.Path(raiz)
    return {str(p.relative_to(raiz)): sha(p.read_bytes()) for p in sorted(raiz.rglob("*")) if p.suffix.lower() in FORMATOS}
incremental
figura 10 · incremental

A figura mostra duas entradas, a pasta de origem (laranja, com o hash de cada arquivo agora) e o manifesto (cinza, com o hash da última vez), que se encontram no losango “Comparar” (azul, pulsando). Dele saem quatro categorias. Novo (verde): está no disco e falta no manifesto. Alterado (azul): o hash do arquivo ou a versão do pipeline mudou. Removido (rosa): está no manifesto e sumiu do disco. Igual (cinza): nada a fazer, e a seta é a mais lenta e fina, porque a única coisa que esse arquivo custa é o cálculo do hash. Os três primeiros seguem para ações (processar o documento ou apagar por doc_id), e o quarto é barrado com ✕ em “Pular”. O código da varredura e a primeira execução:

    disco = varrer(raiz)
    novos = [c for c in disco if c not in m]
    alterados = [c for c in disco if c in m and (m[c]["sha"] != disco[c] or m[c]["pipeline"] != VERSAO_PIPELINE)]
    removidos = [c for c in m if c not in disco]
    alterados += [c for c in disco if c in m and c not in alterados and m[c]["status"] == "duplicata"
                  and (m.get(m[c]["de"], {}).get("status") != "ok" or m[m[c]["de"]]["texto_sha"] != m[c]["texto_sha"] or m[c]["de"] not in disco)]
    iguais = [c for c in disco if c in m and c not in alterados]
    print(f"varredura: {len(novos)} novos, {len(alterados)} alterados, {len(removidos)} removidos, {len(iguais)} iguais")
varredura: 9 novos, 0 alterados, 0 removidos, 0 iguais
  + novo: notas/prime.md: 7 pedaços (7 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/trocas.md: 13 pedaços (13 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/entregas.md: 12 pedaços (12 embutidos, 0 mantidos, 0 apagados)
  = notas/prime-copia.md: mesmo texto de notas/prime.md, não indexado
  + novo: site/frete-gratis.html: 3 pedaços (3 embutidos, 0 mantidos, 0 apagados)
  + novo: pdf/manual-vendedor.pdf: 5 pedaços (5 embutidos, 0 mantidos, 0 apagados)
  ! pdf/contrato-escaneado.pdf: página 1 sem camada de texto (provável digitalização, precisa de OCR)
  + novo: docx/politica-privacidade.docx: 3 pedaços (3 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/interno/politica-desconto.md: 3 pedaços (3 embutidos, 0 mantidos, 0 apagados)
resumo: {'embutidos': 46, 'mantidos': 0, 'apagados': 0, 'ocr': 1, 'duplicatas': 1} em 3.1s | total na coleção: 46 pedaços

A primeira execução embutiu 46 pedaços dos nove arquivos. Os 46 são 3 do DOCX, 12 de entregas, 3 da política interna, 7 de prime, 13 de trocas, 5 do manual em PDF e 3 do HTML. Duas linhas merecem leitura: prime-copia.md foi marcado como duplicata de prime.md (seção 15) e o PDF escaneado foi marcado como sem texto (seção 7), e nenhum dos dois ganhou pedaço. Rodando de novo sem mexer em nada:

varredura: 0 novos, 0 alterados, 0 removidos, 9 iguais
resumo: {'embutidos': 0, 'mantidos': 0, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 2.2s | total na coleção: 46 pedaços

Zero embeddings. Os 2 segundos dessa execução são quase todos o carregamento do modelo de embedding (o programa o carrega no início para saber a dimensão do vetor); o trabalho real de comparar os nove hashes é de milissegundos. Isso resume o valor da ingestão incremental: uma execução que não muda nada deve custar quase nada, e por isso pode rodar a cada commit, a cada hora ou a cada minuto.

Há um detalhe sobre o que conta como “mudou”. Comparar o hash do arquivo é o teste mais seguro, e é o que faço, mas exige ler o arquivo inteiro. Em pastas enormes, uma otimização comum é comparar primeiro o tamanho e a data de modificação, e só calcular o hash se algum deles mudou. Não implementei essa otimização (a pasta é pequena) e ela tem um custo: uma data de modificação pode mudar sem o conteúdo mudar, o que só gera trabalho extra, ou, pior, ficar igual com o conteúdo trocado por uma ferramenta que preserva datas, o que gera um índice velho. Para documentos que importam, vale o hash.

13. Reindexar só o necessário: a diferença por pedaço

Detectar que um arquivo mudou é só o primeiro filtro. Um arquivo de 13 pedaços em que se corrigiu um número afeta um pedaço, e reembutir os 13 é desperdício. Os ids por conteúdo da seção 11 permitem fazer melhor: depois de extrair e cortar o documento alterado, o pipeline calcula os ids dos pedaços novos, pergunta ao banco quais já existem e embute só os que faltam.

diff pedacos
figura 11 · diff pedacos

À esquerda, o documento trocas.md antes, com 13 pedaços: “Intro”, “Arrependimento: 7 dias”, “Defeito”, “Solicitação” e outros 9. À direita, depois da edição, que trocou “7 dias corridos” por “10 dias corridos”. As setas verdes grossas ligam os pedaços que ficaram com o mesmo id (12 mantidos): só o payload é refeito, porque a posição ou algum metadado pode ter mudado. O pedaço editado (rosa, à esquerda) ganha um id novo pelo hash diferente: seu vetor novo vem do embedding (azul) e entra com um upsert, e o id antigo vira órfão e é apagado (seta rosa para “Apagado”). O custo é um embedding em vez de treze. O trecho que faz isso:

        pedacos = cortar(doc)
        ids = [p["id"] for p in pedacos]
        existentes = {str(r.id) for r in com_retry(lambda: cli.retrieve(colecao, ids=ids, with_payload=False, with_vectors=False))}
        faltam = [p for p in pedacos if p["id"] not in existentes]
        antes = n_doc(cli, colecao, doc.doc_id)
        # 4. embute só o que o banco ainda não tem, em lotes
        for i in range(0, len(faltam), 16):
            lote = faltam[i:i + 16]
            vet = embute(mod, [p["emb"] for p in lote])
            def gravar():
                if flaky: flaky()
                cli.upsert(colecao, points=[models.PointStruct(id=p["id"], vector=v.tolist(), payload=p["payload"]) for p, v in zip(lote, vet)])
            com_retry(gravar)
        # 5. pedaços que ficaram: só atualiza o payload (permissão, data, posição), sem tocar no vetor
        ficaram = [p for p in pedacos if p["id"] in existentes]
        if ficaram:
            com_retry(lambda: cli.batch_update_points(colecao, [models.OverwritePayloadOperation(
                overwrite_payload=models.SetPayload(payload=p["payload"], points=[p["id"]])) for p in ficaram]))
        # 6. órfãos: pedaços deste documento cujo id não está mais na lista nova
        com_retry(lambda: cli.delete(colecao, models.FilterSelector(filter=models.Filter(
            must=[models.FieldCondition(key="doc_id", match=models.MatchValue(value=doc.doc_id))],
            must_not=[models.HasIdCondition(has_id=ids)]))))
        depois = n_doc(cli, colecao, doc.doc_id)
        apagados = antes + len(faltam) - depois

O código segue seis passos. Primeiro corta o documento e calcula os ids. Depois pergunta ao banco quais ids já existem (retrieve sem vetor e sem payload, que é uma consulta barata). Os que faltam são embutidos em lotes de 16, e cada lote é gravado com um upsert, dentro do com_retry da seção 16. Os que já existiam têm só o payload sobrescrito, numa única chamada em lote (batch_update_points com operações OverwritePayloadOperation; a documentação do Qdrant descreve as operações de lote e diz que são executadas em ordem). Por último vem a limpeza de órfãos, que a seção 14 detalha. As execuções reais, para a edição do prazo (passo 3) e para uma seção nova inserida no meio de entregas.md (passo 4):

varredura: 0 novos, 1 alterados, 0 removidos, 8 iguais
  + alterado: notas/trocas.md: 13 pedaços (1 embutidos, 12 mantidos, 1 apagados)
resumo: {'embutidos': 1, 'mantidos': 12, 'apagados': 1, 'ocr': 0, 'duplicatas': 0} em 2.3s | total na coleção: 46 pedaços
varredura: 0 novos, 1 alterados, 0 removidos, 8 iguais
  + alterado: notas/entregas.md: 13 pedaços (1 embutidos, 12 mantidos, 0 apagados)
resumo: {'embutidos': 1, 'mantidos': 12, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 2.2s | total na coleção: 47 pedaços

Na edição, 1 pedaço embutido, 12 mantidos e 1 apagado (o total na coleção continua 46). Na inserção de uma seção nova (“Entrega em ilhas”) no meio de entregas.md, também 1 embutido e 12 mantidos, e nenhum apagado, porque nenhum pedaço antigo foi alterado; o total sobe de 46 para 47. É o ganho dos ids por conteúdo: se os ids fossem posição, esta segunda edição teria deslocado todos os pedaços depois da inserção, e o pipeline os veria como alterados.

Um terceiro caso é uma mudança só de metadado. Mudei o acesso de trocas.md de publico para interno no frontmatter. O arquivo mudou (o hash é outro), mas o texto dos pedaços não:

varredura: 0 novos, 1 alterados, 0 removidos, 8 iguais
  + alterado: notas/trocas.md: 13 pedaços (0 embutidos, 13 mantidos, 0 apagados)
resumo: {'embutidos': 0, 'mantidos': 13, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 2.0s | total na coleção: 47 pedaços

Zero embeddings, os 13 pedaços mantidos, com o payload atualizado. O arquivo foi reprocessado, mas só o campo acesso do banco mudou. Esse é o caminho que a seção 18 usa para propagar uma mudança de permissão sem custo de embedding.

Um caso final é o do arquivo removido. Apaguei prime.md e site/frete-gratis.html:

varredura: 0 novos, 1 alterados, 2 removidos, 6 iguais
  - removido: notas/prime.md (7 pedaços apagados)
  - removido: site/frete-gratis.html (3 pedaços apagados)
  + alterado: notas/prime-copia.md: 7 pedaços (7 embutidos, 0 mantidos, 0 apagados)
resumo: {'embutidos': 7, 'mantidos': 0, 'apagados': 10, 'ocr': 0, 'duplicatas': 0} em 2.1s | total na coleção: 44 pedaços

Os dois arquivos tiveram todos os pedaços apagados por um delete com filtro em doc_id (7 e 3, num total de 10). A documentação do Qdrant descreve esse recurso: em vez de listar ids, passa-se um filtro e todos os pontos que o satisfazem são removidos. Sem esse passo, o índice continuaria devolvendo o texto de uma página que já não existe, com todo o aspecto de informação válida. A linha final dessa execução mostra outro efeito, que a seção 15 explica: prime-copia.md, que era duplicata, foi promovida a documento de verdade quando o original sumiu.

14. Órfãos e auditoria

Um pedaço órfão é um registro no banco que não corresponde mais a nada na origem: o pedaço de uma versão antiga de um arquivo, o conteúdo de um documento removido, o resto de uma execução que caiu no meio. Órfãos são perigosos porque são invisíveis (a busca os devolve como qualquer outro) e porque acumulam.

O pipeline previne órfãos de duas formas. A primeira é a limpeza por documento, no passo 6 do código da seção anterior: depois de gravar os pedaços novos, apaga-se tudo o que tem o doc_id do documento e não está na lista de ids atuais. É um delete com filtro composto (must com o doc_id, must_not com HasIdCondition para a lista de ids), uma única chamada, que não depende do manifesto. A segunda é a remoção por documento, da seção 13. Mesmo assim, uma varredura de auditoria vale como cinto de segurança: ela compara os doc_id que o banco tem com os que o manifesto considera ativos.

def auditar(cli, colecao, manifesto, raiz):
    """Compara o que o banco tem com o que o manifesto e o disco dizem. Devolve o que sobra."""
    no_banco = {}
    off = None
    while True:
        pts, off = cli.scroll(colecao, limit=256, offset=off, with_payload=["doc_id", "fonte"])
        for p in pts: no_banco.setdefault(p.payload["doc_id"], p.payload["fonte"])
        if off is None: break
    esperados = {str(uuid.uuid5(NS, c)) for c, v in manifesto.d["docs"].items() if v["status"] == "ok"}
    return {d: f for d, f in no_banco.items() if d not in esperados}
auditoria limpa: {}
simulando manifesto perdido para notas/novo1.md
auditoria: {'1719f6a2-b57a-53d3-8486-3f2ec605b068': 'notas/novo1.md'}

A auditoria inicial voltou vazia. Depois simulei o que acontece quando o manifesto se perde ou fica desatualizado, apagando de propósito a entrada de novo1.md dele, e a auditoria apontou o doc_id do arquivo como pedaço sem dono. Rodando a ingestão em seguida:

varredura: 1 novos, 0 alterados, 0 removidos, 10 iguais
  + novo: notas/novo1.md: 2 pedaços (0 embutidos, 2 mantidos, 0 apagados)
resumo: {'embutidos': 0, 'mantidos': 2, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 1.9s | total na coleção: 52 pedaços
auditoria depois: {}

O pipeline tratou novo1.md como novo, mas descobriu pelo retrieve que os dois ids já estavam no banco: 0 embutidos, 2 mantidos, e a auditoria voltou limpa. O manifesto é uma otimização, e não a fonte de verdade: o banco, com ids determinísticos, tem informação suficiente para o pipeline se corrigir.

15. Deduplicação

Uma duplicata é conteúdo repetido, e ela aparece em três escalas. A mais óbvia é o arquivo copiado: o mesmo manual em duas pastas, a mesma nota exportada duas vezes. A segunda é a quase duplicata: duas versões do mesmo documento com pequenas diferenças (um PDF e a página web com o mesmo texto, a política de 2025 e a de 2026). A terceira é o pedaço repetido em documentos diferentes, como o aviso legal que fecha todas as páginas, ou o mesmo parágrafo de boas-vindas em dez manuais.

O custo de ignorar duplicatas não é só espaço. Se um parágrafo existe quatro vezes no índice, uma busca com k = 3 pode devolver três cópias do mesmo texto, e o modelo recebe um contexto em que a informação aparece repetida e nada mais. Foi por isso que a avaliação deduplica pelo texto devolvido.

O pipeline desta aula trata a primeira escala, a duplicata exata de documento, por um hash do texto extraído e limpo (não do arquivo: duas versões em formatos diferentes, com o mesmo texto, também seriam detectadas). Quando o hash do texto de um documento novo é igual ao de outro documento já indexado, ele é registrado no manifesto como duplicata (guardando de qual é cópia) e não ganha pedaços. Na primeira execução, prime-copia.md ficou assim, porque o pipeline processa os caminhos mais curtos primeiro e prime.md chegou antes. Uma regra determinística de “quem é o original” evita que o resultado mude de uma execução para outra.

A parte sutil é o que acontece quando o original some. Se o prime.md é removido e prime-copia.md continua no disco, o texto continua existindo na origem e precisa continuar buscável. O pipeline verifica, a cada execução, se as duplicatas ainda têm um original válido, e quando não têm as promove a documentos normais: foi o que a execução da seção 13 mostrou (prime-copia.md virou “alterado” e teve 7 pedaços embutidos).

As outras duas escalas eu não implementei. Para quase duplicatas, as técnicas conhecidas são o MinHash e o SimHash (hashes que mudam pouco quando o texto muda pouco) ou comparar vetores com um limiar de similaridade; este último é simples de fazer com o que Similaridade entre vetores e Busca vetorial ensinam, mas o limiar certo depende do corpus e eu não o medi. Para pedaços repetidos em documentos diferentes, a opção mais barata é calcular o hash do pedaço e indexar uma cópia só, guardando todas as fontes no payload, com um custo de manutenção maior (um pedaço deixa de pertencer a um documento só, e apagar um documento deixa de ser um delete por doc_id). Vale como regra: deduplique documentos inteiros sempre, e o resto só se a medida mostrar o problema.

16. Falhas e retomada

Num acervo de milhares de arquivos, alguma coisa vai falhar no meio: o processo é morto, a rede cai, o banco devolve erro, um PDF corrompido lança exceção. O pipeline precisa ser desenhado para que a falha seja um incidente comum, e para que depois da queda baste rodar o mesmo comando de novo. Esse comportamento tem o nome de idempotência: executar a operação uma vez ou várias dá o mesmo resultado final.

Três decisões do código sustentam isso. A primeira são os ids determinísticos, já vistos: regravar um pedaço é inofensivo. A segunda é a ordem das etapas: o manifesto só marca o documento como feito depois de gravar o último pedaço e apagar os órfãos, de modo que uma queda no meio deixa o documento “por fazer”, e a próxima execução o refaz do começo. A terceira é a gravação do manifesto a cada documento (e não só no fim), para que uma queda tarde não jogue fora o trabalho já feito.

retomada
figura 12 · retomada

A figura tem duas linhas. Na execução 1, o pipeline processa novo0.md (verde) e grava seu manifesto (seta verde grossa), mas na hora de processar novo1.md (rosa, tremendo) o processo cai, e a seta para o manifesto é barrada com ✕: ele nunca chega lá. novo2.md (rosa tracejado) nem começou. O manifesto, no meio, só conhece novo0.md. Na execução 2, o mesmo comando compara disco e manifesto: novo0.md tem o hash igual e é pulado (seta cinza lenta, 0 vetores), enquanto novo1.md e novo2.md não têm registro e são feitos (setas azuis). O resultado real, com uma queda simulada depois de um documento:

varredura: 3 novos, 0 alterados, 0 removidos, 7 iguais
  + novo: notas/novo0.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
ERRO: queda simulada depois de 1 documentos
--- segunda execução
varredura: 2 novos, 0 alterados, 0 removidos, 8 iguais
  + novo: notas/novo1.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/novo2.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
resumo: {'embutidos': 4, 'mantidos': 0, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 0.0s | total na coleção: 50 pedaços

A segunda execução processou os dois documentos que faltavam, 4 pedaços embutidos, sem tocar no que já estava pronto, e o total chegou a 50. Para os erros transitórios (a rede que cai por um segundo, um banco que demora a responder), o código tem um com_retry com espera exponencial: tenta de novo depois de 0,2 s, 0,4 s, 0,8 s, e desiste na quarta falha. Simulei um banco que falha as duas primeiras gravações:

def com_retry(f, tentativas=4, espera=0.2):
    for i in range(tentativas):
        try: return f()
        except Exception as e:
            if i == tentativas - 1: raise
            print(f"   (falha transitória: {type(e).__name__}; tentativa {i + 2} em {espera * 2 ** i:.1f}s)")
            time.sleep(espera * 2 ** i)
varredura: 1 novos, 0 alterados, 0 removidos, 10 iguais
   (falha transitória: ConnectionError; tentativa 2 em 0.2s)
   (falha transitória: ConnectionError; tentativa 3 em 0.4s)
  + novo: notas/novo9.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
resumo: {'embutidos': 2, 'mantidos': 0, 'apagados': 0, 'ocr': 0, 'duplicatas': 0} em 2.9s | total na coleção: 52 pedaços

As duas primeiras tentativas falharam e a terceira passou, sem que o resto do pipeline soubesse. Em produção, o intervalo entre tentativas costuma ganhar um componente aleatório (jitter), para que muitas instâncias não tentem todas no mesmo instante, e não implementei esse detalhe. Os lotes têm a mesma lógica: o upsert em lotes de 16 pedaços reduz o número de chamadas ao banco e ao modelo, e se um lote falha só ele é repetido.

Uma ressalva sobre o escopo do que testei: a queda foi simulada com uma exceção lançada entre dois documentos, e a falha de rede foi simulada com uma função que lança ConnectionError. Não testei a morte do processo no meio de uma gravação (por exemplo, com kill -9 durante um upsert), nem falhas parciais num servidor Qdrant real. O raciocínio de que o resultado seria seguro vem dos ids determinísticos e da ordem das etapas (a gravação dos pontos é idempotente, segundo a documentação do Qdrant, que afirma que todas as suas APIs, incluindo o carregamento de pontos, são idempotentes), mas é raciocínio, e não teste.

17. Quanto custa cada mudança, e a troca de modelo

O que determina o custo de uma atualização é o que mudou, e as medidas da aula dão uma escada.

custo mudancas
figura 13 · custo mudancas

A figura liga cinco tipos de mudança (à esquerda) ao que cada um custa em vetores novos (à direita), e a espessura, o número e a velocidade dos pacotes crescem com o custo. Só o metadado: 0 vetores (0 embutidos nos 13 pedaços do experimento da seção 13). Uma seção editada: 1 vetor. Um extrator novo (subi a versão do extrator, o que marca todos os documentos como alterados): os 11 arquivos foram reextraídos e reprocessados, e foram embutidos 0 de 52 pedaços, porque o texto extraído era o mesmo e portanto os ids também. Um chunking novo (pedaços de 60 tokens em vez de 100): 37 dos 68 pedaços resultantes foram embutidos, porque 31 pedaços tinham texto idêntico nos dois cortes e conservaram o id. Um modelo novo: todos, porque os vetores do modelo antigo não servem ao novo.

# EXT=2
varredura: 0 novos, 11 alterados, 0 removidos, 0 iguais
resumo: {'embutidos': 0, 'mantidos': 52, 'apagados': 0, 'ocr': 1, 'duplicatas': 0} em 2.2s | total na coleção: 52 pedaços
# EXT=2 TAM=60
varredura: 0 novos, 11 alterados, 0 removidos, 0 iguais
  + alterado: notas/trocas.md: 19 pedaços (16 embutidos, 3 mantidos, 10 apagados)
  + alterado: notas/entregas.md: 19 pedaços (13 embutidos, 6 mantidos, 7 apagados)
resumo: {'embutidos': 37, 'mantidos': 31, 'apagados': 21, 'ocr': 1, 'duplicatas': 0} em 2.5s | total na coleção: 68 pedaços

Mudar a versão do extrator ou do chunker não obriga a reembutir tudo: o pipeline reprocessa os documentos, e os ids por conteúdo dizem o que realmente mudou. Esse resultado depende de o texto ter ficado igual, e se o extrator novo corrigir as colunas misturadas de um PDF, o texto muda e o embedding também, como deve ser. A versão do pipeline fica no manifesto e no payload (pipeline), e mudar a versão força a reexecução.

A troca de modelo de embedding é o caso em que nada se aproveita. Dois modelos produzem vetores em espaços diferentes, e comparar um vetor de um com um vetor do outro não tem sentido, mesmo quando as dimensões coincidem. Os dois modelos que uso nas aulas têm 384 dimensões, e por isso é possível cometer o erro sem que o banco reclame:

-- erro clássico: consulta embutida com OUTRO modelo, mesma dimensão (384)
  0.121 notas/interno/politica-desconto.md | # Política interna de descontos
  0.107 docx/politica-privacidade.docx | # Política de privacidade
  0.106 notas/trocas.md | ## Trocas em loja física  Nas 14 lojas da rede, é possível t
-- consulta com o modelo certo
  0.911 [interno] notas/trocas.md | ## Arrependimento  Quem compra pela internet pode desistir da compra s
  0.887 [publico] notas/entregas.md | ## Retirada em loja  O pedido fica pronto para retirada em 2 horas dep
  0.880 [interno] notas/trocas.md | ## Produtos com defeito  Quando o produto chega com defeito ou para de

Embuti a mesma pergunta (“quantos dias tenho para desistir de uma compra?”) com o MiniLM e a consultei na coleção feita com o e5. O banco não levantou erro, devolveu três resultados com cossenos em torno de 0,11, e o topo é a política interna de descontos, sem relação com a pergunta. Com o modelo certo, o topo é a seção “Arrependimento” (cosseno 0,911). O erro é silencioso e o resultado parece uma lista normal. É por isso que o manifesto do pipeline registra o nome do modelo e se recusa a rodar com outro:

-- tentativa de reusar o manifesto com outro modelo
   coleção feita com 'e5', não com 'minilm': troca de modelo pede coleção nova

A solução é uma coleção nova, construída ao lado da antiga, enquanto a antiga continua servindo.

troca modelo
figura 14 · troca modelo

A figura mostra a pasta de origem (laranja) passando uma única vez pela ingestão (terracota) e alimentando dois caminhos. Em cima, o modelo atual (e5) e sua coleção aurora_e5_v1 (verde), que serve o tráfego (seta grossa “hoje” para a aplicação). Embaixo, o modelo novo (MiniLM, pulsando, com o selo de reindexação total) e a coleção nova aurora_minilm_v1 (azul), em validação (seta pontilhada “teste”). A caixa rosa de erro, entre as duas, mostra a consulta feita com o modelo errado, barrada. Só quando a coleção nova passar na avaliação é que a aplicação passa a apontar para ela. A reindexação real, no mesmo código com outro modelo:

-- coleção nova ao lado, com o novo modelo
varredura: 11 novos, 0 alterados, 0 removidos, 0 iguais
  + novo: notas/novo0.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/novo1.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/novo2.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/novo9.md: 2 pedaços (2 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/trocas.md: 13 pedaços (13 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/entregas.md: 13 pedaços (13 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/prime-copia.md: 7 pedaços (7 embutidos, 0 mantidos, 0 apagados)
  + novo: pdf/manual-vendedor.pdf: 5 pedaços (5 embutidos, 0 mantidos, 0 apagados)
  ! pdf/contrato-escaneado.pdf: página 1 sem camada de texto (provável digitalização, precisa de OCR)
  + novo: docx/politica-privacidade.docx: 3 pedaços (3 embutidos, 0 mantidos, 0 apagados)
  + novo: notas/interno/politica-desconto.md: 3 pedaços (3 embutidos, 0 mantidos, 0 apagados)
resumo: {'embutidos': 52, 'mantidos': 0, 'apagados': 0, 'ocr': 1, 'duplicatas': 0} em 0.5s | total na coleção: 52 pedaços
-- a mesma pergunta na coleção nova
  0.687 notas/trocas.md | ## Arrependimento  Quem compra pela internet pode desistir d
  0.684 pdf/manual-vendedor.pdf | ## 2. Prazos de envio  O vendedor tem até 2 dias úteis para 
  0.667 notas/entregas.md | ## Retirada em loja  O pedido fica pronto para retirada em 2
-- colecoes: ['aurora_e5_v1', 'aurora_minilm_v1']

Foram 52 pedaços embutidos em uma coleção nova, e as duas coleções existem lado a lado. O custo de ter as duas é o armazenamento duplicado durante a transição, e a vantagem é poder voltar atrás trocando o ponteiro. A documentação de coleções do Qdrant descreve os aliases, nomes alternativos para uma coleção cuja troca é atômica (“nenhuma requisição concorrente é afetada durante a troca”, nas palavras da página), que é o mecanismo natural para a virada; eu não testei os aliases no modo local. A mesma página descreve outra rota, adicionar um vetor nomeado novo na mesma coleção e reembutir os pontos aos poucos, e também não a testei.

18. Permissões e dados sensíveis

Num RAG, o banco vetorial passa a ser uma cópia dos seus documentos, e a cópia precisa respeitar quem pode ler o original. Se um PDF do financeiro só era acessível à diretoria no servidor de arquivos e o RAG o indexa sem rótulo, qualquer pessoa que use o assistente pode, com a pergunta certa, receber o conteúdo dele. A busca por significado é indiferente a quem está perguntando.

A solução é gravar a permissão no payload de cada pedaço, na ingestão, e filtrar por ela na consulta.

permissoes
figura 15 · permissoes

A figura tem dois usuários laranja. O visitante tem acessos [publico] e o filtro da sua consulta (azul) é acesso in [publico]; a equipe tem [publico, interno]. Dois pedaços estão no banco: entregas.md (verde, acesso: publico) e trocas.md (rosa, com cadeado, acesso: interno). Para o visitante, a seta para entregas.md passa (grossa) e a seta para trocas.md é barrada com ✕; para a equipe, as duas passam. À direita, a ingestão (terracota) grava o acesso do frontmatter no payload dos dois (setas pontilhadas). A execução real, depois de eu ter marcado trocas.md como interno (o experimento da seção 13), com a mesma pergunta, “quantos dias para desistir da compra?”:

busca como visitante (só 'publico'):
  0.894 [publico] notas/entregas.md | ## Retirada em loja  O pedido fica pronto para retirada em 2 horas dep
  0.883 [publico] notas/entregas.md | ## Quando ninguém está em casa  O entregador faz até 3 tentativas em d
  0.880 [publico] notas/entregas.md | ## Prazos por região  Os prazos abaixo contam a partir da postagem do 
busca como equipe (publico + interno):
  0.915 [interno] notas/trocas.md | ## Arrependimento  Quem compra pela internet pode desistir da compra s
  0.894 [publico] notas/entregas.md | ## Retirada em loja  O pedido fica pronto para retirada em 2 horas dep
  0.886 [interno] notas/trocas.md | ## Produtos com defeito  Quando o produto chega com defeito ou para de

O visitante nunca vê a seção “Arrependimento”, que é a resposta, e a equipe a vê em primeiro lugar (0,915). Repare no que o visitante recebe: três pedaços de entregas.md com cossenos entre 0,880 e 0,894, de modo que uma busca com filtro nunca devolve vazio, devolve os melhores entre os permitidos, mesmo que nenhum responda. Para a aplicação que monta o prompt, isso significa que o texto “não sei” precisa vir de outro lugar (por exemplo, de um limiar de similaridade ou da avaliação do próprio modelo), e não da lista vazia. O filtro é aplicado dentro da consulta ao banco, antes do ranking, e não depois: filtrar depois do top-k pode devolver menos de k resultados ou nenhum, e vaza informação de existência (Banco de dados vetorial trata de como os filtros interagem com o índice).

Alguns cuidados que vão além do código. O padrão do pipeline é restritivo: sem acesso, o documento é interno. A lista de acessos do usuário precisa vir do sistema de identidade da empresa, no servidor, e nunca de um campo que o cliente envia. A permissão do arquivo de origem pode mudar (alguém o torna restrito), e para isso o pipeline precisa reprocessar o arquivo mesmo que o texto não tenha mudado, o que o hash do arquivo garante, e atualizar o payload sem reembutir, como na seção 13. E existem dados que não deveriam entrar no índice de jeito nenhum (senhas, chaves de API, documentos pessoais de clientes); a ingestão é o único momento em que dá para barrar ou mascarar esses campos, com um passo de limpeza que remova padrões conhecidos (como números de CPF e cartões) antes do chunking. Nesta aula não implementei mascaramento. O vetor também merece o mesmo cuidado que o texto: ele é derivado do conteúdo, e é prudente tratá-lo como tão sensível quanto ele. Por fim, quem tem direito a pedir a remoção dos seus dados exige que os vetores, o payload e as cópias de segurança também sejam apagados, e o delete por doc_id é o ponto de partida, mas as regras legais (como a LGPD no Brasil) e o que elas pedem de backups e logs são questão para quem entende de direito, e eu não verifiquei esse ponto.

Parte 5: Fechar

19. Erros comuns e boas práticas

O primeiro erro é confiar na extração sem olhar. A extração falha em silêncio, e a frase-ouro e a amostra impressa da seção 9 custam minutos. O segundo é usar o mesmo extrator para tudo (um get_text() para HTML, um extract_text() para PDF): cada formato esconde o texto de um jeito, e a saída de um extrator ingênuo parece texto e não é. O terceiro é indexar o que não deveria ir para o índice (menus, rodapés, comentários, “Página 3 de 12”): o ruído tem vetor e compete com o conteúdo.

O quarto é usar ids sequenciais ou aleatórios. Com ids por posição, qualquer inserção no meio do documento reembute o resto, e com ids aleatórios regravar cria duplicatas, e o upsert deixa de ser seguro para repetir. O quinto é reconstruir o índice inteiro a cada atualização, que funciona até o acervo crescer e a conta chegar. O sexto é esquecer de apagar: arquivos removidos e pedaços de versões antigas continuam respondendo perguntas. O sétimo é atualizar o manifesto antes de gravar no banco, o que transforma uma queda em perda silenciosa (o manifesto diz “feito” e o banco não tem os pedaços).

O oitavo é trocar o modelo de embedding misturando coleções. Por mais parecidos que sejam os nomes e as dimensões, vetores de modelos diferentes não se comparam, e o banco não avisa. O nono é deixar a permissão para a camada da aplicação, sem gravá-la no pedaço: quando o índice tem tudo e a aplicação decide depois quem vê o quê, um bug na aplicação expõe tudo. O décimo é tratar OCR, tabelas e colunas como detalhes a resolver depois, quando são justamente os formatos em que a maioria dos documentos corporativos vive. Um RAG com acervo de PDFs escaneados e sem OCR só encontra o que sobrou.

20. Para ir além

Olhe os textos que a ingestão deixou no banco. Entre as perguntas que o RAG vai receber, muitas citam códigos, siglas e nomes exatos: “o que significa P1 no suporte?”, “para que serve o cabeçalho X-Aurora-Assinatura?”, “quanto custa o frete de R$ 18,90?”. A busca por significado é ótima para “quero meu dinheiro de volta” e fraca quando a resposta depende de um token raro, porque dois vetores podem ficar próximos por falarem do mesmo assunto, sem que nenhum dos dois contenha o código exato. Para esses casos, a busca por palavras exatas, a mesma que bancos de dados e mecanismos de busca fazem há décadas, tem a vantagem inversa. A Busca híbrida combina as duas. E o que ela vai usar vem da ingestão: o campo texto do payload (que guarda o texto exato de cada pedaço), os metadados para filtrar e os ids estáveis para juntar os resultados das duas buscas no mesmo pedaço.

21. Exercícios

Os exercícios 1 a 3 são contas e raciocínio. Os demais rodam em CPU, sem chave de API, e usam os códigos desta aula copiados para uma pasta sua. Você precisa de 5 a 10 arquivos seus, de formatos variados, e de um ambiente virtual com pypdf, pdfplumber, trafilatura, python-docx, qdrant-client e sentence-transformers.

  1. Um acervo tem 20.000 documentos de 15 pedaços em média. Em uma semana, 40 documentos são editados, e a edição média altera 2 pedaços de cada. Quantos embeddings uma reconstrução total gasta por semana e quantos a ingestão incremental com ids por conteúdo gasta? (Resposta: reconstrução, 20.000 × 15 = 300.000 pedaços; incremental, 40 × 2 = 80, supondo que cada pedaço editado ganhe um id novo. A razão é de 3.750 vezes. Na prática, some o custo de calcular o hash dos 20.000 arquivos, que é pequeno, mas não zero.)

  2. Por que o id de um pedaço inclui o contador de ocorrência n, e o que aconteceria sem ele numa nota com dois parágrafos idênticos? (Resposta: os dois teriam o mesmo hash e o mesmo id, o segundo upsert sobrescreveria o primeiro e o documento ficaria com um pedaço a menos; a busca ainda acharia o texto, mas indice, a posição e o total de pedaços ficariam incoerentes.)

  3. Você sobe a versão do extrator de PDF e a ingestão marca 5.000 documentos como alterados. Em que condição o custo de embedding será próximo de zero, e em que condição será próximo do total? (Resposta: próximo de zero se o texto extraído de quase todos os PDFs for idêntico ao anterior, como no experimento da seção 17; próximo do total se a mudança altera o texto de todos, por exemplo remover rodapés de todas as páginas ou corrigir a ordem das colunas.)

  4. Rode o extrator de PDF sobre um PDF seu e imprima as primeiras 40 linhas com pypdf e com pdfplumber. Anote, antes de olhar qualquer métrica, o que está errado em cada uma (colunas misturadas, cabeçalho repetido, tabela quebrada).

  5. Escolha uma frase de cada documento seu e transforme-a em teste: assert frase in extrair(...).texto. Quantos documentos reprovam? O que a reprovação revela, falha da extração ou frase com quebra de linha no meio?

  6. Reproduza a seção 6 com uma página sua (artigo de blog, página de documentação). Compare o get_text() com o trafilatura.extract em quantidade de tokens, e escolha uma pergunta cuja resposta não existe na página para ver o que a leitura ingênua devolve em primeiro lugar.

  7. Implemente a ingestão incremental para uma pasta de notas Markdown suas, com manifesto em JSON. Execute: tudo novo, nada mudou, uma edição, uma remoção, e anote em cada execução quantos pedaços foram embutidos, mantidos e apagados. A edição de uma só frase deveria custar 1 embedding.

  8. Provoque o erro silencioso da seção 17: crie duas coleções com modelos de mesma dimensão, consulte uma com o modelo da outra e compare os cossenos do topo. Depois acrescente ao manifesto o nome do modelo e faça o programa se recusar a misturá-los.

  9. Marque dois dos seus documentos como interno e implemente o filtro de acesso na consulta. Pergunte, como visitante, algo cuja resposta só existe no documento interno. O que volta, e como o seu prompt poderia reconhecer que a resposta não está no contexto?

  10. Simule uma queda: faça o programa lançar uma exceção no terceiro documento, depois rode de novo. Confira, contando pontos no banco, que não houve duplicatas, e descreva em duas frases o que a ordem “gravar, depois atualizar o manifesto” garante.

22. Glossário

TermoSignificado
IngestãoPipeline que transforma arquivos em registros indexados e mantém o índice em dia
ExtraçãoEtapa que tira o texto de um formato (PDF, HTML, DOCX, Markdown)
BoilerplateMoldura repetida de uma página (menu, rodapé, aviso de cookies) que não é conteúdo
Camada de textoCaracteres reais dentro de um PDF, por oposição a uma página que é só imagem
OCRReconhecimento óptico de caracteres: transforma a imagem de um texto em texto
FrontmatterBloco YAML no começo de uma nota Markdown, com propriedades como título e tags
WikilinkLink interno do Obsidian, no formato [[nota]] ou [[nota|texto]]
Hash (sha256)Impressão digital de um conteúdo: mesma entrada, mesma saída; entrada diferente, saída diferente
uuid5UUID calculado a partir de um namespace e de um nome, repetível em qualquer máquina
Id determinísticoId calculado do conteúdo ou do nome, não sorteado nem sequencial
ManifestoRegistro do que já foi indexado, com hashes e versões, usado para detectar mudanças
Ingestão incrementalProcessar só o que foi criado, alterado ou removido desde a última execução
Pedaço órfãoRegistro no banco sem correspondência na origem
IdempotênciaPropriedade de uma operação que, repetida, deixa o mesmo estado final
Backoff exponencialEsperar cada vez mais entre as tentativas (0,2 s, 0,4 s, 0,8 s) depois de uma falha transitória
Frase-ouroFrase conhecida usada como teste de que a extração preservou o texto inteiro
DuplicataDocumento ou pedaço com conteúdo idêntico (ou quase) a outro
Reindexação totalRecalcular todos os vetores, necessária ao trocar o modelo de embedding
Alias (coleção)Nome alternativo de uma coleção, cuja troca de destino é atômica

Fontes

Consultadas em 15 de outubro de 2026.

  • Qdrant: Points: ids de ponto aceitos (inteiros sem sinal de 64 bits e UUID); upsert insere ou atualiza; “todas as APIs do Qdrant, incluindo o carregamento de pontos, são idempotentes”; operações de lote executadas em ordem (upsert, delete, set payload, overwrite payload e outras); delete por filtro. A página é longa e li só os primeiros 100 mil caracteres, o que cobre as seções que usei.
  • Qdrant: Collections: aliases (troca atômica, sem afetar requisições concorrentes), vetores com mesma dimensionalidade e uma única métrica por coleção, e a rota alternativa de acrescentar um vetor nomeado para migrar de modelo. Não testei aliases nem vetores nomeados.
  • Trafilatura: uso em Python: função extract, cascata de extratores (regras, readability, jusText), formatos de saída incluindo markdown, include_tables, favor_precision, prune_xpath e o contraste com html2txt.
  • pypdf: extrair texto: texto posicionado de forma absoluta, sem camada semântica; modo layout; o pypdf não extrai texto de imagens e recomenda OCR (como o Tesseract) para páginas digitalizadas.
  • pdfplumber (README): extract_text, extract_text_lines (experimental), find_tables/extract_tables, crop e filter; funciona melhor com PDFs gerados por máquina e não faz OCR.
  • Obsidian: sintaxe Markdown do Obsidian: links internos, embeds, comentários %% %% e callouts como extensões, algumas específicas do Obsidian. A página que li não menciona propriedades ou frontmatter, e o tratamento do frontmatter vem do meu código (YAML entre ---).
  • Python: módulo uuid: uuid5 gera um UUID a partir do SHA-1 de um namespace e de um nome, de forma determinística.
  • Banco de dados vetorial e Chunking: payload, upsert, filtros e o corte por estrutura de até 100 tokens usado aqui.

Medido por mim (ambiente descartável no scratchpad da sessão, macOS, Python 3.12, nada instalado fora dele; instalei só neste ambiente as bibliotecas pypdf 6.20.1, pdfplumber 0.11.10, trafilatura 2.3.1, beautifulsoup4 4.15.0, python-docx 1.2.0, reportlab 5.0.1 e qdrant-client 1.19.1, e usei o sentence-transformers 6.1.0 que já existia): a pasta de nove arquivos, gerada por script; as extrações das seções 5 a 8; a comparação entre pypdf e pdfplumber na página de duas colunas; a leitura ingênua e a limpa do HTML (5 contra 3 pedaços, 220 contra 173 tokens); todas as execuções das seções 12 a 18 (primeira execução, execução sem mudanças, edição de um pedaço, inserção de uma seção, mudança só de permissão, remoção de arquivos, queda simulada e retomada, falhas transitórias com retry, auditoria, mudança de versão do extrator e do corte, troca de modelo, busca com e sem filtro de acesso). Os modelos são o multilingual-e5-small e o paraphrase-multilingual-MiniLM-L12-v2, do cache local do Hugging Face.

Sem execução ou sem fonte: não verifiquei nenhum OCR (não instalei o Tesseract nem outra ferramenta), e portanto o PDF escaneado só foi detectado e marcado, não lido. O corpus é pequeno e inventado, os PDFs foram gerados por mim com o reportlab (PDFs reais, de editoras, scanners e exportadores variados, têm defeitos que o meu teste não tem, como colunas de larguras diferentes, notas de rodapé, texto em curva e fontes sem mapeamento de caracteres), e a página HTML também é minha e sem JavaScript. Os limiares do extrator de PDF (17 e 12,5 pontos para títulos, 5 pontos de espaço entre parágrafos, 60% para cabeçalho repetido) servem a esses arquivos e provavelmente não servem aos seus. Testei o Qdrant em modo local, sem servidor, sem concorrência e sem aliases; a queda foi simulada com exceções, sem matar o processo; a falha de rede foi simulada. Não implementei deduplicação de quase duplicatas, nem de pedaços repetidos entre documentos, nem mascaramento de dados pessoais; o que a aula diz sobre MinHash e SimHash é conhecimento geral e não testado, e o que diz sobre a LGPD e sobre embeddings como dado sensível é prudência de prática, sem verificação jurídica ou técnica da minha parte. Os tempos citados incluem o carregamento do modelo e não servem como medida de desempenho. Um caso que não tratei: renomear ou mover um arquivo, que o pipeline vê como remoção mais criação (o conteúdo é reembutido) porque a identidade é o caminho. As 15 figuras foram renderizadas e examinadas; as principais (1, 2, 10, 11 e 12) tiveram duas ou mais rodadas.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?