O problema que o LIKE não resolve

Você tem uma tela com um grid de clientes. O usuário começa a digitar “João Si” e espera ver “João Silva”, “João Silveira”, “João Simões”. Rápido. Enquanto digita.

A solução mais imediata é usar LIKE:

1
SELECT * FROM Clientes WHERE Nome LIKE '%João Si%'

Funciona. Para 100 registros. Para 500 mil registros em produção, com vários usuários digitando ao mesmo tempo, esse LIKE '%...%' é um full table scan — o banco lê cada linha da tabela, uma a uma, sem usar índice. A query fica lenta, o banco sofre, o usuário abandona a tela.

O Full-Text Search (FTS) resolve exatamente isso: um mecanismo de indexação e busca textual especialmente projetado para consultas em texto, com suporte a:

  • Busca por palavras e frases exatas
  • Relevância e ranking de resultados
  • Radicais de palavras (stemming): “rodando” encontra “rodar”
  • Sinônimos e stopwords
  • Operadores lógicos: AND, OR, NOT, NEAR
  • Índices invertidos — o banco não lê cada linha, lê o índice

Neste artigo vamos implementar o cenário completo: um frontend Angular com busca reativa paginada que chama uma API REST C# que usa Full-Text Search no banco de dados — com exemplos para SQL Server, PostgreSQL e Oracle.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Veja também os exemplos Angular (search-ui) e os scripts SQL.


O cenário: Angular + API REST + Banco via FTS

 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
┌─────────────────────────────────────────────────────────────┐
│  Angular Grid (frontend)                                    │
│                                                             │
│  ┌─────────────────────────────────────────────┐            │
│  │     Digite o nome do cliente...             │            │
│  └─────────────────────────────────────────────┘            │
│       ↓ debounce 400ms + mínimo 3 caracteres                │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Nome          | CPF         | Email                 │   │
│  │  João Silva    | 123.456.789 | [email protected]  │   │
│  │  João Silveira | 987.654.321 | [email protected]  │   │
│  └──────────────────────────────────────────────────────┘   │
│  Página 1 de 12  [< Anterior] [Próxima >]                   │
└─────────────────────────────────────────────────────────────┘
              │ GET /api/clientes?q=João+Si&pagina=1&tamanho=20
┌─────────────────────────────────────────────────────────────┐
│  ASP.NET Core API                                           │
│  ClientesController → ClienteService → IClienteRepository   │
└─────────────────────────────────────────────────────────────┘
              │ Full-Text Search query paginada
┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│  SQL Server  │   │  PostgreSQL  │   │    Oracle    │
│  CONTAINS()  │   │  to_tsquery  │   │  CONTAINS()  │
│  FT Index    │   │  GIN Index   │   │  CTX Domain  │
└──────────────┘   └──────────────┘   └──────────────┘

Frontend Angular: busca reativa com debounce

Por que SPA?

O padrão de “busca durante digitação” é nativo em frameworks SPA (Single-Page Applications) como Angular, React e Vue. Nesses frameworks, toda a lógica de interação roda no navegador, e o modelo de programação reativa — com RxJS no Angular — fornece os operadores debounceTime, distinctUntilChanged e switchMap diretamente, sem gambiarras.

Em frameworks server-side (Razor Pages, Blazor Server, Thymeleaf, JSF…), o fluxo de renderização é diferente: cada interação precisa ir ao servidor e retornar HTML atualizado. Para implementar o mesmo comportamento, você depende de JavaScript adicional (seja escrito à mão, seja via HTMX, Turbo, Alpine.js, etc.) para capturar o evento de digitação, aplicar o debounce no cliente e disparar a requisição assíncrona — adicionando uma camada de complexidade que não existe no modelo SPA. O resultado funciona, mas o desenvolvimento é mais trabalhoso e a manutenção mais fragmentada (lógica dividida entre o servidor e scripts JS avulsos).

Se o seu projeto já usa um framework server-side, avalie se vale introduzir uma ilha de interatividade SPA (Angular Element, React isolado) apenas para esse componente de busca, ou se um script JS leve como HTMX é suficiente para o caso de uso.

O Angular com RxJS torna elegante o padrão de “pesquisa durante digitação”. A ideia é simples: não disparar uma requisição a cada tecla pressionada — isso sobrecarregaria o servidor. Usamos dois filtros:

  1. Mínimo de 3 caracteres — evitar buscas com texto muito curto
  2. Debounce de 400ms — aguardar o usuário parar de digitar antes de enviar

Componente de busca

 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
// clientes-grid.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs';
import {
  debounceTime,
  distinctUntilChanged,
  filter,
  switchMap,
  takeUntil
} from 'rxjs/operators';
import { ClienteService } from './cliente.service';
import { PagedResult, ClienteDto } from './models';

@Component({
  selector: 'app-clientes-grid',
  templateUrl: './clientes-grid.component.html'
})
export class ClientesGridComponent implements OnInit, OnDestroy {
  buscaControl = new FormControl('');
  clientes: ClienteDto[] = [];
  paginaAtual = 1;
  totalPaginas = 1;
  totalRegistros = 0;
  carregando = false;
  termoBusca = '';

  private destroy$ = new Subject<void>();

  constructor(private clienteService: ClienteService) {}

  ngOnInit(): void {
    this.buscaControl.valueChanges.pipe(
      debounceTime(400),                    // aguarda 400ms após a última tecla
      distinctUntilChanged(),               // ignora se o valor não mudou
      filter(termo => !termo || termo.length === 0 || termo.length >= 3),
      takeUntil(this.destroy$)
    ).subscribe(termo => {
      this.termoBusca = termo ?? '';
      this.paginaAtual = 1;                 // volta para a primeira página a cada nova busca
      this.buscar();
    });

    // Carrega a primeira página sem filtro ao abrir a tela
    this.buscar();
  }

  buscar(): void {
    this.carregando = true;
    this.clienteService
      .listar(this.termoBusca, this.paginaAtual, 20)
      .pipe(takeUntil(this.destroy$))
      .subscribe({
        next: (resultado) => {
          this.clientes       = resultado.itens;
          this.totalPaginas   = resultado.totalPaginas;
          this.totalRegistros = resultado.totalRegistros;
          this.carregando     = false;
        },
        error: () => {
          this.carregando = false;
        }
      });
  }

  irParaPagina(pagina: number): void {
    if (pagina < 1 || pagina > this.totalPaginas) return;
    this.paginaAtual = pagina;
    this.buscar();
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Template HTML

 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
<!-- clientes-grid.component.html -->
<div class="grid-container">
  <div class="search-bar">
    <input
      [formControl]="buscaControl"
      placeholder="🔍 Digite o nome do cliente..."
      class="input-busca"
    />
    <span *ngIf="carregando" class="spinner">Buscando...</span>
  </div>

  <table class="grid-tabela">
    <thead>
      <tr>
        <th>Nome</th>
        <th>CPF/CNPJ</th>
        <th>E-mail</th>
        <th>Cidade</th>
      </tr>
    </thead>
    <tbody>
      <tr *ngFor="let cliente of clientes">
        <td>{{ cliente.nome }}</td>
        <td>{{ cliente.cpfCnpj }}</td>
        <td>{{ cliente.email }}</td>
        <td>{{ cliente.cidade }}</td>
      </tr>
      <tr *ngIf="!carregando && clientes.length === 0">
        <td colspan="4" class="sem-resultado">Nenhum cliente encontrado.</td>
      </tr>
    </tbody>
  </table>

  <div class="paginacao">
    <span>{{ totalRegistros }} registros | Página {{ paginaAtual }} de {{ totalPaginas }}</span>
    <button (click)="irParaPagina(paginaAtual - 1)" [disabled]="paginaAtual <= 1">‹ Anterior</button>
    <button (click)="irParaPagina(paginaAtual + 1)" [disabled]="paginaAtual >= totalPaginas">Próxima ›</button>
  </div>
</div>

Service Angular

 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
// cliente.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { PagedResult, ClienteDto } from './models';

@Injectable({ providedIn: 'root' })
export class ClienteService {
  private readonly apiUrl = '/api/clientes';

  constructor(private http: HttpClient) {}

  listar(
    q: string,
    pagina: number,
    tamanho: number
  ): Observable<PagedResult<ClienteDto>> {
    let params = new HttpParams()
      .set('pagina', pagina)
      .set('tamanho', tamanho);

    if (q?.trim()) {
      params = params.set('q', q.trim());
    }

    return this.http.get<PagedResult<ClienteDto>>(this.apiUrl, { params });
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// models.ts
export interface ClienteDto {
  id: number;
  nome: string;
  cpfCnpj: string;
  email: string;
  cidade: string;
}

export interface PagedResult<T> {
  itens: T[];
  totalRegistros: number;
  totalPaginas: number;
  paginaAtual: number;
  tamanhoPagina: number;
}

Por que switchMap? O switchMap cancela a requisição anterior quando uma nova chega. Se o usuário digitar rapidamente e o servidor ainda não respondeu a primeira requisição, a segunda cancela a primeira — sem risco de resultados chegando fora de ordem.


API REST com ASP.NET Core

Controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ClientesController.cs
[ApiController]
[Route("api/clientes")]
public class ClientesController(IClienteService service) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<PagedResult<ClienteDto>>> Listar(
        [FromQuery] string? q,
        [FromQuery] int pagina = 1,
        [FromQuery] int tamanho = 20,
        CancellationToken ct = default)
    {
        if (tamanho > 100) tamanho = 100;  // limite máximo por segurança
        if (pagina < 1)    pagina = 1;

        var resultado = await service.ListarAsync(q, pagina, tamanho, ct);
        return Ok(resultado);
    }
}

Modelos e DTOs

 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
// PagedResult.cs
public record PagedResult<T>(
    IReadOnlyList<T> Itens,
    int TotalRegistros,
    int TotalPaginas,
    int PaginaAtual,
    int TamanhoPagina);

// ClienteDto.cs
public record ClienteDto(
    int Id,
    string Nome,
    string CpfCnpj,
    string Email,
    string Cidade);

// Cliente.cs (entidade)
public class Cliente
{
    public int    Id        { get; set; }
    public string Nome      { get; set; } = "";
    public string CpfCnpj  { get; set; } = "";
    public string Email     { get; set; } = "";
    public string Cidade    { get; set; } = "";
}

Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ClienteService.cs
public class ClienteService(IClienteRepository repo) : IClienteService
{
    public async Task<PagedResult<ClienteDto>> ListarAsync(
        string? q, int pagina, int tamanho, CancellationToken ct)
    {
        var (itens, total) = await repo.BuscarAsync(q, pagina, tamanho, ct);

        return new PagedResult<ClienteDto>(
            Itens:         itens.Select(c => new ClienteDto(c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade)).ToList(),
            TotalRegistros: total,
            TotalPaginas:   (int)Math.Ceiling((double)total / tamanho),
            PaginaAtual:    pagina,
            TamanhoPagina:  tamanho);
    }
}

O que é o Full-Text Index no SQL Server

O SQL Server mantém um catálogo de texto completo — uma estrutura de índice invertido separada dos índices B-Tree normais. Ele tokeniza o texto das colunas indexadas, aplica stopwords (palavras ignoradas: “de”, “do”, “a”…) e stemming (redutor de radicais) por idioma, e armazena os tokens com suas posições nos documentos.

A busca usa os predicados CONTAINS e FREETEXT:

  • CONTAINS(coluna, '"João Silva"') — busca literal ou com prefixo
  • CONTAINS(coluna, '"João*"') — prefixo (João, Joãozinho, etc.)
  • FREETEXT(coluna, 'João Silva') — linguagem natural, mais tolerante

Pré-requisito: habilitando o Full-Text Search no SQL Server

O Full-Text Search é um recurso opcional do SQL Server — ele precisa estar instalado na instância antes de qualquer CREATE FULLTEXT INDEX. Em instalações padrão do SQL Server Developer, Express com Advanced Services ou Enterprise ele já vem ativo; em instalações mínimas pode estar ausente.

Verifique se está ativo:

1
2
-- Retorna 1 se o recurso está instalado, 0 se não está
SELECT FULLTEXTSERVICEPROPERTY('IsFullTextInstalled') AS FTInstalled;

Se retornar 0, você precisa adicionar o feature via SQL Server Setup:

  1. Abra o SQL Server Installation Center (setup.exe da mídia de instalação)
  2. Escolha “Add features to an existing instance”
  3. Selecione “Full-Text and Semantic Extractions for Search”
  4. Conclua o assistente — não é necessário reiniciar a instância

Além disso, o serviço auxiliar SQL Full-text Filter Daemon Launcher (MSSQLFDLauncher) precisa estar em execução. Verifique no SQL Server Configuration Manager ou via PowerShell:

1
Get-Service -Name 'MSSQLFDLauncher*'

Referência completa: Introdução ao Full-Text Search — Microsoft Learn

Setup do Full-Text Index

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- 1. Criar o catálogo (uma vez por banco)
CREATE FULLTEXT CATALOG catalogo_clientes AS DEFAULT;

-- 2. Criar o índice FT na tabela Clientes
--    Requer que a tabela tenha uma coluna chave única (geralmente PK)
CREATE FULLTEXT INDEX ON Clientes
(
    Nome          LANGUAGE 'Brazilian',
    Email         LANGUAGE 'Brazilian',
    Cidade        LANGUAGE 'Brazilian'
)
KEY INDEX PK_Clientes
ON catalogo_clientes
WITH STOPLIST = SYSTEM;

-- 3. Verificar progresso da indexação (pode demorar na primeira vez)
SELECT name, is_enabled, crawl_status_description
FROM sys.fulltext_indexes fi
JOIN sys.tables t ON fi.object_id = t.object_id;

Repositório — SQL Server

 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
// ClienteRepositorioSqlServer.cs
public class ClienteRepositorioSqlServer(AppDbContext db) : IClienteRepository
{
    public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
        string? q, int pagina, int tamanho, CancellationToken ct)
    {
        // Sem filtro: paginação simples
        if (string.IsNullOrWhiteSpace(q))
            return await BuscarSemFiltroAsync(pagina, tamanho, ct);

        var termo = FormatarTermoSqlServer(q);
        var offset = (pagina - 1) * tamanho;

        // CONTAINS com prefixo — suporta busca parcial no início das palavras
        var sql = """
            SELECT Id, Nome, CpfCnpj, Email, Cidade
            FROM Clientes
            WHERE CONTAINS((Nome, Email, Cidade), {0})
            ORDER BY Nome
            OFFSET {1} ROWS FETCH NEXT {2} ROWS ONLY
            """;

        var countSql = """
            SELECT COUNT(*)
            FROM Clientes
            WHERE CONTAINS((Nome, Email, Cidade), {0})
            """;

        var itens = await db.Clientes
            .FromSqlRaw(sql, termo, offset, tamanho)
            .AsNoTracking()
            .ToListAsync(ct);

        var total = await db.Database
            .SqlQueryRaw<int>(countSql, termo)
            .FirstAsync(ct);

        return (itens, total);
    }

    private static string FormatarTermoSqlServer(string q)
    {
        // Divide em palavras e usa prefixo "*" para busca parcial:
        // "João Si" → '"João*" AND "Si*"'
        var palavras = q.Trim()
            .Split(' ', StringSplitOptions.RemoveEmptyEntries)
            .Select(p => $'"{EscaparSqlServer(p)}*"');

        return string.Join(" AND ", palavras);
    }

    private static string EscaparSqlServer(string palavra)
        // Previne injeção no predicado CONTAINS
        => palavra.Replace("\"", "\"\"").Replace("'", "''");

    private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
        int pagina, int tamanho, CancellationToken ct)
    {
        var offset = (pagina - 1) * tamanho;
        var itens = await db.Clientes
            .AsNoTracking()
            .OrderBy(c => c.Nome)
            .Skip(offset)
            .Take(tamanho)
            .ToListAsync(ct);

        var total = await db.Clientes.CountAsync(ct);
        return (itens, total);
    }
}

Exemplo de resultado

Para o termo “João Si”, o SQL Server executa internamente:

1
2
3
4
5
6
7
8
9
-- Equivalente ao que o FT engine executa:
SELECT Id, Nome, CpfCnpj, Email, Cidade
FROM Clientes
WHERE CONTAINS((Nome, Email, Cidade), '"João*" AND "Si*"')
ORDER BY Nome
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;

-- Retorna: João Silva, João Silveira, João Simões, João Simplício...
-- Usando o índice invertido — não faz full table scan

Ranking de relevância com SQL Server

Quando a ordem por relevância importa, use CONTAINSTABLE ou FREETEXTTABLE que retornam uma coluna RANK:

1
2
3
4
5
6
7
8
var rankSql = """
    SELECT c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade
    FROM Clientes c
    INNER JOIN CONTAINSTABLE(Clientes, (Nome, Email, Cidade), {0}) AS ft
        ON c.Id = ft.[KEY]
    ORDER BY ft.[RANK] DESC, c.Nome
    OFFSET {1} ROWS FETCH NEXT {2} ROWS ONLY
    """;

PostgreSQL: Full-Text Search com tsvector

Como funciona o FTS no PostgreSQL

O PostgreSQL tem suporte nativo a FTS sem extensões adicionais. A abordagem usa:

  • tsvector — representação normalizada do texto indexado (tokens + posições)
  • tsquery — expressão de busca compilada
  • to_tsvector(idioma, texto) — converte texto para tsvector
  • to_tsquery(idioma, query) — converte busca para tsquery
  • websearch_to_tsquery — mais tolerante, aceita entrada do usuário diretamente
  • Índice GIN — índice invertido eficiente para tsvector

Pré-requisito: habilitando FTS no PostgreSQL

O PostgreSQL inclui suporte nativo a Full-Text Search desde a versão 8.3 — não há instalação de componente adicional nem licença separada. O recurso está disponível em qualquer instalação padrão, inclusive RDS, Azure Database for PostgreSQL e Cloud SQL.

Verifique quais configurações de idioma estão disponíveis:

1
2
3
4
5
-- Lista os dicionários de busca textual instalados
SELECT cfgname, cfgparser::regproc
FROM pg_ts_config
ORDER BY cfgname;
-- Deve conter 'portuguese' para uso em português

Para texto em português com acentos, é recomendável habilitar a extensão unaccent, que normaliza caracteres acentuados durante a indexação (“João” e “Joao” passam a ser equivalentes):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Extensão incluída no PostgreSQL contrib — sem dependência externa
CREATE EXTENSION IF NOT EXISTS unaccent;

-- Variante do dicionário portuguese que ignora acentos:
CREATE TEXT SEARCH CONFIGURATION portuguese_unaccent (COPY = portuguese);
ALTER TEXT SEARCH CONFIGURATION portuguese_unaccent
  ALTER MAPPING FOR hword, hword_part, word
  WITH unaccent, portuguese_stem;

-- Use 'portuguese_unaccent' no lugar de 'portuguese' nas funções to_tsvector / to_tsquery

Referência completa: Full-Text Search — PostgreSQL Docs

Setup do índice FTS

 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
-- 1. Adicionar coluna tsvector para indexação (alternativa: calcular on-the-fly)
ALTER TABLE clientes ADD COLUMN busca_fts TSVECTOR;

-- 2. Popular a coluna com os textos concatenados (pesos diferentes por campo)
UPDATE clientes SET busca_fts =
    setweight(to_tsvector('portuguese', coalesce(nome, '')),   'A') ||
    setweight(to_tsvector('portuguese', coalesce(email, '')),  'B') ||
    setweight(to_tsvector('portuguese', coalesce(cidade, '')), 'C');

-- 3. Manter a coluna atualizada automaticamente via trigger
CREATE OR REPLACE FUNCTION atualizar_busca_fts()
RETURNS TRIGGER AS $$
BEGIN
    NEW.busca_fts :=
        setweight(to_tsvector('portuguese', coalesce(NEW.nome,   '')), 'A') ||
        setweight(to_tsvector('portuguese', coalesce(NEW.email,  '')), 'B') ||
        setweight(to_tsvector('portuguese', coalesce(NEW.cidade, '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tg_clientes_busca_fts
BEFORE INSERT OR UPDATE ON clientes
FOR EACH ROW EXECUTE FUNCTION atualizar_busca_fts();

-- 4. Criar índice GIN (otimizado para tsvector)
CREATE INDEX idx_clientes_busca_fts ON clientes USING GIN (busca_fts);

setweight e os pesos A, B, C, D: permitem dar prioridade a campos. Resultados onde o termo aparece no nome (A) recebem ranking mais alto do que onde aparece na cidade (C). O ranking é calculado pela função ts_rank.

Repositório — PostgreSQL

 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
// ClienteRepositorioPostgres.cs
public class ClienteRepositorioPostgres(AppDbContext db) : IClienteRepository
{
    public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
        string? q, int pagina, int tamanho, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(q))
            return await BuscarSemFiltroAsync(pagina, tamanho, ct);

        var offset = (pagina - 1) * tamanho;

        // websearch_to_tsquery: aceita entrada humana diretamente — mais seguro
        // Converte "João Si" em 'João:* & Si:*' internamente
        // A cláusula :* indica prefixo (busca parcial)
        var sql = """
            SELECT id, nome, cpf_cnpj, email, cidade
            FROM clientes
            WHERE busca_fts @@ websearch_to_tsquery('portuguese', {0})
            ORDER BY ts_rank(busca_fts, websearch_to_tsquery('portuguese', {0})) DESC,
                     nome
            LIMIT {1} OFFSET {2}
            """;

        var countSql = """
            SELECT COUNT(*)
            FROM clientes
            WHERE busca_fts @@ websearch_to_tsquery('portuguese', {0})
            """;

        var itens = await db.Clientes
            .FromSqlRaw(sql, q.Trim(), tamanho, offset)
            .AsNoTracking()
            .ToListAsync(ct);

        var total = await db.Database
            .SqlQueryRaw<int>(countSql, q.Trim())
            .FirstAsync(ct);

        return (itens, total);
    }

    private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
        int pagina, int tamanho, CancellationToken ct)
    {
        var offset = (pagina - 1) * tamanho;
        var itens = await db.Clientes
            .AsNoTracking()
            .OrderBy(c => c.Nome)
            .Skip(offset)
            .Take(tamanho)
            .ToListAsync(ct);

        var total = await db.Clientes.CountAsync(ct);
        return (itens, total);
    }
}

Busca com prefixo no PostgreSQL

websearch_to_tsquery não suporta prefixo nativamente. Para busca durante digitação onde cada palavra deve ser tratada como prefixo, use to_tsquery com :*:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private static string FormatarTermoPostgres(string q)
{
    // "João Si" → "'João':* & 'Si':*"
    var palavras = q.Trim()
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Select(p => $"'{EscaparPostgres(p)}':*");

    return string.Join(" & ", palavras);
}

private static string EscaparPostgres(string p)
    => p.Replace("'", "''").Replace("\\", "\\\\");
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Uso com to_tsquery para prefixo:
var sql = """
    SELECT id, nome, cpf_cnpj, email, cidade
    FROM clientes
    WHERE busca_fts @@ to_tsquery('portuguese', {0})
    ORDER BY ts_rank(busca_fts, to_tsquery('portuguese', {0})) DESC, nome
    LIMIT {1} OFFSET {2}
    """;

// Passa a query formatada:
var termo = FormatarTermoPostgres(q);

Alternativa: FTS via LINQ com EF.Functions (Npgsql)

O provider Npgsql para PostgreSQL oferece suporte nativo a Full-Text Search diretamente no LINQ, sem precisar de FromSqlRaw. Para queries mais simples — sem ranking customizado ou prefixo — essa abordagem é mais limpa e mantém a tipagem forte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Busca FTS via LINQ puro — sem SQL raw
// Requer: Npgsql.EntityFrameworkCore.PostgreSQL
var itens = await db.Clientes
    .Where(c => EF.Functions.ToTsVector("portuguese", c.Nome + " " + c.Email + " " + c.Cidade)
                    .Matches(EF.Functions.WebSearchToTsQuery("portuguese", q)))
    .OrderBy(c => c.Nome)
    .Skip(offset)
    .Take(tamanho)
    .AsNoTracking()
    .ToListAsync(ct);

⚠️ Atenção: A abordagem via LINQ com EF.Functions funciona bem para buscas simples, mas não suporta prefixo (:* / busca enquanto digita) e oferece menos controle sobre o ranking. Para busca reativa com autocomplete — como o cenário deste artigo — o FromSqlRaw com to_tsquery e :* continua sendo a melhor escolha. Para entender como o EF Core abstrai o acesso a dados e quando usar LINQ vs SQL raw, veja EF Core 8 com Fluent API: Mapeamento e Desacoplamento Total.


Oracle: Full-Text Search com Oracle Text (CTX)

Como funciona o Oracle Text

O Oracle usa o Oracle Text (também chamado de Oracle Context ou CTX) para Full-Text Search. Os índices são criados com CTXSYS.CONTEXT e as buscas usam o predicado CONTAINS(), que retorna um score de relevância:

  • CONTAINS(coluna, 'João') — busca simples
  • CONTAINS(coluna, 'João%') — prefixo (wildcard direito)
  • CONTAINS(coluna, 'João AND Silva') — operador AND
  • CONTAINS(coluna, 'FUZZY(Joao, 60, 6, N)') — busca fuzzy (tolera erros ortográficos)

Pré-requisito: habilitando o Oracle Text

O Oracle Text (anteriormente chamado de Oracle ConText Cartridge ou InterMedia Text) é incluído em todas as edições do Oracle Database — Standard Edition 2, Enterprise Edition e Oracle Database Free (XE). Ele precisa estar instalado no schema CTXSYS e o usuário da aplicação precisa ter os privilégios corretos.

Verifique se o componente está instalado:

1
2
3
4
5
-- Requer acesso a DBA_REGISTRY (DBA ou SELECT_CATALOG_ROLE)
SELECT comp_name, version, status
FROM   dba_registry
WHERE  comp_name = 'Oracle Text';
-- STATUS deve ser 'VALID'; se ausente, o componente não está instalado

Se não estiver instalado, execute como SYS AS SYSDBA:

1
2
3
4
5
6
7
8
9
-- Instala o Oracle Text no banco (executado uma única vez)
-- ctxsys = usuário dono do Oracle Text
-- SYSAUX  = tablespace padrão para o schema CTXSYS
-- TEMP    = tablespace temporária
-- NOLOCK  = não bloqueia a conta CTXSYS após a instalação
@?/ctx/admin/catctx.sql ctxsys SYSAUX TEMP NOLOCK

-- Em seguida, compile objetos inválidos (se necessário)
@?/rdbms/admin/utlrp.sql

Conceda os privilégios necessários ao usuário da aplicação:

1
2
3
4
5
6
-- Opção 1: role CTXAPP (cobre CREATE INDEX, CTX_DDL, CTX_QUERY)
GRANT CTXAPP TO meu_usuario;

-- Opção 2: privilégios granulares (produção com princípio de menor privilégio)
GRANT EXECUTE ON CTXSYS.CTX_DDL   TO meu_usuario;
GRANT EXECUTE ON CTXSYS.CTX_QUERY TO meu_usuario;

Verifique que o serviço de indexação Oracle Text está ativo:

1
2
-- O processo extproc / extjob é necessário para o sincronizador de índices
SELECT program, status FROM v$process WHERE program LIKE '%extproc%';

Referência completa: Oracle Text Application Developer’s Guide · Oracle Text Reference

Setup do índice CTX

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 1. Criar preferências de idioma (uma vez)
BEGIN
  CTX_DDL.CREATE_PREFERENCE('pref_clientes', 'BASIC_LEXER');
  CTX_DDL.SET_ATTRIBUTE('pref_clientes', 'BASE_LETTER', 'YES');  -- ignora acentos
  CTX_DDL.SET_ATTRIBUTE('pref_clientes', 'MIXED_CASE', 'NO');    -- case insensitive

  CTX_DDL.CREATE_PREFERENCE('wordlist_clientes', 'BASIC_WORDLIST');
  CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_INDEX', 'YES');  -- busca por prefixo
  CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_MIN_LENGTH', '2');
  CTX_DDL.SET_ATTRIBUTE('wordlist_clientes', 'PREFIX_MAX_LENGTH', '10');
END;
/

-- 2. Criar índice CONTEXT na coluna Nome
--    Para múltiplas colunas, crie uma coluna calculada ou use MULTI_COLUMN_DATASTORE
CREATE INDEX idx_clientes_fts ON clientes(nome)
INDEXTYPE IS CTXSYS.CONTEXT
PARAMETERS ('LEXER pref_clientes WORDLIST wordlist_clientes');

-- 3. Sincronizar o índice após inserções/updates (pode ser agendado)
EXEC CTX_DDL.SYNC_INDEX('idx_clientes_fts');

-- Para auto-sincronização (Oracle 12c+):
-- PARAMETERS ('... SYNC (ON COMMIT)')

Repositório — Oracle

 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
// ClienteRepositorioOracle.cs
public class ClienteRepositorioOracle(AppDbContext db) : IClienteRepository
{
    public async Task<(IReadOnlyList<Cliente> itens, int total)> BuscarAsync(
        string? q, int pagina, int tamanho, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(q))
            return await BuscarSemFiltroAsync(pagina, tamanho, ct);

        var termo = FormatarTermoOracle(q);
        var offset = (pagina - 1) * tamanho;

        // CONTAINS retorna score 0-100; > 0 significa que encontrou
        // SCORE(1) é o score da primeira chamada CONTAINS na query
        var sql = $"""
            SELECT Id, Nome, CpfCnpj, Email, Cidade
            FROM (
                SELECT c.Id, c.Nome, c.CpfCnpj, c.Email, c.Cidade,
                       SCORE(1) AS relevancia,
                       ROW_NUMBER() OVER (ORDER BY SCORE(1) DESC, c.Nome) AS rn
                FROM Clientes c
                WHERE CONTAINS(c.Nome, :termo, 1) > 0
            )
            WHERE rn > :offset AND rn <= :limite
            ORDER BY rn
            """;

        var countSql = """
            SELECT COUNT(*)
            FROM Clientes
            WHERE CONTAINS(Nome, :termo, 1) > 0
            """;

        var itens = await db.Clientes
            .FromSqlRaw(sql, new OracleParameter("termo", termo),
                             new OracleParameter("offset", offset),
                             new OracleParameter("limite", offset + tamanho))
            .AsNoTracking()
            .ToListAsync(ct);

        var total = await db.Database
            .SqlQueryRaw<int>(countSql, new OracleParameter("termo", termo))
            .FirstAsync(ct);

        return (itens, total);
    }

    private static string FormatarTermoOracle(string q)
    {
        // "João Si" → "João% AND Si%"
        // O % no Oracle Text é wildcard de prefixo (similar ao LIKE 'Si%')
        var palavras = q.Trim()
            .Split(' ', StringSplitOptions.RemoveEmptyEntries)
            .Select(p => $"{EscaparOracle(p)}%");

        return string.Join(" AND ", palavras);
    }

    private static string EscaparOracle(string p)
        => p.Replace("'", "''").Replace("%", "\\%").Replace("_", "\\_");

    private async Task<(IReadOnlyList<Cliente>, int)> BuscarSemFiltroAsync(
        int pagina, int tamanho, CancellationToken ct)
    {
        var offset = (pagina - 1) * tamanho;
        var itens = await db.Clientes
            .AsNoTracking()
            .OrderBy(c => c.Nome)
            .Skip(offset)
            .Take(tamanho)
            .ToListAsync(ct);

        var total = await db.Clientes.CountAsync(ct);
        return (itens, total);
    }
}

Registrando os repositórios por banco

No Program.cs, selecione o repositório conforme o banco configurado:

 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
// Program.cs
var banco = builder.Configuration["DatabaseProvider"]; // "sqlserver" | "postgres" | "oracle"

builder.Services.AddScoped<IClienteService, ClienteService>();

builder.Services.AddScoped<IClienteRepository>(sp =>
{
    var db = sp.GetRequiredService<AppDbContext>();
    return banco switch
    {
        "postgres" => new ClienteRepositorioPostgres(db),
        "oracle"   => new ClienteRepositorioOracle(db),
        _          => new ClienteRepositorioSqlServer(db)   // default: SQL Server
    };
});

// DbContext — seleciona o provider
builder.Services.AddDbContext<AppDbContext>(opt =>
{
    if (banco == "postgres")
        opt.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"));
    else if (banco == "oracle")
        opt.UseOracle(builder.Configuration.GetConnectionString("Oracle"));
    else
        opt.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"));
});

Comparativo entre os três bancos

CaracterísticaSQL ServerPostgreSQLOracle
MecanismoFull-Text Catalog + FT Indextsvector + GIN IndexOracle Text (CTX)
Predicado de buscaCONTAINS(), FREETEXT()@@ (operador tsvector/tsquery)CONTAINS()
Busca por prefixo"termo*"'termo':*termo%
Ranking nativoCONTAINSTABLE RANKts_rank()SCORE()
StemmingPor idioma (LANGUAGE)Por idioma (dictionary)Por lexer
AcentuaçãoDepende de collationunaccent extensionBASE_LETTER = YES
Update do índiceAutomático (background)Trigger na colunaManual/SYNC ou ON COMMIT
SetupCREATE FULLTEXT INDEXCREATE INDEX ... USING GINCTX_DDL.CREATE_PREFERENCE + CREATE INDEX INDEXTYPE CTXSYS.CONTEXT
Licença necessáriaIncluída no SQL ServerNativa, sem custo extraOracle Text (incluído)

Boas Práticas e Cuidados

1. Proteja contra injeção no predicado FTS

Os predicados CONTAINS e to_tsquery não são imunes a injeção — eles têm sua própria sintaxe que pode ser manipulada. Sempre:

  • Use parâmetros ({0}, :param) quando o provider suportar
  • Escape os caracteres especiais antes de interpoler strings
  • Limite o tamanho máximo do termo de busca
1
2
3
4
5
6
// Validação no controller — rejeita termos muito longos ou com caracteres de controle
if (q is { Length: > 100 })
    return BadRequest("Termo de busca muito longo.");

if (q?.Any(c => char.IsControl(c)) == true)
    return BadRequest("Termo de busca inválido.");

2. Latência do índice no Oracle

O Oracle Text não indexa registros novos automaticamente por padrão. Configure sincronização:

1
2
3
4
5
6
7
-- Opcional no CREATE INDEX: atualiza o índice a cada commit
CREATE INDEX idx_clientes_fts ON clientes(nome)
INDEXTYPE IS CTXSYS.CONTEXT
PARAMETERS ('... SYNC (ON COMMIT)');

-- Ou agende via DBMS_JOB / DBMS_SCHEDULER para sync periódico:
EXEC CTX_DDL.SYNC_INDEX('idx_clientes_fts', '2M');  -- 2 minutos de janela de memória

3. Stopwords e termos curtos

FTS ignora stopwords (“de”, “do”, “da”, “e”, “o”, “a”…). Uma busca por apenas uma stopword retorna zero resultados, o que pode confundir o usuário. Trate isso:

1
2
3
4
// No service: se o resultado for zero e o termo tiver apenas stopwords,
// retorne uma mensagem específica ou recaia para LIKE
if (total == 0 && TermoContemApenasStopwords(q))
    return await ListarSemFiltroAsync(pagina, tamanho, ct);

4. Cache de resultados frequentes

Buscas com termos populares (“José”, “Maria”) podem ser cacheadas no Redis com TTL curto:

1
2
3
4
5
6
7
var cacheKey = $"clientes:fts:{q}:p{pagina}:t{tamanho}";
var cached = await cache.GetAsync<PagedResult<ClienteDto>>(cacheKey);
if (cached is not null) return cached;

var resultado = await repo.BuscarAsync(q, pagina, tamanho, ct);
await cache.SetAsync(cacheKey, resultado, TimeSpan.FromSeconds(30));
return resultado;

5. Mínimo de caracteres — também na API

Mesmo com o filtro no Angular, a API deve validar:

1
2
if (q is { Length: > 0 and < 3 })
    return BadRequest("O termo de busca deve ter pelo menos 3 caracteres.");

Conclusão

O Full-Text Search não é uma funcionalidade de nicho — é o caminho certo para qualquer busca textual em tabelas com volume relevante. A diferença entre um LIKE '%termo%' e um CONTAINS() ou @@ pode ser a diferença entre uma query de 8 segundos e uma de 40 milissegundos.

O fluxo completo que vimos:

  1. Angular com debounceTime e distinctUntilChanged — evita flood de requisições
  2. Mínimo de 3 caracteres — tanto no frontend quanto na API
  3. ASP.NET Core com CancellationToken — cancela consultas se o cliente desconectar
  4. Full-Text Index no banco — índice invertido, não full scan
  5. Busca por prefixo ("termo*" / 'termo':* / termo%) — comportamento natural de “busca enquanto digita”
  6. Paginação — porque mesmo FTS com milhares de resultados precisa de paginação

O padrão se repete independentemente do banco: crie o índice invertido específico do banco, use o predicado correto e passe o termo formatado. O EF Core com FromSqlRaw é o ponto de integração — flexível o suficiente para usar o recurso nativo de cada banco sem abrir mão da tipagem forte do C#.


Leia Também


Referências

SQL Server

PostgreSQL

Oracle

EF Core e Angular