A retrieval-augmented generation API: a FastAPI service that splits a Markdown knowledge base into sections, embeds them with the OpenAI embeddings API, ranks them in memory by dot-product similarity, answers questions with gpt-4o-mini grounded on the three best-matching chunks, and stores each conversation in PostgreSQL.
client
| POST /ask {question, session_id?, history?}
v
app/main.py (FastAPI: GET /health, POST /ask, GET /conversations/{session_id})
|
|-- startup (lifespan): data/knowledge_base.txt
| -> app/knowledge.py chunk_by_sections() (split on "# " / "## " headings)
| -> app/embedding.py OpenAI text-embedding-3-small, one call per chunk
| -> data/chunk_embeddings.json (cache, reused while the file's mtime is
| unchanged, git-ignored)
|
|-- app/services/rag_service.py embed the question, dot product against every cached
| chunk, keep the top 3 as context + "sources"
|-- app/llm.py OpenAI chat completion (gpt-4o-mini):
| system prompt + context + prior turns + question
|-- app/services/database_service.py SQLAlchemy engine from DATABASE_URL,
| reads/writes conversations and messages
v
PostgreSQL (tables: conversations, messages; schema in alembic/versions/001_*.py)
Components, by file:
app/main.py: builds the FastAPI app and, at startup (FastAPI lifespan), loads and embeds the knowledge base.POST /asktakes aquestion, an optionalsession_id(a UUID4 is generated if absent) and an optionalhistorylist. Ifhistoryis absent or empty, prior messages for that session are loaded from the database. After the LLM answers, the user message and the assistant message are saved.GET /conversations/{session_id}returns the stored messages.GET /healthreturns{"status": "ok"}.app/knowledge.py:load_system_promptreadsprompts/system_prompt.txt;chunk_by_sectionsstarts a new chunk at every line beginning with#or##(###headings stay inside their parent chunk). The sample knowledge base yields 14 chunks.app/embedding.py: readsdata/knowledge_base.txt, callstext-embedding-3-small(the OpenAI client is created on first use), computes the dot product in plain Python, and caches chunk embeddings indata/chunk_embeddings.json. The cache is reused only if the knowledge file's modification time is unchanged; a change to the chunking function alone does not invalidate it.app/services/rag_service.py: retrieval. Returns the concatenated top-3 chunks as context and asourceslist with each chunk's score and its first 200 characters.app/llm.py: sends two system messages (the prompt file, thenContexto:\n<chunks>), then the history turns, then the question, togpt-4o-mini.app/schemas.py: the Pydantic request models (QuestionRequest,Message).app/models.py: the SQLAlchemy ORM models (Conversation,MessageDB).app/services/database_service.py:DatabaseService, a module-level singleton created at import (the engine only connects on first use). It opens a session per operation and has two methods,save_message_to_conversationandget_conversation_history.alembic/,alembic.ini: one migration (001) creatingconversationsandmessages.alembic/env.pyusesDATABASE_URLwhen it is set (importingapploads.env) and falls back to the placeholder inalembic.iniotherwise.tests/: chunking, ranking, cache behaviour and the three endpoints, with the OpenAI calls and the database replaced by fakes..github/workflows/tests.ymlrunsruffandpyteston every push.prompts/system_prompt.txt: the assistant persona (a support agent for a fictional company "Acme"), in Brazilian Portuguese, instructing the model to answer only from the supplied context.data/knowledge_base.txt: the sample knowledge base (plans, billing, support policies of "Acme"), in Portuguese, about 300 lines.
Retrieval happens in process, against the list of chunk embeddings held in memory since startup. PostgreSQL stores chat history only; there is no vector extension and no vector index.
The example below is illustrative, not captured from a live run (without a valid OpenAI key the app cannot finish starting: the embeddings are generated at startup when no cache exists). The request and response fields match the code; the answer text and scores are placeholders. The previews are the first characters of real chunks of data/knowledge_base.txt.
curl -s -X POST http://127.0.0.1:8000/ask \
-H "Content-Type: application/json" \
-d '{"question": "Quantos usuários o Plano Basic permite?"}'{
"answer": "O Plano Basic permite até 5 usuários. ...",
"sources": [
{"score": 0.61, "text_preview": "## Usuários e limites\n\n### Limites por plano\n- Basic: até 5 usuários\n- Pro: até ..."},
{"score": 0.55, "text_preview": "## Planos disponíveis\n\n### Plano Basic\nO Plano Basic é indicado para profissiona..."},
{"score": 0.47, "text_preview": "## Comparação rápida entre planos\n\nResumo:\n- Basic: solução inicial para uso sim..."}
],
"session_id": "8f3c2c0e-1b7e-4b2e-9a4e-2f1c3d4e5f60"
}Sending the returned session_id in the next request continues the same conversation. GET /conversations/<session_id> returns {"session_id": "...", "messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}.
-
Python 3.9 or newer. No version is declared in the repository; 3.9 is the floor implied by the built-in generic type hints in the code (such as
list[float]). Create a virtual environment and install the dependencies; versions are not pinned inrequirements.txt.python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # or requirements-dev.txt to also get pytest and ruff
-
PostgreSQL. The migration uses only standard column types and no extensions. Create the database:
createdb rag_chatbot
-
Copy
.env.exampleto.envand fill in both variables.OPENAI_API_KEYis read by theopenaiclient.DATABASE_URLis a SQLAlchemy URL such aspostgresql://USER:PASSWORD@localhost:5432/rag_chatbot. IfDATABASE_URLis missing,database_service.pyfalls back topostgresql://user:password@localhost/rag_chatbot. -
Create the tables. Alembic reads
DATABASE_URLfrom.env:alembic upgrade head
-
Start the API. The first start makes one embeddings call per section of the knowledge base and writes
data/chunk_embeddings.json; later starts reuse it untildata/knowledge_base.txtchanges.uvicorn app.main:app --reload
./run.shdoes the same after activating.venv. Interactive docs are served athttp://127.0.0.1:8000/docs.
- Tests against a real database or the real OpenAI API: the test suite replaces both with fakes.
- Authentication, rate limiting or CORS configuration on the endpoints.
- Deployment: no Dockerfile, container configuration or CI.
- Evaluation of answer quality or retrieval quality.
- A vector store: similarity is computed in memory over a JSON file, which is adequate for the ~300-line sample and not designed for larger corpora.
- Error handling: there is no
exceptblock inapp/. If PostgreSQL or the OpenAI API is unreachable,POST /askandGET /conversations/{session_id}return a 500. On/ask, the database write happens after the LLM call, so a database failure loses an answer that was already generated. - A limit on conversation length: the full stored history of a session is replayed into every prompt.
- Access control on conversations: anyone who knows a
session_idcan read and extend that conversation. - Configuration of model names or the number of retrieved chunks; both are hard-coded.
Lucca Matuck, PhD in Engineering Physics. github.com/matuck40