Skip to content

feat(rag): Knowledge Engine overhaul — chunking, source persistence, re-index, hybrid retrieval and retrieval evals - #234

Merged
anilguleroglu merged 16 commits into
mainfrom
feat/knowledge-engine-overhaul
Aug 21, 2026
Merged

feat(rag): Knowledge Engine overhaul — chunking, source persistence, re-index, hybrid retrieval and retrieval evals#234
anilguleroglu merged 16 commits into
mainfrom
feat/knowledge-engine-overhaul

Conversation

@anilguleroglu

Copy link
Copy Markdown
Collaborator

Knowledge Engine overhaul, plus the two pending claude/* branches folded in.

What is in here

  • Chunking rebuilt on one splitter/packer: real tokens via gpt-tokenizer (the token strategy used to split on whitespace and ignore encoding entirely), chunkSize as a hard cap, and new markdown / sentence / semantic strategies. chunkOverlap >= chunkSize used to spin the token splitter forever and was reachable from the UI.
  • Source persistence and re-index: every ingest stores its extracted text, so a re-index no longer rebuilds a document by joining its own overlapping chunks. Changing chunking or the embedding model marks the module and enqueues a resumable, cross-replica-claimed run.
  • Retrieval: optional hybrid dense+keyword per provider, automatic module isolation, small-to-big parent windows, and thresholds that always mean the score the module's own pipeline produced.
  • Insight: query logs record what the threshold discarded, not just what survived, feeding a zero-result feed and a score histogram. Evaluations gain a rag target and three scorers judged by embedding similarity.
  • Merged in: claude/azure-search-key-encoding-naka6b (canonical vector filter language, vector query logs through the DB abstraction) and claude/console-pen-test-prep-36fywd (project-scoping by-id routes, production config fail-closed, separate encryption key). Conflicts were resolved per feature, not per side — see the merge commit.

Verification

tsc clean · 3733 tests · eslint 0 errors · EE overlay src/ tree clean. package.json and package-lock.json are byte-identical to main, so no lockfile regeneration is needed before tagging.

Known issues, deliberately shipped

Four review rounds ran; every round's fixes were themselves audited, which is how the last three criticals were caught. What remains is documented and accepted for this release:

  • Hybrid retrieval is inert — chunk text never reaches the vector store's searchable field, so the keyword channel has nothing to match. Off by default.
  • Query-log retention has no caller — implemented on both backends, scheduled nowhere.
  • Async ingest above a size threshold was not built — ingest is still synchronous in the request.
  • A pod restart during a re-index strands the run — the boot sweep is the only re-drive and it fires while the dying pod's claim is still fresh. Recoverable by cancelling the run.
  • Editing a memory item drops its scope metadata, and filters are now genuinely pushed down, so an edited memory falls out of scoped search. Pre-existing bug, newly visible.
  • Do not ship this to -onprem: production now exits on a config validation error, and the EE chart ships an empty PROVIDER_ENCRYPTION_SECRET.

Rollback note: :production is a mutable tag pulled with Always, so rollout undo cannot roll this back. The current digest is sha256:44e31c06316e4c7babbb88963d29f019b5a32e017d047c11ed8ad1b705d7c55b.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KG71cnyfK5GGwbXb25pxsd

anilguleroglu and others added 16 commits August 21, 2026 04:04
… reset-link poisoning

Three pre-pen-test hardening fixes on the authentication and secrets path.

* bootstrap: refuse to start in production when validateConfig reports errors.
  The validator already required a >=32-char JWT_SECRET and an independent
  PROVIDER_ENCRYPTION_SECRET, but the results were only logged, so a deployment
  missing them booted on the shipped development defaults — a session token
  anyone could forge and credentials encrypted under a publicly known key.
  Development still only warns.

* crypto: production no longer falls back to JWT_SECRET when encrypting provider
  credentials, so the signing key and the at-rest encryption key can never be the
  same value. Decryption still tries both secrets, so existing payloads stay
  readable and are re-encrypted on their next save.

* auth: build the password-reset link from the configured app URL instead of the
  request Origin/Host headers. The endpoint is unauthenticated, so those headers
  let an attacker have a genuine reset token mailed to a victim pointing at a
  host the attacker controls.

Verified: tsc --noEmit clean; auth/crypto/config suites pass (7 files, 73 tests).
findMcpServerById is scoped only by the tenant database, and the by-id handlers
passed the URL id straight to it. Any member of one project could therefore
address a server belonging to another project in the same tenant: read its full
config, PATCH its exposure to public, delete it, or — worst — POST
/mcp/:id/execute to run its tools with the upstream credentials that server
stores.

Adds serverInProjectScope(), which resolves the server and returns null when it
belongs to another project, and applies it to GET /mcp/:id, GET /mcp/:id/logs,
PATCH /mcp/:id, DELETE /mcp/:id, POST /mcp/:id/refresh-tools and
POST /mcp/:id/execute. Out-of-scope ids get the same 404 as missing ones, so the
endpoint is not an existence oracle.

Owners and admins keep tenant-wide reach, which resolveProjectContext already
grants them, and servers stored without a projectId stay tenant-wide, matching
how findMcpServerByKey already treats them — so no in-project or admin flow
changes.

Verified: tsc --noEmit clean; MCP suites pass (6 files, 33 tests).
The same defect the MCP server routes had repeats across the product: a handler
takes an id from the URL and resolves it with a lookup scoped only by tenant
database, never comparing the object's projectId to the caller's resolved
project. Projects are a real authorization boundary — resolveProjectContext
restricts a non-privileged member to the projects they belong to — so every one
of these routes let a member of one project reach another project's data.

Closed here, each with the same guard the mcp.ts fix introduced:

* agents — get/update/delete/versions/publish/conversations/chat. Chat and
  conversations also let the caller drive another project's agent.
* tools — get/update/delete/logs, and execute, which issued the outbound call
  with the victim project's stored upstream credentials and returned the
  response to the caller.
* rag — document get, and also delete and re-ingest, which resolve the document
  by id alone while scoping only the module, so a member could destroy or
  overwrite another project's document. Module usage logs (the questions asked
  against a knowledge base) are now behind the same module resolution the
  sibling routes already use.
* memory — item delete and update, and bulk delete, which ignored the store's
  project entirely.
* guardrails — get/update/delete/evaluations, plus the word-list routes, whose
  edits change what every guardrail referencing the list blocks.
* ocr-jobs — all nine by-id routes, including item read and export.
* pii — policy get/update/delete.

In every case an out-of-scope id returns exactly what a missing id already
returned, so none of these routes becomes an existence oracle. Owners and admins
keep the tenant-wide reach resolveProjectContext already grants them, and rows
stored without a projectId stay reachable so nothing predating project stamping
breaks. Several handlers that previously used requireSessionContext now resolve
project context, which is what their sibling list/create routes already did.

The guards live in the plugins. The service and database layers still resolve
these objects by id alone, so a future caller can reintroduce the same hole;
pushing projectId down into those lookups is the durable fix.

Verified: tsc --noEmit clean; full suite identical to the pristine tree —
3244 passed, 0 failures, same 5 pre-existing MongoDB-dependent files.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KUtq8Ck52nZDTzh8uTTUfJ
Azure AI Search document keys only allow letters, digits, underscore,
dash, and equal sign. Knowledge Engine vector ids use the
"module:documentId:chunkIndex" pattern, so every upsert was rejected
with 'Invalid document key' and repeated failures tripped the
vector-upsert circuit breaker.

Unsafe ids are now stored under a marked URL-safe Base64 form
(_b64_ prefix) and transparently decoded on query/list, so callers keep
their original ids end-to-end. Already-safe ids pass through unchanged,
and deletes encode with the same rule so stored documents are targeted
correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gQKi8FTcqmRzQTchuKg3s
Vector queries accepted a `filter` that each driver interpreted differently:
Chroma read it as a `where` clause, Elasticsearch as query DSL, Atlas as a
$vectorSearch filter, Milvus stringified it into an expression it cannot parse,
and nine drivers — including Azure AI Search — ignored it outright. Callers
that filtered (Knowledge Engine queries, memory scope isolation) silently got
unfiltered results.

Introduce one provider-neutral filter DSL, parsed and validated once in the
service layer, then translated per driver into the store's native syntax:

  { source: "crawler", depth: { $lte: 2 }, lang: { $in: ["tr", "en"] } }

Field operators $eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$exists compose with
$and/$or/$not; `$raw` passes a provider-native filter through unchanged.

Filters are always pushed down, never applied afterwards in memory: a driver
declares the operators it can express via a `vector.filterOperators`
capability, and a filter it cannot honour is rejected with 400 rather than
dropped. topK therefore always counts matching documents.

Two stores needed schema changes to filter at all, and both stay backwards
compatible — pre-existing indexes keep serving unfiltered queries and reject
filtered ones with a message pointing at reindexing:

- Azure AI Search cannot filter inside its metadata JSON blob, so new indexes
  carry flattened `metadata_kv`/`metadata_keys` collections. That covers the
  equality family; range operators are not expressible over a string collection
  and are declared unsupported.
- Milvus can only filter a JSON-typed column; new collections type
  `metadata_json` as JSON instead of VarChar.

Knowledge Engine modules gain `defaultFilter`, ANDed into every query so
several sources can share one vector index, and `filterableFields`, which
restricts what callers may filter on and is advertised to agents and MCP
clients as the `filter` argument's documentation.

The filter reaches the store through every surface: client and dashboard APIs,
the Knowledge Engine query and playground, the agent `knowledge_search` tool,
the Knowledge Base MCP tool, and the OpenAPI contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gQKi8FTcqmRzQTchuKg3s
The vector index detail page aggregates `vector_query_logs` for daily query
volume, latency, average score, filtered-query share, and topK distribution,
but nothing ever wrote to that collection — the panel had been empty since it
shipped, and `filterApplied` was read by the aggregation while no code set it.

`queryVectorIndex` now records one row per search through the database
abstraction (`createVectorQueryLog`, implemented for both MongoDB and SQLite),
carrying the fields the aggregation matches on: `indexKey` from the index
record, `topK`, match count, latency, mean similarity score, whether a metadata
filter was pushed down, and the caller attribution resolved from the request
context.

The write happens after the search returns and its failures are logged and
swallowed: analytics must never turn a successful query into an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gQKi8FTcqmRzQTchuKg3s
…p usage

Two gaps left over from recording vector query logs.

The analytics endpoints reached past the database abstraction into a raw
MongoDB client, so the panel returned 503 on SQLite tenants and the aggregation
pipeline was duplicated between the Fastify plugin and the legacy route. The
aggregation moves behind `aggregateVectorQueryStats`, implemented for both
backends, with the day-filling and rounding the panel expects shared in
`getVectorIndexQueryStats`. Both endpoints now call that one path, and the index
key the logs are scoped by is resolved within the caller's project instead of
tenant-wide.

Vector searches were also absent from the usage rollup: they now record a
`vector` service event keyed by index key, carrying match count as a unit, with
failed searches counted as errors so the failure rate stays visible. Query
embeddings remain billed through the models service, so no tokens are
attributed here. Usage accounting and the analytics row are written together and
stay best-effort — neither can turn a successful search into an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gQKi8FTcqmRzQTchuKg3s
… integration

Snapshot only — taken so the azure-search-key-encoding and pen-test-prep
branches can be merged without risking the uncommitted tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nches

Brings three lines of work onto one base so the Knowledge Engine overhaul can
build on top of all of them:

  - origin/claude/azure-search-key-encoding-naka6b (canonical vector filter
    language, vector query logs + analytics through the DB abstraction,
    Azure AI Search key encoding)
  - origin/claude/console-pen-test-prep-36fywd (project-scoping the by-id
    routes, production config fail-closed, separate encryption key)
  - the uncommitted local tree (chunker separator fix, upload de-dup,
    defaultTopK/minScore, countRagQueryLogs, MCP composite/tool-name/custom-key,
    vector migrations, provider score fixes)

The first two merged clean. The local tree duplicated three of the branch's
features with weaker implementations, so conflicts were resolved per feature
rather than per side:

  - Azure document keys: kept the branch's prefix-marked encodeVectorId /
    decodeVectorId and dropped the local round-trip-guess encodeKey/decodeKey.
    Kept the local fix that searchText must stay undefined — passing '*' puts
    Azure into hybrid/RRF scoring and collapses every similarity to ~0.016.
  - vector_query_logs: kept the branch's writer, which goes through the DB
    abstraction (both backends, with usage attribution), and dropped the local
    raw-Mongo writer. Kept the local insight by moving that writer off the
    query's critical path with fireAndForget.
  - index analytics: kept the branch's move into the DB abstraction, and folded
    the local live "Vectors" KPI into getVectorIndexQueryStats, which already
    resolves the index key.

Also preserved from the local tree: the query-time dimension guard, the
metric-aware Postgres score expression (now over the branch's SQL-side
filtering), the systemDefault returnDistance/returnMetadata fix without which
every score was 0, and the Milvus id escaping.

Test fixtures updated, not weakened: six vector-service query fixtures now
carry a correctly sized vector because the dimension guard rejects mismatches,
and three RAG route tests mock shapeRagQueryResponse instead of leaving it
undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and offsets

Adds src/lib/services/rag/chunking.ts and moves every strategy onto a shared
splitter/packer pair, then extends the schema the chunks are stored under.

  - 'token' now counts real tokens with gpt-tokenizer (already a dependency,
    previously unused). It used to split on whitespace and ignore `encoding`
    entirely while the edit form offered three of them.
  - chunkSize is a hard cap. The trailing '' separator was inert, so text with
    no usable boundary (CJK, base64, a wide markdown table) was emitted whole
    and could overflow the embedding model.
  - chunkOverlap >= chunkSize used to spin the token splitter forever and was
    reachable from the UI. Rejected on write, clamped for existing modules.
  - New strategies: markdown (never crosses a heading, carries the breadcrumb),
    sentence, and semantic (cuts on embedding distance).
  - Optional contextual headers: one LLM-written sentence per chunk situating
    it in its document. Degrades to an unprefixed chunk on failure.
  - Chunks now record charStart/charEnd/headingPath/tokenCount, which is what
    makes small-to-big parent windows resolvable without duplicating text.
  - Documents carry an optional per-document chunkConfig override plus the
    columns the stored source and re-index will use.

ingest and re-ingest now share indexDocumentContent instead of carrying
near-verbatim copies. That closes two bugs in the copies: caller metadata was
spread OVER the reserved _documentId/_fileName keys, and a run that failed
after upserting left vectors nothing could delete, because deleteRagDocument
reconstructs ids from a chunkCount only written on success.

29 new chunking tests; the pipeline had none, so regressions shipped silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and retrieval evals

Completes the Knowledge Engine overhaul on top of the new chunker.

Storage and lifecycle
  - Every ingest now stores the extracted text (inline under 250k chars, in the
    file bucket above it) and the original bytes when a bucket is configured.
    Re-ingest resolves original -> stored text -> the old lossy chunk join, so a
    re-index no longer rebuilds a document from its own overlapping chunks.
  - Uploads de-duplicate on a content hash; an explicit force flag overrides it.
  - Deleting a module cascades to its documents, chunks, stored sources and
    vectors. It used to delete one row and orphan everything else.

Re-index
  - Changing chunkConfig or the embedding model marks the module and enqueues a
    resumable run (checkpointed per document, cancellable across nodes, resumed
    at boot). Queries keep serving the old vectors until it finishes.
  - A same-dimension embedding model swap is now flagged; nothing errored before
    and retrieval silently returned meaningless neighbours.

Retrieval
  - Optional hybrid dense+keyword search per provider, behind a capability flag,
    with fused scores normalised back onto the similarity scale.
  - minScore and the score histogram are thresholded on the SIMILARITY scale
    (denseScore/vectorScore), never on a reranker's or RRF's own scale — a
    module's calibrated 0.5 must not silently change meaning when hybrid is on.
  - isolateByModule ANDs the module key into every query, defaulting on only
    when the vector index is actually shared, so an externally populated index
    still returns results.
  - Small-to-big parent windows resolved from the stored source at query time.

Insight
  - Query logs record preFilterMatchCount, topScore, avgScore, minScoreApplied
    and hybrid, which is what separates "nothing was retrieved" from "the
    threshold discarded everything". New zero-result feed, score histogram and
    retention.
  - Evaluations gain a rag target and three scorers judged by embedding
    similarity rather than string overlap: context-recall, context-precision
    and groundedness.

Also fixes, found while wiring the above: rag evaluation targets were silently
dropped on SQLite; every new module retrieval field was silently dropped on
SQLite; chunkConfig was never validated on any write; and the document detail
endpoints would have returned the whole stored source on the wire.

3665 tests green, tsc clean, EE overlay app tree clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each finding here survived two independent reviewers instructed to refute it.

Data loss
  - Deleting a module deleted documents and chunks by module key alone,
    tenant-wide. Module keys are unique only within a project, so removing
    "docs" in one project destroyed the corpus of "docs" in every other project
    of the tenant. The bulk teardown is now taken only when no sibling module
    shares the key; otherwise rows go one document at a time.
  - The same function enumerated documents project-scoped but deleted them
    unscoped, so every document ingested through /client/v1 (which writes no
    projectId) lost its row while its vectors and bucket object survived.

Retrieval returning the wrong thing
  - The SaaS default vector provider re-wrapped a translated compound filter,
    emitting an $and bound to an object instead of an array. With isolation on
    by default that broke any filtered query with a ValidationException.
  - The same provider unwrapped metadata as typed scalars while the SDK returns
    plain JSON, so every key read back null — breaking content hydration,
    citations, parent windows and isolation itself.
  - Azure AI Search threw on any filter against an index created before this
    branch, so the isolation guard the system adds by itself killed every query
    on existing indexes. An explicit filter still fails loudly; the system's own
    guard degrades visibly instead.
  - minScore was being compared against the pre-rerank similarity on reranked
    modules, and against two different scales inside one hybrid result set. It
    now always means the score the module's own pipeline produced.
  - A re-index dropped every user and crawler metadata key from the corpus,
    which is exactly what defaultFilter and filterableFields match on.
  - Re-indexing a document that predates source persistence stored the lossy
    chunk join as its canonical source, making the corruption permanent.
  - Query-log analytics read the rows of a same-key module in another project,
    including the verbatim end-user query text.

Chunking
  - A stored chunkConfig with no chunkOverlap carried the whole previous chunk
    forward on every flush, so chunks grew without bound past chunkSize.
    undefined loses every numeric comparison, so no guard caught it.
  - Semantic chunking dropped whitespace-only atoms, breaking the contiguity the
    offsets depend on, so a parent window could cut off the matched chunk.
  - topScore was recorded after the min-score filter, so every query the
    threshold emptied logged 0 — in the panel that exists to explain them.

Operations
  - A production config failure threw into a caller that swallowed it, leaving
    the process alive and permanently unready: liveness kept passing, the
    rollout never stalled, and three replicas served 503 while looking healthy.
  - Re-index runs were claimed only in process memory. With no Redis the queue
    is per-pod, so after a rolling restart all three replicas resumed the same
    run and re-embedded the same documents concurrently.

Dashboard
  - Editing a module 400d whenever the description was empty, because the PATCH
    whitelist rejected the null the form sends to clear it.
  - The playground crashed the whole page on a module set to "Text only".
  - The edit form promised a background re-index and never started one.
  - Saving a legacy module silently flipped isolation on, and saving before the
    provider list loaded silently turned hybrid off.

3721 tests green (56 new regression tests), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second review pass audited the previous commit. Three of its fixes were
wrong, two of them worse than what they replaced.

  - Re-index refused to run at all. The cross-replica claim was written as a
    TypeScript interface and a test double and implemented on neither provider,
    so the job's own guard rejected every run. claimRagReindexRun,
    touchRagReindexRunClaim and releaseRagReindexRunClaim now exist on both
    backends: one findOneAndUpdate on MongoDB, one UPDATE ... WHERE on SQLite,
    with the claim fields, their columns and their tenant migration. Parity
    tests pin that two workers cannot both win, that a silent owner is
    reclaimable and a live one is not, and that a finished run is never claimed.

  - Refusing to store a chunk reconstruction removed the document's last copy.
    Re-ingest deletes a document's vectors and chunk rows before rebuilding it,
    and for a document with no stored source those rows ARE its text. Keeping
    the reconstruction only in a local variable meant one embedding timeout, or
    a pod killed mid-document, destroyed the document permanently — with a
    failure counter as the only trace. The text is stored again; what is
    withheld is its HASH, which is what de-duplication and change detection
    compare against, so a re-upload of the real original can never be mistaken
    for a duplicate of the join.

  - A 409 from the re-index endpoint was reported as success. It means a run
    started for the PREVIOUS configuration is still in flight, so nothing is
    rebuilding what was just saved. The run now records the configuration it
    rebuilds against and refuses to clear reindexRequired for a configuration it
    did not rebuild, and the form says so instead of showing a tick.

Also: clearing an optional module field did nothing on SQLite while the API
answered 200, because the service turns the API's null into an explicit
undefined and the guards there skipped undefined. Presence now decides, matching
MongoDB, with a parity test.

Not a regression, checked against origin/main rather than assumed: minScore has
always been compared against the post-rerank score (main, ragService.ts:680 and
:667). rankedScoreOf restores that; the similarityOf introduced earlier on this
branch was the deviation.

3733 tests green, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third audit found that the previous commit's durability fix did not actually
persist anything. For text under the inline cap, storeDocumentSource only builds
an in-memory object — it performs no write — and the source fields reached the
document row only in the success branch, after indexing had already deleted the
document's vectors and chunk rows. For a document with no previously stored
source those rows ARE its text, so any failure in between still destroyed it.

The source row fields are now written before the destructive steps.

The same audit found that dropping the `sourceIsTrustworthy` guard in that commit
also un-gated discardDocumentSource, so a chunk-join re-ingest deleted the
document's real stored source object and replaced it with the lossy join. A
reconstruction is what we fall back to when the real source could not be READ —
very often a transient failure — so it must never delete the object it failed to
read. The guard is back, on the discard alone.

SQLite's updateRagDocument still used `!== undefined` guards, so the deliberate
`sourceHash: undefined` that keeps a reconstruction from becoming canonical was
silently skipped there, leaving the stale hash of the true original next to the
join. Presence decides now, matching MongoDB.

The tests for this no longer assert which CALL carries the source; they assert
the source write happens before the first vector delete, which is the property
that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…resolves

`next build` failed with "Can't resolve 'gpt-tokenizer/encoding'": webpack
resolves requires statically, and the template literal made it try to resolve
the encoding DIRECTORY rather than the three modules underneath it.

tsc, vitest and eslint all pass on the dynamic form because they run on the real
Node resolver, so nothing local caught it — only the production build does. The
three specifiers are written out; loading stays lazy and memoised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@anilguleroglu
anilguleroglu merged commit 67e438a into main Aug 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants