# RAG Custom with pgvector — Knowledge retrieval you control

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** rag, vector search, pgvector, embeddings, custom rag, retrieval, semantic search, knowledge base, RAG tool, delta ingestion, incremental ingestion, lookup key
> **Read by Claude in:** implement (when an agent needs to retrieve knowledge)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 (ai-pin 2026-09-08); `provado por templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs` **restrito à seção "Ingestão delta por lookup-key"** — é a única parte deste standard que o ai-kit compila. Todo o resto (pgvector, SQL, HNSW, embeddings, multi-tenant) é **verificação documental**; a versão e o status de `Microsoft.Extensions.DataIngestion` foram apurados no nuget.org em 2026-09-08. Last-verified: 2026-09-08.

---

## Por que RAG custom

Hosted RAG (uploading PDFs to a service) hands control of chunking, embedding, and storage to a third party. You cannot tune chunk size for your document type, cannot switch embedding models without re-indexing, and cannot add tenant isolation at the storage layer. Custom RAG gives you all three.

- **Chunking:** you choose the strategy per document type (catalog, contract, technical doc). See `data-vector-search-rag-chunking`.
- **Embedding:** provider-agnostic. The embedding model is chosen via the `embeddings` alias in the project's Model Registry (see `ai-agents-providers-model-registry`). The project wires an `IEmbeddingGenerator<string, Embedding<float>>` for that alias — switching from `text-embedding-3-small` to another model is a one-file config change.
- **Storage:** pgvector on Neon — a column you own, indexed by HNSW, with RLS for tenant isolation.

> **This is the default.** `Microsoft.Extensions.VectorData` (see `ai-agents-vector-data-extensions`) is Microsoft's official, provider-agnostic vector-store abstraction, but its Postgres connector is still a prerelease package as of last-verified date. Adopt VectorData only when genuine multi-backend portability is required — do not migrate this working implementation to a preview dependency by default.

---

## Arquitetura

```
[Document ingestion]
        │
        ▼
 EmbeddingService                  (generates float vectors via IEmbeddingGenerator)
        │
        ▼
  kb_embeddings table              (pgvector column, HNSW index, tenant_id partition)
        │
        ▼
  VectorSearchTool  ◄── MAF agent  (tool registered via AIFunctionFactory.Create)
        │
        ▼
  Top-k chunks → agent context     (agent decides when to call the tool)
```

Three pieces:

1. **EmbeddingService** — wraps `IEmbeddingGenerator<string, Embedding<float>>` from `Microsoft.Extensions.AI`. Called during indexing (batch) and at query time (single text).
2. **`kb_embeddings` table** — stores chunk text + `vector(1536)` + `tenant_id` + `metadata jsonb`. HNSW index with `vector_cosine_ops`.
3. **VectorSearchTool** — a typed MAF tool. A `[Description]`-decorated method the agent can call. Embeds the query, runs cosine-distance SQL, returns top-k chunks as concatenated text.

---

## Chunking por tipo de documento

Chunk strategy varies by document type:

| Document type | Recommended strategy | Chunk size |
|---------------|----------------------|------------|
| Product catalog | Fixed-size | 256–512 tokens |
| Legal contract | Sentence-aware | 256 tokens |
| Technical docs | Semantic (split on headers) | Variable |
| Q&A pairs | Keep whole pair | Whole Q+A |
| Code files | Function boundary | Whole function |

For implementation of each strategy, see `data-vector-search-rag-chunking`. Do not duplicate chunking code here — that standard is the single source of truth for chunk implementations and the `Chunk` metadata record.

---

## EmbeddingService

Reference template: `templates/code/dotnet/ai-agents/EmbeddingService.cs.template`

The service wraps `IEmbeddingGenerator<string, Embedding<float>>` (from `Microsoft.Extensions.AI`) and exposes two methods:

```csharp
// Single text → embedding vector (used at query time)
Task<ReadOnlyMemory<float>> EmbedAsync(string text, CancellationToken ct = default);

// Batch of texts → list of vectors (used during document indexing)
Task<IReadOnlyList<ReadOnlyMemory<float>>> EmbedBatchAsync(
    IReadOnlyList<string> texts, CancellationToken ct = default);
```

Both call `GenerateAsync(IEnumerable<string>, ...)` on the injected `IEmbeddingGenerator` (verified via context7 / Microsoft Learn). `GenerateAsync` returns `GeneratedEmbeddings<Embedding<float>>`; each element's `.Vector` property is a `ReadOnlyMemory<float>`.

**Wiring the `IEmbeddingGenerator`:** the project registers its chosen embedding provider in DI (e.g., via the OpenAI embedding client's `.AsIEmbeddingGenerator()` extension, or the equivalent for another provider). The `embeddings` alias in `model-registry.json` declares the model (`text-embedding-3-small`, dimensions 1536). The `EmbeddingService` is registered as a scoped or singleton service and injected wherever indexing or search happens.

```csharp
// Example DI registration (Program.cs, in the project — not in framework)
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(sp =>
{
    var cfg = sp.GetRequiredService<IConfiguration>();
    return new OpenAIClient(cfg["OpenAI:ApiKey"]!)
        .GetEmbeddingClient("text-embedding-3-small")
        .AsIEmbeddingGenerator();
});
builder.Services.AddScoped<EmbeddingService>();
```

---

## A tabela pgvector

Reference template: `templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template`

```sql
CREATE TABLE kb_embeddings (
    id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   uuid        NOT NULL,
    source      text        NOT NULL,   -- document identifier / URL
    chunk_index int         NOT NULL,   -- position within source document
    content     text        NOT NULL,   -- chunk text returned to the agent
    embedding   vector(1536) NOT NULL,  -- matches embedding model dimensions
    metadata    jsonb       NOT NULL DEFAULT '{}',
    created_at  timestamptz NOT NULL DEFAULT now()
);

-- HNSW index — cosine distance, production-grade recall
CREATE INDEX ix_kb_embeddings_hnsw
    ON kb_embeddings
    USING hnsw (embedding vector_cosine_ops);

-- Index for tenant filter (applied in every query)
CREATE INDEX ix_kb_embeddings_tenant
    ON kb_embeddings (tenant_id);
```

For pgvector extension setup (`CREATE EXTENSION IF NOT EXISTS vector;`, `UseVector()` on `NpgsqlDataSourceBuilder`, HNSW parameter tuning), see `infrastructure-neon-pgvector`.

> **Dimension match:** `vector(1536)` matches `text-embedding-3-small` (default 1536 dimensions). If the project switches models, re-create the column with the new dimension. Never mix models on a single column.

---

## VectorSearchTool (tool MAF)

Reference template: `templates/code/dotnet/ai-agents/VectorSearchTool.cs.template`

A sealed class with a `[Description]`-decorated method the agent calls when it needs context from the knowledge base.

```csharp
// Registration — in the agent setup code
var tool = sp.GetRequiredService<VectorSearchTool>();
var agent = _modelRegistry.GetChatClient("text-default")
    .AsAIAgent(
        instructions: systemPrompt,
        tools: [AIFunctionFactory.Create(tool.SearchAsync)]);
```

The tool:
1. Takes a natural-language `query` string (the only parameter the LLM sees).
2. Calls `EmbeddingService.EmbedAsync(query)` to get a query vector.
3. Runs a parameterized SQL `SELECT content FROM kb_embeddings WHERE tenant_id = @tenant ORDER BY embedding <=> @queryVec LIMIT 5`.
4. Concatenates chunk contents and returns them as a single string — the agent uses this as grounding context.

The `tenant_id` filter is **never a tool parameter** (that would allow the LLM to cross tenant boundaries). It is injected via a constructor dependency — e.g., a scoped `ITenantContext` or a `Guid currentTenantId` resolved from the authenticated request. See §Multi-tenant below.

Note on raw Npgsql vs EF Core: this template uses `NpgsqlDataSource` (raw SQL) for the cosine-distance query, since pgvector operators (`<=>`) are easier to express and verify in raw SQL. If the project already wires pgvector through EF Core (`UseVector()`, `HasMethod("hnsw")`), the equivalent `db.Database.SqlQuery<>` form per `infrastructure-neon-pgvector` is equally valid — choose whichever is already in use.

---

## Exemplo de uso

```csharp
// In an agent feature handler (the project's code)
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

public sealed class KnowledgeAgentHandler(
    ModelRegistry modelRegistry,
    VectorSearchTool searchTool,
    IPromptRepository prompts)
{
    public async Task<string> AnswerAsync(string question, CancellationToken ct = default)
    {
        var systemPrompt = await prompts.GetActivePromptAsync("knowledge-agent", ct);

        var agent = modelRegistry.GetChatClient("text-default")
            .AsAIAgent(
                instructions: systemPrompt,
                tools: [AIFunctionFactory.Create(searchTool.SearchAsync)]);

        var response = await agent.RunAsync(question, ct);
        return response.Text;
    }
}
```

The agent decides autonomously when to call `VectorSearchTool.SearchAsync`. It receives the top-k chunks as a string and incorporates them into its response. The handler code stays clean — no manual retrieval loop.

---

## Ingestão delta por lookup-key

**O defeito que esta seção existe para matar é o apaga-e-regrava.** `KbStore.IngestAsync` do GHLBrain faz `DELETE … WHERE location_id AND source` e re-insere/re-embeda **tudo** do `source` a cada ingestão, mudou ou não (`docs/specs/elevacao-9/brutos/scan-ghlbrain.md:412`): 288 fichas re-embedadas, uma chamada de embedding por item, a cada reindex em massa do catálogo — e, com id posicional, inserir um trecho no topo de um documento torna "novos" todos os de baixo.

> [!warning] Errata (2026-09-15, medida no código e no Neon de produção — Morph.GHLBrain#96)
> A versão anterior deste parágrafo afirmava também uma **janela** entre o apagar e o gravar em que a busca não acha nada. **No GHLBrain ela não existe:** `ConvVectorSql.ExecuteAsync` roda o `DELETE` e os `INSERT`s numa transação só, e o embedding acontece **antes** de a transação abrir — em READ COMMITTED o leitor vê o conteúdo antigo até o commit. O custo medido também é pequeno (324 trechos, ~US$ 0,0005 por re-embed completo, 1 trecho por `source`): lá, o ganho real do delta é **latência** (o reindex em massa) e **prevenção** (documento de até ~70 trechos). A janela é real em quem apaga fora da transação que grava — é o caso que a regra 4 abaixo cobre.

### O contrato — uma função pura, sem I/O

```csharp
// Nada aqui toca banco, vetor ou arquivo. Quem APLICA o plano é o projeto,
// que sabe qual é a sua transação — e é por isso que a idempotência da
// ingestão pode ser provada sem subir infraestrutura nenhuma.
public static DeltaPlan<T> Plan<T>(
    IEnumerable<T> incoming,
    Func<T, string> lookupKey,
    Func<T, string> contentHash,
    IReadOnlyDictionary<string, string> existingHashes);

public sealed record DeltaPlan<T>(
    IReadOnlyList<T> Added,        // chave não existia no acervo
    IReadOnlyList<T> Updated,      // chave existia com OUTRO conteúdo
    IReadOnlyList<string> Removed, // chaves no acervo que NÃO vieram na entrada — são chaves, não itens
    IReadOnlyList<T> Unchanged)    // chave existia com o MESMO conteúdo
{
    public bool IsNoOp => Added.Count == 0 && Updated.Count == 0 && Removed.Count == 0;
    public int WriteCount => Added.Count + Updated.Count + Removed.Count;
}
```

`IsNoOp` **é a idempotência expressa como predicado**: replanejar a mesma entrada sobre o acervo que ela produziu tem de dar `true`. É o teste que separa "delta" de "delete-and-reinsert com passos extras".

### As quatro regras que fazem o delta valer a pena

1. **`Unchanged` não é re-embedado.** É a economia inteira. Num republish típico a lista grande é `Unchanged` e as outras três são curtas — e é exatamente isso que a ingestão anterior escondia.
2. **`Removed` são as chaves presentes no acervo e ausentes do `incoming`.** Sem esse balde, conteúdo apagado na origem continua sendo devolvido pela busca.
3. **O id continua determinístico** — `DeterministicUuid.V5(namespace, "{loc}:{source}:{idx}")` já é a prática — para que `Updated` seja **upsert**, não delete+insert. Delete+insert quebra referências e troca o id de um chunk que só mudou de texto.
   **Ressalva medida (Morph.GHLBrain#96, ADR-2 da `delta-ingestion`):** quando o item é um *trecho* de documento, a posição como lookup-key derrota a regra 1 — inserir um trecho no topo desloca os índices e marca todo trecho abaixo como `Updated`, re-embedando tudo. Ali a chave certa é o **hash do conteúdo** (`lookupKey` e `contentHash` recebem o mesmo delegado): `Updated` fica vazio por construção, editar um trecho vira `Added` + `Removed`, e trechos idênticos são deduplicados antes do `Plan` para a chave duplicada nunca disparar. A chave posicional continua certa quando o item tem identidade própria (uma ficha por produto, um documento inteiro).
4. **Sem janela vazia: nunca apague antes de gravar fora da transação que grava.** Apagar e gravar em transações separadas (ou sem transação) é o que faz a busca devolver vazio durante a reingestão. Aplique `Added`/`Updated` primeiro e só então `Removed`, dentro da mesma transação — com a transação a janela já não existe, e a ordem mantém o código correto se um dia ela for partida.

### Chave duplicada no `incoming` é erro de entrada

Duas linhas com a mesma lookup-key tornam o plano **não determinístico** (qual das duas vence?). O planejador **falha**, dizendo a chave e **as duas posições** em que ela apareceu — porque "chave X duplicada" sem posição manda procurar num corpus inteiro. Silenciar isso produz um acervo diferente a cada execução da mesma entrada.

### A alternativa oficial, com o status honesto

`Microsoft.Extensions.DataIngestion` é o pipeline oficial (`IngestionDocument` → `IngestionChunker<T>` → `VectorStoreWriter<T>`), com `VectorStoreWriterOptions.IncrementalIngestion` e deduplicação por `DocumentId`. Status apurado em 2026-09-08: **preview** — a versão mais recente no nuget.org é `10.9.0-preview.1.26411.16`, e **não existe versão estável**.

**Ressalva medida, que muda a decisão:** o teste oficial da funcionalidade chama-se `IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChunks`. A semântica é *apagar todos os chunks pré-existentes daquele documento antes de regravar* — o que **não** é o mesmo que "não re-embedar o que não mudou".

| Objetivo | O que resolve |
|---|---|
| **Custo** (não pagar embedding do que não mudou) | O plano por hash acima |
| **Higiene** (não deixar chunk órfão de uma versão anterior do documento) | O `VectorStoreWriter` com `IncrementalIngestion` |

Os dois não competem; resolvem problemas diferentes. Adote o writer quando aceitar a dependência preview.

### Item aberto, medido em campo

**Threshold de similaridade e `k` por escopo ainda não têm regra.** Hoje o escopo institucional da base do GHLBrain roda **sem `LIMIT`** (`scan-ghlbrain.md:410`): a busca devolve tudo o que passa do threshold, e o custo do prompt varia com o tamanho da base. Definir `k` e o corte por escopo é trabalho pendente, não recomendação existente — registrado aqui para não ser reinventado como se fosse novo.

---

## Multi-tenant

Each tenant's knowledge is partitioned by `tenant_id`:

- Every row in `kb_embeddings` has a `tenant_id UUID NOT NULL`.
- Every query filters on `tenant_id = @tenant` before any cosine-distance ordering.
- `VectorSearchTool` resolves `tenant_id` from the authenticated request context (injected at construction time) — **never from a tool parameter the LLM supplies**.
- For database-level enforcement, enable Row-Level Security:

```sql
ALTER TABLE kb_embeddings ENABLE ROW LEVEL SECURITY;
-- See the project's RLS policy template (templates/code/sql/postgresql-rls-policy.sql)
-- for the policy pattern that binds app.current_tenant_id to the session.
```

> **Rule:** never query across tenants without the filter. One missing `WHERE tenant_id = @tenant` leaks every tenant's data to the requester.

---

## Anti-patterns

| Anti-pattern | Why it's wrong | Right way |
|--------------|----------------|-----------|
| Hosted RAG (upload PDFs to a service) | Loses control of chunking, embedding, and storage; vendor lock-in | Custom RAG: own all three layers |
| Embeddings column without an index | Full table scan — 100 ms becomes 30 s at 100k rows | HNSW index on the embedding column always |
| One fixed chunk size for all document types | Contracts need dense sentences; catalogs need short snippets; mixed chunks degrade recall | Choose strategy per document type (see `data-vector-search-rag-chunking`) |
| `tenant_id` as a tool parameter the LLM passes | LLM can be prompted to pass any GUID — cross-tenant data leak | Inject `tenant_id` from auth context at construction; never expose as a model-facing parameter |
| Embedding with model A, querying with model B | Vectors live in different spaces — cosine distance is meaningless | One embedding model per column; re-index when switching models |
| `ORDER BY similarity DESC` in SQL | `<=>` returns distance (0 = identical), not similarity | `ORDER BY embedding <=> @queryVec` (ascending distance = most similar first) |

---

## Checklist (verifiable by morph-eval)

- [ ] EmbeddingService (concrete class) registered in DI; it injects IEmbeddingGenerator<string, Embedding<float>> for the project's chosen embedding model.
- [ ] Embedding model declared in `model-registry.json` under the `embeddings` alias.
- [ ] `kb_embeddings` table has `embedding vector(1536)` and HNSW index with `vector_cosine_ops`.
- [ ] `tenant_id` column present and filtered in every query.
- [ ] `VectorSearchTool.SearchAsync` takes only `query` + `ct` — no tenant param visible to the LLM.
- [ ] `VectorSearchTool` registered on the agent via `AIFunctionFactory.Create(tool.SearchAsync)`.
- [ ] No `Microsoft.SemanticKernel` references in the project.
- [ ] Vector dimensions consistent between embedding model and `vector(N)` column definition.
- [ ] RLS enabled on `kb_embeddings` (or equivalent app-level guard documented in `decisions.md`).
- [ ] Ingestion follows §*Ingestão delta por lookup-key*: a pure `Plan(...)` produces `Added`/`Updated`/`Removed`/`Unchanged`, and `Unchanged` is never re-embedded.
- [ ] No `DELETE`-everything-then-reinsert: `Removed` is applied **after** `Added`/`Updated`, in the same transaction — there is no window where the search returns empty.
- [ ] `Updated` is an **upsert** on a deterministic id, never delete+insert.
- [ ] A duplicate lookup-key in the incoming set **fails** the ingestion, naming the key and both positions.
- [ ] Replanning the same input over the store it produced yields `IsNoOp == true` (idempotence, as a test).

---

## References

- `ai-agents-setup` — MAF packages, `AsAIAgent`, `AIFunctionFactory.Create`
- `ai-agents-providers-model-registry` — `embeddings` alias, `IEmbeddingGenerator` wiring
- `data-vector-search-rag-chunking` — chunk strategies per document type
- `infrastructure-neon-pgvector` — extension setup, `UseVector()`, HNSW parameter tuning
- `ai-agents-vector-data-extensions` — the official `Microsoft.Extensions.VectorData` abstraction, and when it's worth adopting instead of this custom implementation
- Reference implementation of the delta planner (compiles against the pin): `templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs` + `DeltaPlan.cs`
- `Microsoft.Extensions.DataIngestion` — https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dataingestion.vectorstorewriteroptions (preview; versão apurada 2026-09-08)

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/rag-custom-pgvector.md v2.1 (2026-09-15 — errata da janela no GHLBrain; ressalva da chave por hash)*
