Repository navigation
RAG
The assistant uses an in-database Retrieval-Augmented Generation (RAG)
pipeline built entirely on Oracle Database 26ai's AI Vector Search. Every
task, sprint, and project is embedded into a VECTOR(384, FLOAT32) column;
at query time the user's question is embedded with the same model and the
top-K most similar chunks are pulled with a cosine-distance search and handed
to the LLM as grounding context.
Nothing about the retrieval path leaves the database except the final prompt:
embeddings are produced inside Oracle by a loaded ONNX model
(ALL_MINILM_L12_V2), so there is no external embedding API, no per-call cost,
and no extra network hop.
write path read path
┌──────────────┐ (async, queued) ┌──────────────┐ (per question)
│ Task/Sprint/ │ │ BotAgent / │
│ Project/ │──enqueue──► RAG_DIRTY │ chat caller │
│ Comment svc │ │ └──────┬───────┘
└──────────────┘ │ poll 2s │ embed query
▼ ▼
┌───────────────┐ ┌───────────────┐
│ RagIndexWorker│ │ RagRetriever │
│ + RagIndexer │ │ (role-scoped) │
└──────┬────────┘ └──────┬────────┘
VECTOR_EMBEDDING │ VECTOR_DISTANCE │ FETCH APPROX
▼ ▼
┌───────────────────────────────────┐
│ RAG_CHUNK (CONTENT + EMBEDDING) │
│ RAG_CHUNK_EMB_IDX (vector index) │
└───────────────────────────────────┘
The previous BotAgentService was not a RAG. On every chat turn it dumped
the entire role-scoped project state into the LLM prompt (a developer's full
task list, or — for managers — every employee, task, sprint, and project).
That approach grows the prompt linearly with the organization: it bursts the
context window and the LLM spend limit within a handful of sprints, and answer
quality drops because the model can't tell relevant data from noise.
RAG replaces the full dump with a small, targeted set of chunks selected by semantic similarity to the actual question.
| Feature | Role in this project |
|---|---|
VECTOR(384, FLOAT32) column |
Stores each chunk's embedding (RAG_CHUNK.EMBEDDING) |
DBMS_VECTOR.LOAD_ONNX_MODEL[_CLOUD] |
Loads the embedding model into the DB (one-shot per database) |
VECTOR_EMBEDDING(model USING ? AS DATA) |
SQL function: text → vector, used by indexer and retriever |
VECTOR_DISTANCE(v1, v2, COSINE) |
Cosine distance, used in retrieval ORDER BY
|
CREATE VECTOR INDEX ... ORGANIZATION NEIGHBOR PARTITIONS |
Approximate-NN (IVF) index for the search |
FETCH APPROX FIRST k ROWS ONLY |
Triggers the approximate vector index |
- 384 dimensions, distributed by Oracle as a single ~80 MB ONNX bundle.
- Runs in-database, so there is no external embedding API key, no egress, and no per-call cost.
- 384 dims keeps the vector index small enough for the Always Free ATP shape (1 OCPU / 20 GB).
- Suited to short technical text (task titles, descriptions, comments). It is
English-leaning; seed data is mixed ES/EN, which is acceptable for v1. If
Spanish recall becomes a real issue, swapping to
multilingual-e5-small(same dim family) is contained to the bootstrap load +application.properties.
Note on the bundle: only Oracle's augmented bundle (
all_MiniLM_L12_v2_augmented.zip) works withLOAD_ONNX_MODEL— it has the tokenizer and post-processing fused into the ONNX graph. The plain HuggingFace export does not load.
Denormalized so retrieval is a single index lookup with no joins. Defined in migrations/V5__rag_chunk.sql.
| Column | Notes |
|---|---|
CHUNK_ID |
Identity PK |
SOURCE_TYPE |
'task' | 'sprint' | 'project'
|
SOURCE_ID |
PK in the source table |
OWNER_EMPLOYEE_ID |
For task rows: the assignee's employee id (NULL if unassigned). For sprint/project rows: NULL (organizational, not personal data). Used for row-level access control at query time. |
PROJECT_ID, SPRINT_ID
|
Optional scope filters |
CONTENT |
The exact text that was embedded; stored so it can be handed to the LLM verbatim without re-reading source rows |
EMBEDDING |
VECTOR(384, FLOAT32) |
CONTENT_HASH |
SHA-256 hex of CONTENT; lets the indexer skip the embedding call when nothing changed |
UPDATED_AT |
Last refresh timestamp |
Unique constraint RAG_CHUNK_SRC_UQ (SOURCE_TYPE, SOURCE_ID) makes the upsert
a clean MERGE target. Secondary B-tree indexes on OWNER_EMPLOYEE_ID and
PROJECT_ID support the access-control filter.
CREATE VECTOR INDEX TODOUSER.RAG_CHUNK_EMB_IDX
ON TODOUSER.RAG_CHUNK (EMBEDDING)
ORGANIZATION NEIGHBOR PARTITIONS -- IVF, on disk
DISTANCE COSINE
WITH TARGET ACCURACY 90;The implementation uses IVF (ORGANIZATION NEIGHBOR PARTITIONS) rather
than HNSW (ORGANIZATION INMEMORY NEIGHBOR GRAPH). HNSW needs a configured
vector_memory_area, which is 0 on 23ai Free and tight on Always Free ATP;
IVF lives on disk and avoids both limits. The query path
(FETCH APPROX FIRST k ROWS ONLY) is identical for either index type, so the
application code is unaffected by the choice.
RAG_DIRTY (
DIRTY_ID, SOURCE_TYPE, SOURCE_ID,
ACTION -- 'UPSERT' | 'DELETE'
ENQUEUED_AT, ATTEMPTS, LAST_ERROR
)Entity writes don't embed inline — they insert a cheap row here, in the same
transaction. A scheduled worker drains it. Index RAG_DIRTY_PICK_IDX (ENQUEUED_AT, DIRTY_ID) supports FIFO draining.
One chunk per entity (comments are folded into the parent task's chunk, since per-entity text is short and further chunking would only add retrieval noise). Built in RagIndexer.java.
[TASK #<id>] <title>
Status: <status> | Priority: <priority> | Sprint: <sprint name or "none">
Project: <project name or "none">
Assignee: <first> <last> | "unassigned"
Story points: <n> (omitted if null)
Estimated hours: <n> (omitted if null)
Due: <expected_end_date or "-">
Description: <description or "-">
Comments:
- <comment 1>
- <comment 2>
[SPRINT #<id>] <name> (<status>)
Project: <project name or "none">
Dates: <start> -> <end>
Goal: <goal or "-">
[PROJECT #<id>] <name> (<status>)
Dates: <start> -> <end>
Description: <description> (omitted if blank)
The bracketed [TASK #id] / [SPRINT #id] / [PROJECT #id] prefix is
deliberate: it survives embedding and gives the LLM a stable anchor to cite
IDs back to the user.
Indexing is decoupled from entity writes via the RAG_DIRTY queue. The
classes live in com.springboot.MyTodoList.rag.
A single INSERT into RAG_DIRTY, run with Propagation.REQUIRED so it joins
the caller's transaction. If the entity write rolls back, the queue row rolls
back too — no phantom dirty rows. The four mutating services call it:
| Service | Methods | Enqueues |
|---|---|---|
TaskService |
save/update | upsert("task", id) |
TaskService |
delete | delete("task", id) |
SprintService |
save/update/delete | ("sprint", id, …) |
ProjectService |
save/update/delete | ("project", id, …) |
CommentService |
save/update/delete |
upsert("task", comment.task.id) — comments live in their task's chunk |
A @Scheduled bean (fixedDelay = 2s, initialDelay = 5s). Each tick, inside
a REQUIRES_NEW transaction:
-
Pick a batch (default 32) ordered by
ENQUEUED_AT, DIRTY_IDwithFOR UPDATE SKIP LOCKED, so multiple replicas never double-process the same row. (Oracle rejectsFETCH FIRST … FOR UPDATEwith ORA-02014, so the query locks byROWIDselected in a subquery — the canonical workaround.) -
Coalesce by
(SOURCE_TYPE, SOURCE_ID): the latestDIRTY_IDwins the action, so aDELETEafter anUPSERTresolves to delete, and a chatty comment thread collapses to one embedding call per task. - For each group:
UPSERT → RagIndexer.reindex(...),DELETE → RagIndexer.delete(...). - On success, delete that group's queue rows by exact id.
- On failure, increment
ATTEMPTS, storeLAST_ERROR, and leave the rows. AtATTEMPTS >= max-attempts(default 5) it logs loudly but keeps the rows — no silent data loss.
RagIndexer.reindex loads the source entity, builds its chunk text, and:
- Computes the SHA-256 hash of the text.
- If a
RAG_CHUNKrow already exists with the same hash → no-op (skips the embedding call entirely). This is the second line of defense against redundant work, after the queue's coalescing. - Otherwise calls
EmbeddingService.embed(text)→SELECT VECTOR_EMBEDDING(ALL_MINILM_L12_V2 USING ? AS DATA) FROM dual, read back asfloat[]via the 23ai+ JDBC driver'sgetObject(1, float[].class). -
MERGEs the(content, embedding, hash, owner, project, sprint)intoRAG_CHUNKkeyed on(SOURCE_TYPE, SOURCE_ID). Thefloat[]is bound withOracleType.VECTOR_FLOAT32.
If the source entity no longer exists, reindex deletes the chunk instead.
On ApplicationReadyEvent, enqueues UPSERTs for every Task/Sprint/Project that
has no matching RAG_CHUNK row. Cheap on warm DBs (the missing set is empty);
on a cold DB it seeds the queue so the worker populates everything without
blocking startup. Controlled by rag.backfill.on-startup.
List<RetrievedChunk> search(String query, int topK, Employee caller, boolean isManager)- Embed the question via
EmbeddingService(same model as indexing). - Run the role-scoped top-K cosine search:
SELECT SOURCE_TYPE, SOURCE_ID, CONTENT,
VECTOR_DISTANCE(EMBEDDING, ?, COSINE) AS DIST
FROM RAG_CHUNK
WHERE (? = 1 -- isManager → see everything
OR SOURCE_TYPE IN ('sprint','project') -- org context, not personal
OR OWNER_EMPLOYEE_ID = ?) -- own task chunks only
ORDER BY VECTOR_DISTANCE(EMBEDDING, ?, COSINE)
FETCH APPROX FIRST ? ROWS ONLY;Access control is enforced in SQL, mirroring the old role scoping:
- Managers / admins see every chunk.
- Developers see all sprint/project chunks (organizational context they legitimately need) plus only the task chunks where they are the assignee.
FETCH APPROX FIRST is what tells the optimizer to use RAG_CHUNK_EMB_IDX.
Without APPROX Oracle does an exact full scan — correct, but it defeats the
index.
All knobs live in application.properties with safe defaults, so no new secrets are introduced (the model lives in the DB; the LLM key is already wired):
| Property | Default | Purpose |
|---|---|---|
rag.embedding.model |
ALL_MINILM_L12_V2 |
DB mining-model name used by VECTOR_EMBEDDING
|
rag.retrieval.top-k |
8 |
Chunks fed to the LLM per question |
rag.backfill.on-startup |
true |
Enqueue missing chunks on boot |
rag.worker.fixed-delay-ms |
2000 |
Worker poll interval |
rag.worker.initial-delay-ms |
5000 |
Delay before first worker tick |
rag.worker.batch-size |
32 |
Rows drained per tick |
rag.worker.max-attempts |
5 |
Retry cap before logging loudly |
The ONNX model must exist in the DB once before any embedding SQL works — a per-database operation, not per-deploy.
-
MtdrSpring/terraform/embeddings.tf
downloads the augmented ONNX bundle at
terraform applytime (via anull_resourceinto a gitignored.cache/), uploads it to the existing Object Storage bucket, and issues a 1-year Pre-Authenticated Request (PAR) URL the database can read without its own credentials. The URL is exported as theminilm_onnx_par_urloutput (markedsensitive). -
bootstrap-app.sh (step 5b) applies
V5__rag_chunk.sql, then — ifMINILM_ONNX_PAR_URLis set — runsDBMS_VECTOR.LOAD_ONNX_MODEL_CLOUDguarded by anall_mining_modelsexistence check, so it loads once and is a no-op on every subsequent deploy. The PAR URL is the credential, so noDBMS_CLOUD.CREATE_CREDENTIALand no extra GitHub secret are needed.
compose.dev.yml uses gvenzl/oracle-free:23-slim-faststart, which ships the
full vector engine. LOAD_ONNX_MODEL_CLOUD only accepts OCI Object Storage
URLs, so locally the model is loaded from a bind-mounted file via
DBMS_VECTOR.LOAD_ONNX_MODEL — see
migrations/local/ and the helper under
MtdrSpring/utils/dev/. The Java code does not branch on local vs cloud;
as long as the model name ALL_MINILM_L12_V2 exists in the user's
mining-model catalog, the indexer, worker, retriever, and chat all behave
identically.
- No re-ranker. Pure cosine on 384-dim MiniLM will occasionally pull a semantically-close-but-wrong chunk into the top-K. The mitigation is the LLM's instruction to answer only from context and otherwise say it doesn't know.
-
Worker stall signal. If the embedding model is missing or broken, every
tick fails but rows stay queued (no data loss). Alert on
SELECT COUNT(*) FROM RAG_DIRTY WHERE ATTEMPTS >= 3. -
Access control is WHERE-clause-based, not VPD. There is exactly one
caller (
BotAgentService); forgetting to passcaller/isManagerwould leak data, so the scoping is centralized inRagRetriever.
Hybrid lexical+vector search, multi-turn chat memory, streaming responses,
a second-stage re-ranker, and moving embeddings to OCI Generative AI
(cohere.embed-multilingual-v3.0).
| File | Role |
|---|---|
| migrations/V5__rag_chunk.sql |
RAG_CHUNK, RAG_DIRTY, indexes, vector index, grants |
| MtdrSpring/terraform/embeddings.tf | ONNX download + upload + PAR |
rag/EmbeddingService.java |
text → float[384] via VECTOR_EMBEDDING
|
rag/RagIndexer.java |
Chunk text builders + hash-skip + MERGE
|
rag/RagIndexWorker.java |
@Scheduled queue drainer (coalesce, retry, SKIP LOCKED) |
rag/RagDirtyEnqueuer.java |
Cheap in-transaction enqueue |
rag/RagBackfillRunner.java |
Startup backfill of missing chunks |
rag/RagRetriever.java |
Role-scoped top-K vector search |
rag/RagChunk.java, rag/RagDirty.java (+ repos) |
JPA entities for the two tables |
See AI_Feature.md for how RAG plugs into the overall AI assistant, and Bot-Architecture.md for the Telegram surface.