Version‑aware Retrieval‑Augmented Generation with Strapi and Revisionary
Learn how to create a RAG pipeline that queries Strapi content while honoring version history, using Revisionary to keep answers up‑to‑date and auditable.
By the Revisionary team · · 5 min read
Strapi can store your content, and Revisionary records every change. By exporting the latest version of each entry to a vector store, you can build a Retrieval‑Augmented Generation (RAG) system that always answers from the most recent approved content while keeping a full audit trail. The steps are straightforward:
- Install and configure Revisionary so every edit is versioned.
- Export the current version of the content you want to augment.
- Index those texts in a vector database, storing the associated Revisionary version ID.
- When a user asks a question, retrieve the most relevant vectors, fetch the exact version from Strapi, and feed the text to your LLM.
- If the content changes, re‑index only the affected entries; the version ID lets you trace which answer used which content.
Below is a complete, production‑ready guide.
1. Prerequisites
- A Strapi 5 project with the Revisionary plugin installed (downloaded as a zip and enabled in
config/plugins.ts). - Node 18+ and a PostgreSQL/MySQL/SQLite database (as required by Strapi).
- Access to a vector database (e.g., Pinecone, Weaviate, or an open‑source solution like Qdrant).
- An LLM endpoint that accepts a prompt and returns a completion (OpenAI, Anthropic, etc.).
- Basic knowledge of JavaScript/TypeScript and async/await.
2. Install Revisionary and Verify Versioning
# From your Strapi project root
npm install ./revisionary-plugin.zip # the zip you purchasedEnable the plugin in config/plugins.ts:
module.exports = {
// ...other plugins
revisionary: {
enabled: true,
// optional: configure retention, audit privacy, etc.
},
};Start Strapi and confirm the Version history panel appears when you edit a content type. See the guide [/docs/everyday-use/history] for details.
3. Choose the Content to Augment
Not every collection needs to be searchable. Typical candidates are:
- Blog posts
- Product documentation
- Knowledge‑base articles
- Legal or policy pages
Mark those content types as versioned in Revisionary settings (Revisionary → Settings → Content types). This ensures every edit creates a new version that can be referenced later.
4. Export the Latest Version of Each Entry
Revisionary stores each version in the revisionary_versions table. The latest version of a record can be fetched via the Content API with the populate=revision query parameter, or by using a custom endpoint.
Here is a small Node script that pulls the newest version of every entry in a given collection:
import fetch from 'node-fetch';
const STRAPI_URL = 'https://cms.example.com';
const API_TOKEN = 'YOUR_STRAPI_API_TOKEN';
const COLLECTION = 'articles'; // change to your content type
async function fetchLatestVersions() {
const res = await fetch(
`${STRAPI_URL}/api/${COLLECTION}?populate=revision&pagination[pageSize]=1000`,
{ headers: { Authorization: `Bearer ${API_TOKEN}` } }
);
const { data } = await res.json();
return data.map(entry => ({
id: entry.id,
versionId: entry.attributes.revision?.id, // Revisionary version ID
title: entry.attributes.title,
body: entry.attributes.body,
locale: entry.attributes.locale,
}));
}
fetchLatestVersions().then(console.log).catch(console.error);5. Index Content in a Vector Store
Each document you index should include the text, metadata, and the Revisionary versionId. This lets you retrieve the exact version later and store a provenance record.
Below is an example using Pinecone (the API is similar for other providers):
import { PineconeClient } from '@pinecone-database/pinecone';
import { embed } from './my-embedding-service.js'; // your own embedding function
const pinecone = new PineconeClient();
await pinecone.init({ apiKey: process.env.PINECONE_API_KEY, environment: 'us-west1-gcp' });
const index = pinecone.Index('strapi-rag');
async function indexEntries(entries) {
const vectors = [];
for (const entry of entries) {
const text = `${entry.title}\n\n${entry.body}`;
const embedding = await embed(text); // returns Float32Array
vectors.push({
id: `${entry.id}-${entry.versionId}`,
values: embedding,
metadata: {
strapiId: entry.id,
versionId: entry.versionId,
title: entry.title,
locale: entry.locale,
},
});
}
await index.upsert({ upsertRequest: { vectors } });
}
fetchLatestVersions().then(indexEntries).catch(console.error);The vector ID combines the Strapi entry ID and the Revisionary version ID (${id}-${versionId}) to guarantee uniqueness across versions.
6. Querying – From Question to Answer
A typical RAG request consists of three steps:
- Embed the user question.
- Retrieve the top‑k nearest vectors.
- Fetch the full versioned content from Strapi and build the prompt.
import { embed } from './my-embedding-service.js';
import fetch from 'node-fetch';
async function retrieveRelevantDocs(question, k = 5) {
const queryVector = await embed(question);
const queryRes = await index.query({
queryRequest: { vector: queryVector, topK: k, includeMetadata: true },
});
return queryRes.matches.map(match => match.metadata);
}
async function buildPrompt(question, docs) {
const context = docs
.map(d => `Title: ${d.title}\nContent: ${d.body || ''}`)
.join('\n\n---\n\n');
return `Answer the question using only the provided context.\n\nContext:\n${context}\n\nQuestion: ${question}\nAnswer:`;
}
async function answerQuestion(question) {
const docsMeta = await retrieveRelevantDocs(question);
// Fetch the full versioned bodies from Strapi (if not already stored)
const docs = await Promise.all(
docsMeta.map(async meta => {
const res = await fetch(
`${STRAPI_URL}/api/articles/${meta.strapiId}?populate=revision`,
{ headers: { Authorization: `Bearer ${API_TOKEN}` } }
);
const { data } = await res.json();
const version = data.attributes.revision?.id;
// Ensure we have the correct version; Revisionary guarantees the versionId matches
return {
title: data.attributes.title,
body: data.attributes.body,
versionId: version,
};
})
);
const prompt = await buildPrompt(question, docs);
// Send prompt to your LLM (example using OpenAI)
const llmRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_KEY}` },
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
});
const { choices } = await llmRes.json();
return choices[0].message.content;
}
answerQuestion('How do we handle user data deletion?').then(console.log);The prompt explicitly tells the model to rely only on the retrieved context, preventing hallucinations about outdated content.
7. Keeping the Index Up‑to‑Date
Whenever an editor publishes a new version, Revisionary emits a lifecycle hook (afterUpdate or afterPublish). Use a Strapi webhook to trigger re‑indexing of just the changed entry.
- Create a webhook in Strapi admin → Settings → Webhooks → New webhook.
- Set the URL to an internal endpoint (e.g.,
/api/rag/reindex). - Choose the event
Entry Publishedfor the versioned collection.
The webhook payload contains the entry ID and the new revision.id. Your endpoint can then:
- Pull the latest version (as in section 4).
- Upsert the new vector, overwriting the old ID (
${id}-${oldVersion}) or deleting it first.
// Express‑style handler for the webhook
app.post('/api/rag/reindex', async (req, res) => {
const { entry, model } = req.body; // Strapi sends these fields
const latest = await fetchLatestVersionsForId(entry.id);
await indexEntries([latest]); // re‑upsert single vector
res.sendStatus(200);
});Because the vector ID incorporates the version, the old vector remains searchable until you explicitly delete it. If you prefer a clean store, call index.delete with the old ID before upserting.
8. Auditing Answers – Provenance for Compliance
Regulators increasingly demand proof of where AI‑generated answers originated. With Revisionary you can store a provenance record alongside each LLM response:
async function answerWithProvenance(question) {
const answer = await answerQuestion(question);
const docsMeta = await retrieveRelevantDocs(question);
const provenance = docsMeta.map(m => ({
strapiId: m.strapiId,
versionId: m.versionId,
title: m.title,
}));
// Persist to your own audit table or to Revisionary's audit log via API
await fetch(`${STRAPI_URL}/api/audit-logs`, {
method: 'POST',
headers: { Authorization: `Bearer ${API_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ question, answer, provenance }),
});
return answer;
}The audit log can later be exported for legal review, satisfying the checklist in [/blog/ai-regulation-for-publishers-using-strapi-compliance-checklist].
9. Security and Access Control
- Authentication: Protect the RAG endpoints with the same JWT strategy you use for the Strapi API.
- Rate limiting: Apply the rate‑limit middleware described in [/blog/protect-strapi-nextjs-admin-api-from-brute-force] to avoid abuse of the embedding service.
- Data privacy: If you store personal data, enable the Privacy settings in Revisionary to anonymize IPs in the audit log [/docs/settings/privacy].
- Least‑privilege: Create a dedicated API token with only
readpermissions on the versioned collection andcreatepermission on the audit‑log collection.
10. Full Example Project Structure
my-rag-app/
├─ src/
│ ├─ index.js # entry point, starts Express server
│ ├─ rag/
│ │ ├─ fetch.js # functions from sections 4‑6
│ │ ├─ embed.js # wrapper around your embedding model
│ │ └─ llm.js # call to OpenAI/Anthropic
│ └─ webhook/
│ └─ reindex.js # webhook handler (section 7)
├─ .env # STRAPI_URL, API_TOKEN, PINECONE_KEY, OPENAI_KEY
└─ package.jsonDeploy the server alongside your Strapi instance (same VPC) to keep latency low and avoid exposing the vector store directly to the public internet.
11. Testing and Monitoring
- Unit tests: Mock the Strapi API and vector store to verify that the correct version ID is used.
- Integration test: Publish a new version of an article, trigger the webhook, then query the same question and assert that the answer reflects the updated content.
- Monitoring: Log the number of vectors upserted per day and the latency of the retrieval step. Alert if latency exceeds a threshold, as it may indicate a stale index.
12. When to Re‑index All Content
Full re‑indexing is rarely needed, but you may want to do it:
- After a major schema change (new fields that affect the context).
- When switching to a new embedding model.
- Periodically, to prune vectors belonging to deleted versions (use Revisionary’s Trash to identify them).
A simple script can iterate over all versioned entries and rebuild the index in batches.
async function fullReindex() {
const pageSize = 100;
let page = 1;
while (true) {
const res = await fetch(`${STRAPI_URL}/api/articles?pagination[page]=${page}&pagination[pageSize]=${pageSize}&populate=revision`, {
headers: { Authorization: `Bearer ${API_TOKEN}` },
});
const { data } = await res.json();
if (data.length === 0) break;
const entries = data.map(e => ({
id: e.id,
versionId: e.attributes.revision?.id,
title: e.attributes.title,
body: e.attributes.body,
locale: e.attributes.locale,
}));
await indexEntries(entries);
page++;
}
}Run this during a maintenance window; the vector store can handle bulk upserts.
Conclusion
By leveraging Revisionary’s versioning capabilities, you can build a RAG pipeline that:
- Always answers from the latest approved content.
- Provides a tamper‑evident audit trail linking each answer to a specific content version.
- Reacts automatically to content updates via Strapi webhooks, keeping the vector index fresh without full re‑indexing.
- Meets compliance requirements through built‑in provenance records and audit‑log privacy settings.
The result is a trustworthy AI‑augmented experience for both developers and content teams, combining the flexibility of a headless CMS with the power of modern LLMs.
The Revisionary team
Developers who build Revisionary, a version history plugin for Strapi 5. We write about Strapi, web development, AI and application security.
This article was written with the help of AI.