The problem
Proposals for a B2B services business are repetitive to write but only work when they're specific: the right past projects, the right description of the team, the client's own words. Copying from old proposals is slow, and generic AI drafts read like everyone else's. We built the assistant for a B2B services business; client details are withheld.
How it works
- Intake. A conversation and a short form capture what the prospect needs. Conversations, messages and form submissions are stored in MongoDB.
- Reference library. The business's reference material (company bio, past projects, earlier proposals) is split into chunks, embedded with OpenAI embeddings and stored in Pinecone, one namespace per reference set.
- Retrieval. For each request, a retriever returns the few most relevant chunks from the right namespace.
- Generation. A LangChain chain (prompt template, model, output parser) drafts the proposal or catalog in Markdown. The chain can run on an OpenAI model or a Google Gemini model.
- API. A Node.js and Express service exposes conversations, form submissions and generation to the front end.
The retrieval and generation step
A minimal version of the pattern in TypeScript with LangChain. Note the instruction to say so when none of the retrieved work is relevant:
import { Pinecone } from "@pinecone-database/pinecone";
import { PineconeStore } from "@langchain/pinecone";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
const index = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! })
.Index(process.env.PINECONE_INDEX!);
const store = new PineconeStore(new OpenAIEmbeddings(), {
pineconeIndex: index,
namespace: "past-projects",
});
const retriever = store.asRetriever({ k: 3 });
const prompt = PromptTemplate.fromTemplate(`You write proposals for {company}.
Cite experience only from the past work below. If none of it is relevant, say so.
Past work:
{context}
Client request:
{request}`);
export async function draftProposal(company: string, request: string) {
const docs = await retriever.invoke(request);
const context = docs.map((doc) => doc.pageContent).join("\n---\n");
return prompt
.pipe(new ChatOpenAI({ model: "gpt-4o-mini" }))
.pipe(new StringOutputParser())
.invoke({ company, context, request });
}What we learned
- Namespaces keep sources apart. One namespace per reference set means a bio never leaks into another set's project list, and a set can be rebuilt without touching the rest.
- A few strong examples beat many weak ones. We kept retrieval tight (two to three chunks). For a proposal, three relevant projects persuade more than ten loosely related ones, and less context means less to contradict.
- Tell the model when not to use the context. Retrieval always returns something, even when nothing fits. The prompt has to allow "none of this is relevant".
- Keep the model swappable. Building the chain on a common interface let the same flow run on either an OpenAI or a Gemini model, so switching provider is a configuration change.
What we'd do differently today
The first version was built in 2024 with the models of the time. Building it again, we would:
- Use current models, and route simple steps such as classification to a smaller one.
- Add metadata filters (industry, service, project size) so retrieval matches on more than wording.
- Re-rank retrieved chunks before generation.
- Show which past projects each paragraph drew on, so the sales team can check the claims.
- Build an evaluation set from proposals the sales team rated, and score every prompt or model change against it.
- Keep every API key in a secret manager or environment variables, with nothing in source code.