How to Build a RAG Chatbot in ASP.NET Core 10 Using C# and Vector Database – Complete Guide (2026)
A support chatbot needs more than a capable language model. It needs the right policy document, the current refund rules, and a way to avoid answering from another customer's private material.
This guide builds a RAG chatbot in ASP.NET Core 10 using C#, OpenAI, PostgreSQL, and pgvector. You will ingest plain text, split it into overlapping chunks, generate embeddings, store vectors, retrieve tenant-authorized passages, and ask a model to answer with source labels.
How does a RAG chatbot work?
Retrieval-Augmented Generation first searches a knowledge collection, then supplies relevant passages to a language model with the user's question. Embeddings turn text into numeric vectors; pgvector stores and compares them inside PostgreSQL. The API selects authorized context before generation and returns an answer with references to the retrieved passages.
Scope: a complete, bounded backend example—not a production-ready SaaS product. It accepts plain-text JSON, uses tenant-wide read permissions, and answers one question at a time. It has no PDF parser, browser UI, conversation history, or document-level ACL system. All files below are needed in one project.
RAG architecture: ingestion and question answering
- Ingestion: an authorized writer submits text → chunking → OpenAI embeddings → transactional storage in PostgreSQL.
- Question: authenticate caller → embed question → filter by tenant and embedding model → rank by cosine distance → select bounded context.
- Generation: send instructions plus question and retrieved data to OpenAI → check response status and citation labels → return text and evidence metadata.
An embedding represents relationships in text; it is not an encrypted document or a permission boundary. Semantic search retrieves similar meaning rather than requiring exact keyword matches. Similarity is not proof that a passage answers a question, and RAG does not eliminate hallucinations.
A vector database stores vectors and supports similarity queries. Here, PostgreSQL remains the database and pgvector supplies its vector type and operators. We use direct HTTP calls to OpenAI and parameterized SQL through Npgsql so the entire flow is visible without an orchestration framework.
Prerequisites and NuGet packages
- .NET 10 SDK, plus a compatible C# editor. For Visual Studio, use Visual Studio 2026 version 18.0 or later with the web development workload.
- PostgreSQL 17 or 18 with a compatible pgvector installation. Installing the NuGet client does not install a PostgreSQL server extension.
- An OpenAI project API key with access to the embedding and generation models and sufficient API usage capacity.
- psql or another PostgreSQL administration client, PowerShell for the local secret commands, and Postman.
Npgsql is the PostgreSQL client. JwtBearer supports the non-Development authentication branch. ASP.NET Core's shared framework supplies controllers, HttpClient registration, configuration, and rate limiting. This example does not require the OpenAI SDK or a pgvector .NET type mapper: vectors are passed as parameters containing invariant-format text and explicitly cast by PostgreSQL.
Keep the resolved package versions in your project and review upgrades rather than mixing examples from unrelated package versions. The implementation uses the documented NpgsqlDataSource and asynchronous command APIs.
Step 1: Create the PostgreSQL database and vector schema
Install pgvector on the database host using its official instructions or enable it through your managed provider. Then connect as an administrator. Run this only for a new tutorial database:
The backslash command is a psql meta-command, not SQL. In pgAdmin, create the database and reconnect to it before running the schema script.
The final command prompts for the application's database password without embedding it in the SQL script. Run the schema as a migration/administration identity; the runtime login receives only the listed data privileges, not extension-installation or schema-ownership rights.
The vector column has 1,536 dimensions, matching the default output of text-embedding-3-small. The application records the embedding model and filters by it during search. Do not mix embedding spaces merely because their dimensions happen to match. Changing the model or dimension requires re-embedding and an intentional schema/version migration.
References: PostgreSQL CREATE EXTENSION, GRANT privileges, and the official pgvector project.
Step 2: Configure API keys and local development secrets
Run in the RagApi project directory, replacing the placeholders locally:
Loopback-only database example: SSL Mode=Disable is used solely for a PostgreSQL instance on your development machine's localhost interface. Do not use that connection string across a network. Deployed connections should use verified TLS, for example VerifyFull with the appropriate trusted certificate configuration, plus protected credentials.
User Secrets keeps values outside the project but does not encrypt them. Use a secret manager or protected deployment configuration in production. The environment-variable names are OpenAI__ApiKey and ConnectionStrings__RagDb. Never put the OpenAI key in frontend JavaScript, source control, screenshots, or document content.
Demo:Key authenticates only the local Development test identity; it is not your OpenAI key. Save it securely for your local Postman header. Do not publish User Secrets or run a public deployment in Development.
Create appsettings.json:
The generation model is configurable; the sample ID follows the current official OpenAI quickstart, not a claim that it is the cheapest choice. Check availability and pricing for your project. Auth placeholders are unused locally but must be replaced with a trusted identity provider and API audience before a non-Development deployment.
Step 3: Define request models and response contracts
Create Models.cs at the project root:
ApiController validation rejects empty text, oversized fields, and invalid JSON before service execution. A separate check rejects the empty document UUID. No request contains a tenant ID: access scope comes from authenticated server-side claims.
Step 4: Implement bounded, overlapping text chunking
Create Services/TextChunker.cs:
The baseline uses 800 Unicode scalar values per chunk with 100 overlapping values. It avoids splitting a surrogate pair, but can split words, sentences, or grapheme clusters. A chunk's UTF-8 size is at most about 3,200 bytes, keeping this bounded example below the documented embedding input ceiling; this is not a general token-counting algorithm.
For production ingestion, prefer document-aware boundaries and model-aware token counting. Evaluate chunk size, overlap, extraction quality, and retrieval behavior on your actual material. The sample accepts text only; PDF extraction, OCR, tables, and file-upload validation are separate responsibilities.
Step 5: Generate embeddings and model responses
Create Services/OpenAiGateway.cs:
The embeddings call requests float values and validates the dimension and finite values before storage. The Responses call supplies fixed higher-priority instructions and places the question and retrieved passages in a serialized data payload. It provides no tools and requests no side effects.
Raw HTTP responses do not have the SDK's convenience output_text accessor. The code collects output_text content from message items in the output array, and rejects incomplete responses. max_output_tokens bounds output generation, including reasoning where applicable; it is not a guarantee of 1,200 visible answer tokens.
store=false disables Responses API storage for this request; it does not by itself establish zero retention for all provider systems. Review your organization's data-processing and retention requirements before sending private documents to an external API.
Official references: OpenAI embeddings, developer quickstart, and Responses request parameters.
Step 6: Store vectors and search with pgvector
Create Services/VectorStore.cs:
All user-controlled values are parameters. The vector string uses invariant numeric formatting, then PostgreSQL casts the parameter to vector. Do not interpolate document text, tenant identifiers, or questions into SQL.
The <=> operator returns cosine distance: smaller values are nearer. The query applies both tenant and model filters and returns at most five chunks. This baseline uses exact nearest-neighbor search without an approximate vector index.
Ingestion computes embeddings before opening the database transaction. The transaction replaces only the selected tenant/document pair, so a failed write rolls back instead of leaving a partially replaced document. Reusing the same documentId replaces stored chunks, but still generates new embeddings and can incur API cost. Concurrent ingestion needs job coordination or an explicit idempotency/conflict policy before production.
Step 7: Orchestrate retrieval and source-labelled answers
Create Services/RagService.cs:
MaxDistance=0.6 is an illustrative starting point, not a validated relevance threshold or a confidence score. Tune it using labelled questions. The service abstains when retrieval finds no acceptable context, when the model abstains, or when output lacks valid source labels.
The label check only ensures references point at one of the supplied passages. It does not prove that a passage supports every statement. Sources contains the retrieved evidence passed to the model, not an independent claim-verification report. Evaluate citation fidelity and answer grounding separately.
Step 8: Add local test authentication
Create Security/DemoAuthHandler.cs:
The fixed-time comparison uses hashes of the demo keys. On success, the server assigns one known local user to demo-tenant with read and ingestion permissions. Neither a caller-supplied header naming a tenant nor a JSON property can change that assignment.
This is intentionally not a user-management system. The handler is registered only in Development. Outside Development, the next file configures JWT bearer authentication against the trusted provider; do not accept the demo header in production.
Step 9: Add authorized ingestion and question endpoints
Create Controllers/RagController.cs:
RagWrite is required to modify a tenant's knowledge collection, while RagRead is required to ask questions. In this baseline, authorized readers can access every ingested document in their tenant. For document-specific entitlements, extend SQL filtering before ranking or generation. Filtering evidence after it has already reached the model is too late.
Step 10: Register the complete pipeline in Program.cs
Authentication runs before authorization and tenant-keyed rate limiting. Outside Development, JWT validation checks the configured issuer's signing keys, audience, signature, and lifetime. Configure your identity provider to issue tenant_id and individual permission claims named exactly as the policies expect. A single scope string is not automatically equivalent to these permission claims.
The limits allow ten question requests and two ingestion requests per minute per tenant in one process. They are not distributed quotas or spending caps. An ingestion request can make multiple paid embedding calls. Add tenant usage accounting, concurrency limits, gateway protection, and provider budgets for a real deployment.
Errors return safe generic ProblemDetails with a trace identifier. The sample logs the exception type, not raw provider error bodies or document text. More detailed observability needs controlled access and deliberate redaction. See Microsoft's JWT bearer guidance and rate-limiting documentation.
Step 11: Build and test using Postman
The Development launch binds to localhost and selects the demo authentication scheme. Keep the local certificate trusted and verification enabled. Do not share actual credentials in the examples below.
Ingest one policy document
Expect HTTP 200 with the document ID and chunk count. This short sample should form one chunk. Larger documents create more chunks and embedding calls. The returned response is an expected contract, not a captured run.
Ask a question grounded in the policy
Expect HTTP 200 with an answer and evidence when retrieval passes the configured threshold and generation returns a valid answer. Wording, vector distances, labels, and chunk IDs can vary. If retrieval or generation fails the checks, the API instead returns:
That is a normal application response, distinct from a provider or database outage. The question endpoint remains stateless: it does not remember previous messages or resolve follow-up pronouns using chat history.
Verify the database and negative cases
- Omit X-Demo-Key locally: expect 401 without an embedding request.
- Send empty text, an empty document ID, or oversized input: expect 400; very large bodies can be rejected with 413 before binding.
- Ask an unrelated question: verify abstention behavior and inspect retrieval; do not assume one distance threshold solves every topic.
- Exceed the local request allowance: expect 429 from the application limiter.
- Outside Development, test a valid read-only token against ingestion: expect 403.
- With two independently issued tenant identities, ingest a distinctive document in tenant A and ask from B. Verify A's chunks never appear in B's retrieved sources.
Execution disclosure: the code and request patterns were checked against current official documentation on November 3, 2026. This authoring environment has no .NET SDK or PostgreSQL instance, and no authenticated OpenAI request was executed. The project has not been compiled or end-to-end tested here. Run the build, database, authorization, and grounding tests in your environment before deployment.
Prompt injection and production security
Retrieved text is untrusted even when it comes from your own database. A poisoned document might ask the model to ignore instructions, reveal hidden prompts, or invent a citation. JSON serialization and fixed instructions reduce ambiguity but do not guarantee safety.
- Restrict ingestion to trusted identities and apply document provenance, moderation/review, and deletion policies.
- Enforce authorization in the retrieval query. Never rely on the model to decide which tenant's information it may reveal.
- Keep credentials and confidential operational instructions out of prompts. Do not add tools that can execute arbitrary model-generated SQL or commands.
- Encode model text in the UI; do not render it as trusted HTML. Review any generated links rather than automatically opening them.
- Test malicious documents and user questions, refusal behavior, source-label integrity, and data leakage.
Consider PostgreSQL row-level security as an additional database boundary. The shared role used here can access all rows permitted by its table grants; tenant isolation is implemented in application queries, not RLS. A production RLS design must set tenant context safely on each transaction and account for pooling and owner/bypass behavior. See PostgreSQL row security and OpenAI's untrusted-input safety guidance.
Performance, retrieval quality, and indexing
Start with a small exact-search corpus and measure retrieval and answer quality before adding approximate indexes. The tenant/model B-tree index helps select rows, but it is not a vector nearest-neighbor index.
Once workload measurements justify it, an administrator can evaluate a cosine HNSW index:
Approximate search trades retrieval recall for efficiency. Filtering can reduce the returned candidate count; evaluate pgvector's version-specific iterative-scan and tuning options. Do not promise a speedup or perfect recall without workload measurements.
For larger ingestion, move work to a durable queue, track document versions/content hashes, batch embeddings where suitable, and implement bounded retries with backoff. The sample deliberately avoids blind automatic retries, which can repeat paid work. Add deletion and re-indexing workflows, backup/restore procedures, and tenant-aware cache keys before scaling.
Troubleshooting
- Extension vector is unavailable: install pgvector on the PostgreSQL host or select a provider that supports it; CREATE EXTENSION cannot download server binaries.
- Dimension mismatch: verify 1,536 values and the pinned embedding model. Re-embed rather than truncating existing vectors to force compatibility.
- 401 or 403: check the active environment, chosen authentication scheme, issuer/audience, tenant_id, and permission claims. Do not disable validation to bypass the problem.
- 503: inspect protected telemetry for provider access, usage limits, network timeout, database availability, or schema permissions. The API intentionally does not return raw provider details.
- Unexpected insufficient_context: inspect tenant/model filters, retrieved distances, chunk content, output status, and citations. A 1,200-token cap may be too small for a selected reasoning model; tune model and budget together.
- Empty or irrelevant retrieval: check ingestion success, embedding-space consistency, chunk boundaries, and the calibrated threshold.
- Duplicate ingestion costs: reusing a document ID avoids duplicated stored versions, but does not skip embedding calls in this implementation.
For application logging background, see our C# logging guide. It is optional context: this project uses ILogger and does not require log4net.
Frequently asked questions
Is pgvector a separate database server?
No. It is an extension that adds vector storage and similarity-search capabilities to PostgreSQL. You still manage PostgreSQL connections, permissions, backups, and operations.
Does RAG train the model on my documents?
This implementation sends selected document passages as request context. It does not run a fine-tuning job or update model weights. Sending data still requires a review of provider data-handling terms.
Why not send every document with every question?
Retrieval limits the context passed to the model and makes source attribution easier to inspect. It also introduces retrieval failure modes, so evaluate whether the correct passages actually reach generation.
Can I change the embedding model?
Yes, as a migration: update dimensions where necessary, version the corpus, re-embed documents, and use the same model for new questions. Equal-length vectors from different models are not automatically comparable.
Is the answer guaranteed to be correct when it cites a source?
No. A valid source label can still accompany an unsupported claim. The code checks label membership; professional deployments need citation-fidelity evaluation and domain-appropriate review.
How is this different from an MCP server?
RAG retrieves knowledge to inform an answer. MCP exposes capabilities to compatible clients. They can be combined, but neither replaces the other; our C# MCP server tutorial explains that separate integration layer.
Conclusion
The useful foundation is a verifiable pipeline: ingest bounded text, embed it consistently, store it with access metadata, retrieve only authorized chunks, and inspect the evidence behind an answer. This example makes each step explicit while keeping production limitations visible.
Before adding a chat UI, run tenant-isolation and grounding tests, calibrate retrieval on real questions, and replace synchronous ingestion and local-only controls with the operational design your application requires.
Comments
Post a Comment