I recently read Sriram Rokkam’s excellent book, ‘Agentic Hybrid RAG on SAP BTP: A Hands-On Guide with LangGraph, HANA Cloud, and Google Vertex AI’, and wanted to see if I could build something similar with Claude — a Python orchestration service, a CAP service exposing OData V4, and a HANA Cloud retrieval backend.
The architecture
User query
|
v
Python orchestrator (Flask, Cloud Foundry)
1. distills the question into search keywords
2. calls the CAP service’s search action
3. sends retrieved documents + question to an LLM
v
CAP service (Node.js, OData V4, Cloud Foundry)
exposes a Documents entity and a search action
v
HANA Cloud (full-text/fuzzy search)
Step 1: scaffolding
Two folders: cap-service/ (standard CAP shape — package.json, db/schema.cds, srv/.cds + srv/.js) and orchestrator/ (plain Flask app). Both deploy as native Cloud Foundry apps via manifest.yml and cf push — no Docker, no Kubernetes involved.
Step 2: the HANA quota wall
First attempt was to provision a fresh HANA Cloud instance:
cf create-service hana-cloud hana-free my-hana-instance -c ‘{“data”:{“edition”:”cloud”,”memory”:16,”systempassword”:”…”}}’
Failed:
Job (…) failed: provision could not be completed: Service broker error: Service broker
hana-cloud failed with: You may only create one SAP HANA Cloud Free Tier instance
The quota turned out to apply to the underlying HANA Cloud database, not to HDI containers — a single free-tier database can host any number of independent HDI containers (schemas). So instead of fighting the quota, created a dedicated container of its own on the org’s existing database:
cf create-service hana hdi-shared btp-agent-poc-db
and pointed the CAP service’s manifest at it:
services:
– btp-agent-poc-db
Confirmed this is genuinely independent — its own HDI container, its own schema, no dependency on any other project — while still not requiring a second HANA database.
The container creation itself failed on the first attempt too:
status: create failed
message: Failed to open connection to the database, because of: … HANA Database
instance is stopped
Same free-tier auto-stop-on-inactivity behavior as before. Resumed it and retried:
cf update-service HANA_TRAINING_FREE -c ‘{“data”: {“serviceStopped”: false}}’
cf create-service hana hdi-shared btp-agent-poc-db
Second attempt succeeded. Then hit one more snag deploying the schema to it: cf push doesn’t automatically unbind a service that’s been removed from the manifest — the app ended up bound to both the old and new HDI containers at once, and cds deploy refused to pick one:
Error: More than one HDI service found, but no service is defined as the deployment
target via the environment variable “TARGET_CONTAINER”
Fixed with an explicit unbind of the old container before redeploying:
cf unbind-service btp-agent-retrieval-srv <old-container-name>
After that, the schema deployed cleanly to the new, fully independent container, and the whole pipeline re-tested working end to end against it — same correct, grounded answers as before.
Step 3: the schema-deploy gap
cf push deploys the running Node.js service. It doesn’t deploy the database schema — that’s a separate step, cds deploy –to hana, easy to miss the first time. First test hit:
invalid table name: Could not find table/view RETRIEVALSERVICE_DOCUMENTS in schema…
Tried running the deploy from a laptop:
cds bind -2 btp-agent-poc-db
cds deploy –to hana
Failed differently:
Could not connect to any host: [ …hanacloud.ondemand.com:443 – Client network socket
disconnected before secure TLS connection was established ]
The HANA instance only accepts connections from Cloud Foundry IPs in-region. A laptop was never going to get through — and this is a separate restriction from general cf CLI access, which talks to the Cloud Foundry API, a different and publicly reachable host. cf push working doesn’t mean cds deploy from the same machine will.
Fix: run the schema deploy from inside Cloud Foundry, temporarily changing the start command:
command: npx cds deploy –to hana && npx cds-serve
Push once with that command, confirm the schema deployed (endpoint returns 200 instead of the table error), then revert to plain npx cds-serve so it doesn’t redeploy on every restart. This needed @SAP/cds-dk moved into package.json’s dependencies rather than devDependencies — Cloud Foundry’s buildpack sets NPM_CONFIG_PRODUCTION=true, which skips dev dependencies.
Step 4: the actual AI part
Everything so far was plumbing. Added the real thing to the orchestrator:
message = claude.messages.create(
model=”claude-opus-5″,
max_tokens=1024,
system=”Answer the user’s question using only the documents provided…”,
messages=[{“role”: “user”, “content”: f”Documents:n{documents}nnQuestion: {query}”}],
)
Deployed, tested against an empty document table first — correctly said “no documents found” instead of inventing something. Good sign.
Step 5: a secrets lesson
While setting the API key on the deployed app:
cf set-env btp-agent-orchestrator ANTHROPIC_API_KEY sk-ant-…
cf restage btp-agent-orchestrator
An unrelated later cf push printed its manifest diff to the terminal — and because the key wasn’t declared in the committed manifest.yml, having only been set via cf set-env, the diff displayed the full key value as a line being removed:
– ANTHROPIC_API_KEY: sk-ant-api03-…
Worth knowing: a secret set only via cf set-env, absent from the manifest, can get printed in full the next time a cf push touches that app — even though it was never written to a file. (The push hadn’t actually removed the variable despite the diff showing it as removed — not something to rely on either way.)
Step 6: real search, not a placeholder
First search implementation was a plain LIKE:
return await SELECT.from(Documents).where({ content: { like: `%${query}%` } });
Needs an exact substring match — a natural question like “What are the three layers of the architecture?” basically never appears verbatim in a document, so it only matched when the query happened to be a literal fragment of the text.
First upgrade: CAP’s built-in .search(), backed by real HANA full-text/fuzzy search:
@cds.search: { content }
entity Documents { … }return await SELECT.from(Documents).search(query);
Better — single keywords matched via genuine fuzzy search. Full sentences still failed. HANA’s fuzzy search treats a multi-word query as one phrase, requiring the words to appear together, not scattered across the document. “Blade Runner” (verbatim in the text) matched; “Blade Runner Philip Dick films” (words present, not contiguous) did not.
Two-part fix:
Query distillation in the orchestrator — ask the LLM to reduce the question to 2-5 short keywords before searching:keyword_message = claude.messages.create(
model=”claude-opus-5″, max_tokens=50,
system=”Extract 2-5 short search keywords from the user’s question…”,
messages=[{“role”: “user”, “content”: query}],
)Per-term search with merge in the CAP service — split the terms and search each independently, unioning the results:const terms = query.split(/s+/).filter(Boolean);
const byID = new Map();
for (const term of terms) {
const matches = await SELECT.from(Documents).search(term);
for (const doc of matches) byID.set(doc.ID, doc);
}
return […byID.values()];
Real fuzzy search per term, OR’d across terms, is what made natural-language questions actually work.
Step 7: proving it with real content
Test data so far was one generic document describing the architecture itself. To properly test retrieval plus generation together, inserted three real documents via the CAP service’s OData endpoint — picked Philip K. Dick’s biography, major works, and recurring themes, just needed something to genuinely query:
curl -X POST https://…/retrieval/Documents -H “Content-Type: application/json”
-d ‘{“title”: “…”, “content”: “…”, “createdAt”: “…”}’
Then asked the full pipeline:
“Which of Philip K. Dick novel was Blade Runner based on, and what films came from his other stories?”
Got back a correct answer citing Do Androids Dream of Electric Sheep? and the other film adaptations accurately, and separately, correctly declined to answer a question about Dick’s amphetamine use, since that fact wasn’t in any of the three documents provided — despite being well-documented elsewhere. That’s the actual proof: it answers from what it’s given, not from what it otherwise knows.
Three more documents followed the same way — an essay on writers and stimulants, and two about R2-D2 — all six now committed as plain text files under documents/ alongside the code, rather than existing only as rows in HANA.
Step 8: making it usable
Testing via raw curl is fine for verification, not for actually using the thing. Added CORS headers to the Flask app and a single self-contained HTML file (ask.html) with a text box and a button, no server or build step needed:
@app.after_request
def add_cors_headers(response):
response.headers[“Access-Control-Allow-Origin”] = “*”
return response
Where it stands
Working end to end: a question typed into a plain HTML page routes through keyword extraction, real full-text search against live HANA Cloud data, and a grounded answer generated from whatever comes back — correctly refusing to answer when the documents don’t contain the information.
Not done: both services currently run with no authentication — fine for a personal proof of concept, not something to point at anything real without locking that down first.
Takeaways
HANA’s fuzzy search is phrase-based, not keyword-based. A .search(query) call with a full sentence will quietly return nothing, and it’s not obvious why until tested with progressively shorter queries.Deploying code and deploying schema are different operations, and on a network-restricted HANA instance, the schema deploy may only be runnable from inside the platform, not from your own machine — even when other tooling works fine locally.Secrets set via cf set-env and absent from the manifest can still leak through an unrelated cf push’s diff output.
Reference
Rokkam, Sriram. Agentic Hybrid RAG on SAP BTP: A Hands-On Guide with LangGraph, HANA Cloud, and Google Vertex AI. Kindle Direct Publishing, 2026.
I recently read Sriram Rokkam’s excellent book, ‘Agentic Hybrid RAG on SAP BTP: A Hands-On Guide with LangGraph, HANA Cloud, and Google Vertex AI’, and wanted to see if I could build something similar with Claude — a Python orchestration service, a CAP service exposing OData V4, and a HANA Cloud retrieval backend.The architectureUser query
|
v
Python orchestrator (Flask, Cloud Foundry)
1. distills the question into search keywords
2. calls the CAP service’s search action
3. sends retrieved documents + question to an LLM
v
CAP service (Node.js, OData V4, Cloud Foundry)
exposes a Documents entity and a search action
v
HANA Cloud (full-text/fuzzy search)Step 1: scaffoldingTwo folders: cap-service/ (standard CAP shape — package.json, db/schema.cds, srv/.cds + srv/.js) and orchestrator/ (plain Flask app). Both deploy as native Cloud Foundry apps via manifest.yml and cf push — no Docker, no Kubernetes involved.Step 2: the HANA quota wallFirst attempt was to provision a fresh HANA Cloud instance:cf create-service hana-cloud hana-free my-hana-instance -c ‘{“data”:{“edition”:”cloud”,”memory”:16,”systempassword”:”…”}}’Failed:Job (…) failed: provision could not be completed: Service broker error: Service broker
hana-cloud failed with: You may only create one SAP HANA Cloud Free Tier instanceThe quota turned out to apply to the underlying HANA Cloud database, not to HDI containers — a single free-tier database can host any number of independent HDI containers (schemas). So instead of fighting the quota, created a dedicated container of its own on the org’s existing database:cf create-service hana hdi-shared btp-agent-poc-dband pointed the CAP service’s manifest at it:services:
– btp-agent-poc-dbConfirmed this is genuinely independent — its own HDI container, its own schema, no dependency on any other project — while still not requiring a second HANA database.The container creation itself failed on the first attempt too:status: create failed
message: Failed to open connection to the database, because of: … HANA Database
instance is stoppedSame free-tier auto-stop-on-inactivity behavior as before. Resumed it and retried:cf update-service HANA_TRAINING_FREE -c ‘{“data”: {“serviceStopped”: false}}’
cf create-service hana hdi-shared btp-agent-poc-dbSecond attempt succeeded. Then hit one more snag deploying the schema to it: cf push doesn’t automatically unbind a service that’s been removed from the manifest — the app ended up bound to both the old and new HDI containers at once, and cds deploy refused to pick one:Error: More than one HDI service found, but no service is defined as the deployment
target via the environment variable “TARGET_CONTAINER”Fixed with an explicit unbind of the old container before redeploying:cf unbind-service btp-agent-retrieval-srv <old-container-name>After that, the schema deployed cleanly to the new, fully independent container, and the whole pipeline re-tested working end to end against it — same correct, grounded answers as before.Step 3: the schema-deploy gapcf push deploys the running Node.js service. It doesn’t deploy the database schema — that’s a separate step, cds deploy –to hana, easy to miss the first time. First test hit:invalid table name: Could not find table/view RETRIEVALSERVICE_DOCUMENTS in schema…Tried running the deploy from a laptop:cds bind -2 btp-agent-poc-db
cds deploy –to hanaFailed differently:Could not connect to any host: [ …hanacloud.ondemand.com:443 – Client network socket
disconnected before secure TLS connection was established ]The HANA instance only accepts connections from Cloud Foundry IPs in-region. A laptop was never going to get through — and this is a separate restriction from general cf CLI access, which talks to the Cloud Foundry API, a different and publicly reachable host. cf push working doesn’t mean cds deploy from the same machine will.Fix: run the schema deploy from inside Cloud Foundry, temporarily changing the start command:command: npx cds deploy –to hana && npx cds-servePush once with that command, confirm the schema deployed (endpoint returns 200 instead of the table error), then revert to plain npx cds-serve so it doesn’t redeploy on every restart. This needed @SAP/cds-dk moved into package.json’s dependencies rather than devDependencies — Cloud Foundry’s buildpack sets NPM_CONFIG_PRODUCTION=true, which skips dev dependencies.Step 4: the actual AI partEverything so far was plumbing. Added the real thing to the orchestrator:message = claude.messages.create(
model=”claude-opus-5″,
max_tokens=1024,
system=”Answer the user’s question using only the documents provided…”,
messages=[{“role”: “user”, “content”: f”Documents:n{documents}nnQuestion: {query}”}],
)Deployed, tested against an empty document table first — correctly said “no documents found” instead of inventing something. Good sign.Step 5: a secrets lessonWhile setting the API key on the deployed app:cf set-env btp-agent-orchestrator ANTHROPIC_API_KEY sk-ant-…
cf restage btp-agent-orchestratorAn unrelated later cf push printed its manifest diff to the terminal — and because the key wasn’t declared in the committed manifest.yml, having only been set via cf set-env, the diff displayed the full key value as a line being removed:- ANTHROPIC_API_KEY: sk-ant-api03-…Worth knowing: a secret set only via cf set-env, absent from the manifest, can get printed in full the next time a cf push touches that app — even though it was never written to a file. (The push hadn’t actually removed the variable despite the diff showing it as removed — not something to rely on either way.)Step 6: real search, not a placeholderFirst search implementation was a plain LIKE:return await SELECT.from(Documents).where({ content: { like: `%${query}%` } });Needs an exact substring match — a natural question like “What are the three layers of the architecture?” basically never appears verbatim in a document, so it only matched when the query happened to be a literal fragment of the text.First upgrade: CAP’s built-in .search(), backed by real HANA full-text/fuzzy search:@cds.search: { content }
entity Documents { … }return await SELECT.from(Documents).search(query);Better — single keywords matched via genuine fuzzy search. Full sentences still failed. HANA’s fuzzy search treats a multi-word query as one phrase, requiring the words to appear together, not scattered across the document. “Blade Runner” (verbatim in the text) matched; “Blade Runner Philip Dick films” (words present, not contiguous) did not.Two-part fix:Query distillation in the orchestrator — ask the LLM to reduce the question to 2-5 short keywords before searching:keyword_message = claude.messages.create(
model=”claude-opus-5″, max_tokens=50,
system=”Extract 2-5 short search keywords from the user’s question…”,
messages=[{“role”: “user”, “content”: query}],
)Per-term search with merge in the CAP service — split the terms and search each independently, unioning the results:const terms = query.split(/s+/).filter(Boolean);
const byID = new Map();
for (const term of terms) {
const matches = await SELECT.from(Documents).search(term);
for (const doc of matches) byID.set(doc.ID, doc);
}
return […byID.values()];Real fuzzy search per term, OR’d across terms, is what made natural-language questions actually work.Step 7: proving it with real contentTest data so far was one generic document describing the architecture itself. To properly test retrieval plus generation together, inserted three real documents via the CAP service’s OData endpoint — picked Philip K. Dick’s biography, major works, and recurring themes, just needed something to genuinely query:curl -X POST https://…/retrieval/Documents -H “Content-Type: application/json”
-d ‘{“title”: “…”, “content”: “…”, “createdAt”: “…”}’Then asked the full pipeline:”Which of Philip K. Dick novel was Blade Runner based on, and what films came from his other stories?”Got back a correct answer citing Do Androids Dream of Electric Sheep? and the other film adaptations accurately, and separately, correctly declined to answer a question about Dick’s amphetamine use, since that fact wasn’t in any of the three documents provided — despite being well-documented elsewhere. That’s the actual proof: it answers from what it’s given, not from what it otherwise knows.Three more documents followed the same way — an essay on writers and stimulants, and two about R2-D2 — all six now committed as plain text files under documents/ alongside the code, rather than existing only as rows in HANA.Step 8: making it usableTesting via raw curl is fine for verification, not for actually using the thing. Added CORS headers to the Flask app and a single self-contained HTML file (ask.html) with a text box and a button, no server or build step needed:@app.after_request
def add_cors_headers(response):
response.headers[“Access-Control-Allow-Origin”] = “*”
return response Where it standsWorking end to end: a question typed into a plain HTML page routes through keyword extraction, real full-text search against live HANA Cloud data, and a grounded answer generated from whatever comes back — correctly refusing to answer when the documents don’t contain the information.Not done: both services currently run with no authentication — fine for a personal proof of concept, not something to point at anything real without locking that down first.TakeawaysHANA’s fuzzy search is phrase-based, not keyword-based. A .search(query) call with a full sentence will quietly return nothing, and it’s not obvious why until tested with progressively shorter queries.Deploying code and deploying schema are different operations, and on a network-restricted HANA instance, the schema deploy may only be runnable from inside the platform, not from your own machine — even when other tooling works fine locally.Secrets set via cf set-env and absent from the manifest can still leak through an unrelated cf push’s diff output.ReferenceRokkam, Sriram. Agentic Hybrid RAG on SAP BTP: A Hands-On Guide with LangGraph, HANA Cloud, and Google Vertex AI. Kindle Direct Publishing, 2026. Read More Technology Blog Posts by Members articles
#SAP
#SAPTechnologyblog