Post

LLM local para descrição de produto: gerar texto sem inventar atributos

Se preferir começar por um resumo, peça um TL;DR ao ChatGPT ou ao Claude .

Gist ID: 4bf43c9e0ab47b6afc845dcb1312eedd

Em 2024, testei um modelo pequeno executado com Ollama para reescrever a descrição de um ventilador de refrigeração. O prompt continha nome, duas características e listas de compatibilidade e incompatibilidade. A resposta acrescentou afirmações que não estavam na entrada.

O resultado gerado não foi preservado no post original. Portanto, consigo registrar que observei informações sem suporte, mas não reconstituir quais foram nem medir a frequência do problema. Este texto revisa o desenho do experimento, não compara modelos.

O erro não era falta de contexto

Minha primeira anotação sugeria aplicar RAG como próximo passo. Para este caso, essa não é a correção inicial: os fatos necessários já estavam no prompt.

RAG combina geração com recuperação de documentos externos. A técnica é útil quando o sistema precisa localizar informações que não cabem ou não foram fornecidas diretamente na solicitação. O artigo de Lewis e outros, Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks, é uma referência primária para essa arquitetura.

Recuperar mais texto não garante que a resposta ficará restrita às fontes. Neste experimento, o problema imediato era outro: uma saída livre estava sendo tratada como descrição publicável.

Separe fatos de texto gerado

Uma abordagem mais segura mantém os dados de produto em uma estrutura controlada e pede ao modelo apenas uma sugestão de resumo. Compatibilidade, incompatibilidade e características são renderizadas diretamente da fonte, sem passar pela reescrita do modelo.

O exemplo abaixo também solicita uma saída estruturada. O Ollama aceita um JSON Schema no parâmetro format, conforme a documentação de Structured Outputs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
from __future__ import annotations

import json
import os
from typing import Any

from ollama import Client


FACTS = {
    "product_name": "Khadas 3705 Cooling Fan",
    "features": {
        "high_airflow": "High Airflow",
        "super_quiet": "Super Quiet",
    },
    "compatible_with": [
        "Edge",
        "Edge-V",
        "VIM3",
        "VIM2 v1.4",
        "Edge Heatsink",
        "New VIM Heatsink",
    ],
    "incompatible_with": [
        "VIM1 (all versions)",
        "VIM2 (v1.2 and earlier)",
        "Original VIM Heatsink",
    ],
}

ALLOWED_FACT_IDS = [
    "product_name",
    *[f"feature.{key}" for key in FACTS["features"]],
]

OUTPUT_SCHEMA = {
    "type": "object",
    "properties": {
        "summary_candidate": {"type": "string"},
        "facts_used": {
            "type": "array",
            "items": {"type": "string", "enum": ALLOWED_FACT_IDS},
            "uniqueItems": True,
        },
    },
    "required": ["summary_candidate", "facts_used"],
    "additionalProperties": False,
}


def generate_candidate(client: Client, model: str) -> dict[str, Any]:
    source = {
        "product_name": FACTS["product_name"],
        "features": FACTS["features"],
    }
    prompt = f"""
Crie uma frase curta em português para apresentar o produto.

Regras:
- use somente os fatos presentes em SOURCE;
- não acrescente benefícios, materiais, desempenho ou casos de uso;
- não mencione compatibilidade: ela será renderizada separadamente;
- liste em facts_used os identificadores que sustentam a frase;
- responda conforme OUTPUT_SCHEMA.

SOURCE:
{json.dumps(source, ensure_ascii=False, indent=2)}

OUTPUT_SCHEMA:
{json.dumps(OUTPUT_SCHEMA, ensure_ascii=False, indent=2)}
""".strip()

    response = client.chat(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        format=OUTPUT_SCHEMA,
        options={"temperature": 0},
    )
    return json.loads(response.message.content)


def validate_candidate(candidate: dict[str, Any]) -> None:
    if set(candidate) != {"summary_candidate", "facts_used"}:
        raise ValueError("A resposta contém campos inesperados")
    if not isinstance(candidate["summary_candidate"], str):
        raise ValueError("summary_candidate deve ser uma string")

    facts_used = candidate["facts_used"]
    if not isinstance(facts_used, list):
        raise ValueError("facts_used deve ser uma lista")
    unknown = set(facts_used) - set(ALLOWED_FACT_IDS)
    if unknown:
        raise ValueError(f"Fatos desconhecidos: {sorted(unknown)}")


def render_verified_fields() -> str:
    features = "\n".join(
        f"- {feature}" for feature in FACTS["features"].values()
    )
    compatible = "\n".join(
        f"- {item}" for item in FACTS["compatible_with"]
    )
    incompatible = "\n".join(
        f"- {item}" for item in FACTS["incompatible_with"]
    )
    return f"""Características:
{features}

Compatível com:
{compatible}

Incompatível com:
{incompatible}"""


def main() -> None:
    model = os.environ.get("OLLAMA_MODEL")
    if not model:
        raise RuntimeError("Defina OLLAMA_MODEL com um modelo já instalado")

    client = Client(
        host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
        timeout=30.0,
    )
    candidate = generate_candidate(client, model)
    validate_candidate(candidate)

    print("CANDIDATO — REVISÃO HUMANA OBRIGATÓRIA")
    print(candidate["summary_candidate"])
    print()
    print(render_verified_fields())


if __name__ == "__main__":
    main()

Para executar, instale o cliente, escolha um modelo disponível na sua máquina e inicie o Ollama:

1
2
3
python -m pip install ollama
export OLLAMA_MODEL='nome-do-modelo-instalado'
python product_description.py

O código não fixa um modelo porque nomes disponíveis, versões e requisitos de hardware mudam. O repositório oficial do ollama-python documenta o cliente e informa que o Ollama deve estar instalado e em execução.

O que esta versão controla

Os campos críticos são copiados de FACTS para a saída final. O modelo não pode alterar as listas de compatibilidade e incompatibilidade. O JSON Schema limita o formato da resposta e facts_used torna explícita a alegação de quais fatos sustentam o resumo.

Isso melhora a rastreabilidade, mas não prova que a frase é fiel. Um modelo pode produzir JSON válido, citar um identificador permitido e ainda escrever uma conclusão que o dado não sustenta. Temperatura zero reduz variação; não é uma garantia de veracidade.

Validação antes de publicar

Minha recomendação para esse fluxo é:

  1. preservar a entrada original e a identificação do modelo;
  2. validar a estrutura da resposta;
  3. renderizar atributos críticos diretamente da fonte;
  4. comparar o resumo com os fatos apresentados;
  5. reprovar qualquer benefício ou especificação sem evidência;
  6. manter revisão humana antes da publicação;
  7. avaliar o sistema com um conjunto de produtos e critérios registrados.

Se os fatos estiverem espalhados em manuais, fichas técnicas e bancos de dados, uma etapa de recuperação pode ser adicionada. Nesse caso, cada trecho recuperado precisa conservar sua origem e a avaliação deve medir tanto a recuperação quanto a fidelidade da geração.

O aprendizado do experimento é limitado, mas útil: um LLM pode sugerir redação; não deve se tornar, por isso, a fonte dos atributos do produto.