What this is

No new pgvector concepts in this lesson. This is the capstone: every idea from this entire course, wired into one small, realistically shaped web service. If you can read the code and understand why every piece is there, you've completed this course. If any piece feels unfamiliar, that's a sign to revisit the lesson it came from.

What it does

A FastAPI app, NotesRAGService, exposing three endpoints: POST /notes upserts one or more notes (insert-or-update, embedding and indexing them), GET /search?q=...&k=... runs hybrid search (vector and full-text, fused by rank), and POST /ask gives a full RAG answer, retrieving relevant notes and asking the model to answer using only that context. The lesson script exercises this end to end using FastAPI's TestClient, the same convention as every other lesson in this course, rather than requiring you to leave a server running and poke it from another terminal. A real deployment would instead run uvicorn lesson:app --reload.

Where each piece came from

app.state.pool = ConnectionPool(
conninfo=dsn_raw, min_size=2, max_size=10, configure=register_vector
)
app.state.pool.wait()

Lesson 18 (pooling) and Lesson 19 (registering vector on every pooled connection via configure).

CREATE TABLE notes (
external_id text PRIMARY KEY,
content text NOT NULL,
category text NOT NULL,
embedding vector(768),
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
)
CREATE INDEX ON notes USING hnsw (embedding vector_cosine_ops)

Lesson 7 (metadata column), Lesson 12 (HNSW), Lesson 16 (the generated tsvector column for full-text search).

@app.post("/notes")
def upsert_notes(notes: list[NoteIn]) -> dict:
vectors = app.state.embeddings_model.embed_documents([note.content for note in notes])
with app.state.pool.connection() as conn, conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO notes (external_id, content, category, embedding)
VALUES (%s, %s, %s, %s)
ON CONFLICT (external_id) DO UPDATE
SET content = EXCLUDED.content, category = EXCLUDED.category, embedding = EXCLUDED.embedding
""",
[(note.external_id, note.content, note.category, Vector(vector))
for note, vector in zip(notes, vectors)],
)
return {"upserted": len(notes)}

Lesson 15 (upsert) combined with Lesson 8's rule: any content change re-embeds in the same statement. FastAPI's request/response models (pydantic) and route decorators are how this all gets exposed over HTTP, the actual logic inside each route is entirely Lessons 1-27.

@app.get("/search", response_model=list[SearchResult])
def hybrid_search(q: str, k: int = 3) -> list[SearchResult]:
...
fused = reciprocal_rank_fusion(vector_ranking, text_ranking)
ranked = sorted(fused.items(), key=lambda item: item[1], reverse=True)[:k]
return [SearchResult(content=content_by_id[doc_id], score=score) for doc_id, score in ranked]

Lesson 16 (running both kinds of search) and Lesson 17 (fusing them by rank).

@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
retriever = app.state.vector_store.as_retriever(search_kwargs={"k": 2})
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| app.state.chat_model
| StrOutputParser()
)
return AskResponse(answer=chain.invoke(request.question))

Lesson 20 (PGVector, the LangChain-compatible store) and Lesson 21 (the full RAG chain built on top of it).

Running the script shows: notes ingested via POST /notes, a hybrid search via GET /search returning a fused ranking, and a RAG answer via POST /ask that correctly says it doesn't know about baking bread (only pizza dough is in the notes), the same honest-about-its-context behavior from Lesson 21, now reachable over HTTP.

Where to go from here

This capstone is deliberately still small enough to read start to finish in one sitting. A production version of this same service would add: a real client-facing authentication layer, a versioned migration path for the day the embedding model changes (Lesson 27), partitioning if one tenant's data ever dominates the table (Lessons 22-23), and dashboards built on pg_stat_statements (Lesson 26) to catch a slow query before a user does. Every piece those additions would build on, the vector column itself, the indexes, hybrid search, connection pooling, the LangChain integration, you've now built yourself, from scratch, in this course, on top of the exact InMemoryVectorStore limitation the LangChain course was upfront about from the start.

Checkpoint

  • A complete, pooled, hybrid-search-and-RAG web service, backed by Postgres, wired from every lesson in this course.
  • The same upsert-and-reembed lifecycle from Lesson 8, the same fused ranking from Lesson 17, the same honest RAG prompt from Lesson 21, now behind three ordinary HTTP endpoints.
  • Everything genuinely new here is shape (FastAPI routes, request/response models), not substance, the logic underneath is Lessons 1-27.

If this ran cleanly end to end, ingestion, hybrid search, and a context-honest RAG answer, all reachable over HTTP, you've completed the pgvector course. From here, LangChain Lessons 27-29 and this course's Lessons 20-21 are worth re-reading together, you now know both what InMemoryVectorStore was hiding, and what actually replaces it.