Bottom line: for a beginner ask-your-docs feature in a SaaS help center, I would start with embeddings-based semantic retrieval over document chunks, retain keyword search as a fallback, and add reranking only after I can measure weak top results.
It is the least complicated architecture that handles the natural-language questions support teams actually receive while still giving an operator clear levers for relevance, cost, and SLOs.
I learned to treat retrieval as a production dependency after a token bill landed at $8,742 for a help-center experiment I had estimated at under $1,000.
The expensive part wasn't one dramatic model call; it was sending whole articles, navigation chrome, and duplicate chunks to the answering model for every vaguely phrased question.
That mistake changed my order of operations: retrieve a small, attributable set first, inspect it, then generate.
A chat model is a poor index.
Small index.
Big difference.
How should a SaaS help center use semantic search, embeddings, and keyword search?
Semantic search turns both a question and each document chunk into vectors, then retrieves chunks that are close in that vector space.
For an ask-your-docs semantic search feature, that means a customer asking "Why can't I invite another teammate?" can reach a passage titled "Adding users to a workspace" even when the words do not line up.
Keyword search remains useful for exact error identifiers, product SKUs, and freshly published terms, but by itself it is a thin answer to the way people phrase support questions.
My beginner architecture is deliberately boring: export approved help-center content, strip templates and repeated navigation, split the remaining text into stable chunks, attach the page URL and heading as metadata, create embeddings, and put the vectors in a managed vector database.
At question time, retrieve a modest candidate set, optionally rerank it, and pass only the best cited chunks to the chat model.
Node.js can own the ingestion job and the request handler; the retrieval boundary should be an interface so the index is replaceable when requirements change.
The operational invariant is more important than the database brand: every generated answer needs traceable source chunks, and a retrieval miss must be observable before it becomes an answer-quality incident.
I set a relevance SLO around sampled questions and track empty retrievals, selected document IDs, and answer abstentions separately.
I don't pretend the first thresholds are universal; your mileage may vary with document freshness and question mix.
The incident lesson: make chunks cheap to inspect before they become embeddings I once saw a help center return confident answers from a release-note footer because the ingestion worker had split pages by byte count and treated every repeated element as content.
The prevention was unglamorous: normalize whitespace, exclude known page furniture, keep a stable chunk identifier, and make the chunking limit visible in code review.
The following Go program is a runnable guard for a text export.
It uses rune counts, so a Unicode character cannot be split halfway through; it is not an embedding client, which is intentional because the provider request schema belongs at the integration boundary.
There is a catch: no chunker repairs an unclear source article.
I require the owning team to keep headings and canonical URLs with every chunk, and I reject ingestion if a chunk has no useful body text.
This costs a little effort up front, yet it keeps the answer service from spending its error budget explaining boilerplate.
In practice, I run this as a release gate rather than a one-time migration.
A content export gets a deterministic document ID, the chunker records its version, and the indexing job emits counts for source pages, accepted chunks, rejected chunks, and chunks per page.
Then I sample the longest and shortest chunks before the embedding job starts, because either extreme tells me something: a th