@askdb/rag 0.2.0-beta.22 → 0.2.0-beta.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +14 -10
  2. package/bin/askdb-rag.js +8 -1
  3. package/dist/chunker/chunker.d.ts +5 -1
  4. package/dist/chunker/chunker.d.ts.map +1 -1
  5. package/dist/chunker/chunker.js +218 -125
  6. package/dist/chunker/chunker.js.map +1 -1
  7. package/dist/chunker/ids.d.ts +15 -0
  8. package/dist/chunker/ids.d.ts.map +1 -0
  9. package/dist/chunker/ids.js +22 -0
  10. package/dist/chunker/ids.js.map +1 -0
  11. package/dist/chunker/index.d.ts +1 -0
  12. package/dist/chunker/index.d.ts.map +1 -1
  13. package/dist/chunker/index.js +1 -0
  14. package/dist/chunker/index.js.map +1 -1
  15. package/dist/chunker/sources.d.ts.map +1 -1
  16. package/dist/chunker/sources.js +1 -0
  17. package/dist/chunker/sources.js.map +1 -1
  18. package/dist/embedders/ai-sdk.d.ts +15 -0
  19. package/dist/embedders/ai-sdk.d.ts.map +1 -1
  20. package/dist/embedders/ai-sdk.js +9 -0
  21. package/dist/embedders/ai-sdk.js.map +1 -1
  22. package/dist/embedders/dimensions.d.ts +9 -0
  23. package/dist/embedders/dimensions.d.ts.map +1 -0
  24. package/dist/embedders/dimensions.js +18 -0
  25. package/dist/embedders/dimensions.js.map +1 -0
  26. package/dist/embedders/openai.d.ts +1 -1
  27. package/dist/embedders/openai.js +1 -1
  28. package/dist/index.d.ts +5 -4
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +4 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/indexer/hash.d.ts +7 -0
  33. package/dist/indexer/hash.d.ts.map +1 -1
  34. package/dist/indexer/hash.js +13 -0
  35. package/dist/indexer/hash.js.map +1 -1
  36. package/dist/indexer/index.d.ts +10 -4
  37. package/dist/indexer/index.d.ts.map +1 -1
  38. package/dist/indexer/index.js +167 -30
  39. package/dist/indexer/index.js.map +1 -1
  40. package/dist/indexer/lock-file.d.ts +85 -4
  41. package/dist/indexer/lock-file.d.ts.map +1 -1
  42. package/dist/indexer/lock-file.js +108 -9
  43. package/dist/indexer/lock-file.js.map +1 -1
  44. package/dist/log-events.d.ts +11 -0
  45. package/dist/log-events.d.ts.map +1 -1
  46. package/dist/log-events.js +11 -0
  47. package/dist/log-events.js.map +1 -1
  48. package/dist/stores/file.d.ts +7 -1
  49. package/dist/stores/file.d.ts.map +1 -1
  50. package/dist/stores/file.js +52 -10
  51. package/dist/stores/file.js.map +1 -1
  52. package/dist/stores/memory.d.ts.map +1 -1
  53. package/dist/stores/memory.js +31 -3
  54. package/dist/stores/memory.js.map +1 -1
  55. package/dist/stores/pgvector.d.ts +36 -3
  56. package/dist/stores/pgvector.d.ts.map +1 -1
  57. package/dist/stores/pgvector.js +125 -17
  58. package/dist/stores/pgvector.js.map +1 -1
  59. package/dist/types.d.ts +69 -9
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +13 -11
  62. package/dist/bin.d.ts +0 -3
  63. package/dist/bin.d.ts.map +0 -1
  64. package/dist/bin.js +0 -7
  65. package/dist/bin.js.map +0 -1
  66. package/dist/cli.d.ts +0 -11
  67. package/dist/cli.d.ts.map +0 -1
  68. package/dist/cli.js +0 -356
  69. package/dist/cli.js.map +0 -1
package/README.md CHANGED
@@ -7,14 +7,14 @@ Deterministic retrieval over AskDB Schema v2 artifacts. `@askdb/rag` chunks the
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- pnpm add @askdb/rag @askdb/core
10
+ pnpm add @askdb/rag @askdb/core ai
11
11
  # only if you use the pgvector adapter:
12
12
  pnpm add pg
13
- # only if you use the OpenAI embedder helper or CLI `--embedder openai`:
14
- pnpm add ai @ai-sdk/openai
13
+ # only if you use the OpenAI embedder helper:
14
+ pnpm add @ai-sdk/openai
15
15
  ```
16
16
 
17
- `pg`, `ai`, and `@ai-sdk/openai` are optional peer dependencies. The chunker, in-memory store, and file store do not require them.
17
+ `ai` is a required peer dependency of `@askdb/core`. For `@askdb/rag` itself, `pg`, `ai`, and `@ai-sdk/openai` are optional peer dependencies — the chunker, in-memory store, and file store do not require them.
18
18
 
19
19
  ## Quickstart
20
20
 
@@ -52,13 +52,15 @@ const { sql } = await ask({
52
52
 
53
53
  ## CLI
54
54
 
55
+ `@askdb/rag` is a library. The command line for it is `askdb rag` in the [`askdb`](https://www.npmjs.com/package/askdb) package, which reads `rag.*` and `ai.embedding` from `askdb.config.*`:
56
+
55
57
  ```bash
56
- askdb-rag index fixtures/schemas/orders-users.schema --store file
57
- askdb-rag query fixtures/schemas/orders-users.schema \
58
+ npx askdb rag index fixtures/schemas/orders-users.schema --store file
59
+ npx askdb rag query fixtures/schemas/orders-users.schema \
58
60
  --question "How much revenue did we make last month?"
59
61
  ```
60
62
 
61
- The default CLI embedder is a deterministic mock for smoke tests. Use `--embedder openai` with `OPENAI_API_KEY` for real embeddings.
63
+ See the [CLI reference](https://askdb.tools/reference/cli/#askdb-rag) for every flag. The `askdb-rag` binary this package used to ship now only points at `askdb rag`, and is removed at 1.0.
62
64
 
63
65
  ## Public Surface
64
66
 
@@ -76,14 +78,16 @@ All exports are available from the root `@askdb/rag` import. Sub-path imports ar
76
78
  |---|---|---|
77
79
  | `createMemoryStore` | `@askdb/rag/stores/memory` | In-memory cosine store. Zero deps. |
78
80
  | `createFileStore` | `@askdb/rag/stores/file` | Binary embedding file + JSON metadata. |
79
- | `createPgvectorStore` | `@askdb/rag/stores/pgvector` | pgvector adapter with documented setup SQL. Requires `pg`. |
81
+ | `createPgvectorStore` | `@askdb/rag/stores/pgvector` | pgvector adapter with documented setup SQL. Requires `pg`. Needs `dimensions` only to create its table; `ensureSchema()` refuses an existing table of another width with a `PgvectorDimensionMismatchError`. |
80
82
 
81
83
  ### Embedders
82
84
 
83
85
  | Root import | Sub-path import | Description |
84
86
  |---|---|---|
85
87
  | `createAiSdkEmbedder` | `@askdb/rag/embedders/ai-sdk` | Generic AI SDK `EmbeddingModel` adapter. Requires `ai`. |
86
- | `createOpenAiEmbedder` | `@askdb/rag/embedders/openai` | **Deprecated.** OpenAI convenience helper. Use `createAiSdkEmbedder` with an `@askdb/ai-openai` model or the `@askdb/ai` registry instead. Removed in 1.0. |
88
+ | `detectEmbeddingDimensions` | — | Learns an embedder's vector width by embedding one short text. Use it for a new pgvector table instead of hard-coding a width. |
89
+ | `aiSdkEmbedderId` | `@askdb/rag/embedders/ai-sdk` | The `embedderId` Studio and `askdb rag` record for an `ai.embedding` model (`ai-sdk:<provider>:<model>:<dimensions or default>`), so an index built by one is accepted by the other. |
90
+ | `createOpenAiEmbedder` | `@askdb/rag/embedders/openai` | **Deprecated.** OpenAI convenience helper. Use `createAiSdkEmbedder` with an AI SDK embedding model (e.g. from `@ai-sdk/openai`) or one built by the `@askdb/ai` registry instead. Removed in 1.0. |
87
91
 
88
92
  ### Import examples
89
93
 
@@ -101,7 +105,7 @@ import { createMemoryStore } from "@askdb/rag/stores/memory";
101
105
  import { createFileStore } from "@askdb/rag/stores/file";
102
106
  import { createPgvectorStore } from "@askdb/rag/stores/pgvector";
103
107
  import { createAiSdkEmbedder } from "@askdb/rag/embedders/ai-sdk";
104
- // Recommended: bring your own model via @askdb/ai-openai + createAiSdkEmbedder
108
+ // Recommended: bring your own embedding model (e.g. @ai-sdk/openai, or @askdb/ai's registry) + createAiSdkEmbedder
105
109
  // import { createOpenAiEmbedder } from "@askdb/rag/embedders/openai"; // deprecated, removed in 1.0
106
110
  ```
107
111
 
package/bin/askdb-rag.js CHANGED
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import "../dist/bin.js";
2
+ // Deprecated: the RAG CLI moved into the `askdb` package. Removed before 1.0.
3
+ // Names only the command: the other arguments can hold a connection string's password.
4
+ const command = ["index", "query", "setup-store"].includes(process.argv[2]) ? ` ${process.argv[2]}` : "";
5
+ process.stderr.write(
6
+ "askdb-rag has moved: use `npx askdb rag" + command + "` (see `npx askdb rag --help` for its flags).\n" +
7
+ "`askdb rag` reads rag.store, rag.storeConfig and ai.embedding from askdb.config.*; flags still override.\n",
8
+ );
9
+ process.exit(1);
@@ -11,7 +11,11 @@ import type { ChunkerSources } from "./sources.js";
11
11
  export type ChunkStats = {
12
12
  totalChunks: number;
13
13
  byType: Record<Chunk["type"], number>;
14
- /** Describable-layer chunks dropped because the source content references a sensitive column/table. */
14
+ /**
15
+ * Chunks dropped, plus chunks emitted with part of their describable text
16
+ * (a description, alias, primary entity, enum values, or note) dropped,
17
+ * because the content belongs to or mentions a sensitive column/table.
18
+ */
15
19
  sensitiveExcluded: number;
16
20
  /** Describable-layer chunks emitted **only because** `includeSensitiveDescribable: true` was set. */
17
21
  sensitiveIncluded: number;
@@ -1 +1 @@
1
- {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../../src/chunker/chunker.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAEL,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;IACtC,uGAAuG;IACvG,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qGAAqG;IACrG,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,qCAAqC;AACrC,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAkNb"}
1
+ {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../../src/chunker/chunker.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAEL,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAGnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qGAAqG;IACrG,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,qCAAqC;AACrC,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAqQb"}
@@ -1,4 +1,6 @@
1
+ import { createMentionMatcher } from "@askdb/core";
1
2
  import { DEFAULT_CHUNK_MAX_CHARS, } from "./options.js";
3
+ import { chunkId } from "./ids.js";
2
4
  /**
3
5
  * Deterministic chunker for a Schema v2 artifact.
4
6
  *
@@ -30,22 +32,34 @@ export function chunkSchema(sources, options = {}) {
30
32
  sensitiveIncluded: 0,
31
33
  };
32
34
  const { schema, tables: tableMarkdowns, concepts } = sources;
35
+ // Names whose mention marks describable text as sensitive, schema-wide
36
+ // (ADR 0017): every table's text, concept, and tenant policy section is
37
+ // checked against them.
38
+ const sensitiveMentions = createMentionMatcher(collectSensitiveNames(schema));
33
39
  for (const table of schema.tables) {
34
40
  if (table.tracked === false) {
35
41
  continue;
36
42
  }
37
43
  const md = tableMarkdowns[table.id];
38
- const sensitiveColumnNames = table.columns
39
- .filter((c) => c.sensitive)
40
- .map((c) => c.name);
41
44
  if (table.sensitive && !includeSensitive) {
42
45
  stats.sensitiveExcluded++;
43
46
  }
44
47
  else {
45
- chunks.push(buildTableChunk(table, schema.schemaId, includeSensitive, schema));
48
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
49
+ const tableChunk = buildTableChunk(table, schema.schemaId, includeSensitive, schema, filter);
50
+ // A non-sensitive table whose description/aliases/column headlines
51
+ // mention a sensitive column is only embedded verbatim in opt-in mode.
52
+ if (filter.dropped)
53
+ stats.sensitiveExcluded++;
54
+ // Opt-in: a sensitive table's chunk is emitted only because of it.
55
+ if (table.sensitive || filter.included)
56
+ stats.sensitiveIncluded++;
57
+ chunks.push(tableChunk);
46
58
  }
47
59
  // Column chunks — one per column. Identifier + type always; describable
48
- // fields only when not sensitive (or when opted in).
60
+ // fields only when not sensitive (or when opted in). A non-sensitive
61
+ // column whose describable fields name a sensitive column is treated
62
+ // like a sensitive column's describable layer.
49
63
  for (const col of table.columns) {
50
64
  const colNote = readColumnNote(md, col.name);
51
65
  const colSensitive = col.sensitive || table.sensitive;
@@ -53,27 +67,46 @@ export function chunkSchema(sources, options = {}) {
53
67
  stats.sensitiveExcluded++;
54
68
  continue;
55
69
  }
56
- else if (colSensitive && includeSensitive) {
57
- if (col.description || (col.aliases && col.aliases.length) || (col.enum && col.enum.length) || colNote) {
58
- stats.sensitiveIncluded++;
59
- }
70
+ const describable = columnDescribableTexts(col, colNote);
71
+ if (colSensitive) {
72
+ // Opt-in mode: the default leaves this chunk out entirely.
73
+ stats.sensitiveIncluded++;
74
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, true, true));
75
+ continue;
76
+ }
77
+ const describableMentionsSensitive = describable.some((text) => sensitiveMentions.mentionsAny(text));
78
+ if (describableMentionsSensitive && !includeSensitive) {
79
+ // Keep the identifier + type; drop the whole describable layer.
80
+ stats.sensitiveExcluded++;
81
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, false, false));
82
+ continue;
60
83
  }
61
- chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, includeSensitive));
84
+ if (describableMentionsSensitive)
85
+ stats.sensitiveIncluded++;
86
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, true, describableMentionsSensitive));
62
87
  }
63
88
  // CQL chunk — excluded entirely if the body mentions a sensitive column by name.
64
89
  if (table.commonQueryLanguage) {
65
- const mentionsSensitive = mentionsAnyName(table.commonQueryLanguage, sensitiveColumnNames);
90
+ const mentionsSensitive = sensitiveMentions.mentionsAny(table.commonQueryLanguage);
66
91
  const tableLevelSensitive = table.sensitive;
67
92
  const skip = (tableLevelSensitive || mentionsSensitive) && !includeSensitive;
93
+ // Counted per chunk: a long body splits into several.
94
+ const parts = splitLong(table.commonQueryLanguage, maxChars);
68
95
  if (skip) {
69
- stats.sensitiveExcluded++;
96
+ stats.sensitiveExcluded += parts.length;
70
97
  }
71
98
  else {
72
- if (tableLevelSensitive || mentionsSensitive) {
73
- stats.sensitiveIncluded++;
74
- }
75
- for (const part of splitLong(table.commonQueryLanguage, maxChars)) {
76
- chunks.push(buildCqlChunk(table, part.text, part.suffix, schema.schemaId, tableLevelSensitive || mentionsSensitive));
99
+ // The heading repeats the table's aliases; one naming a sensitive
100
+ // column is dropped like it is from the table chunk.
101
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
102
+ const aliases = (table.aliases ?? []).filter((a) => filter.allow(a));
103
+ const sensitive = tableLevelSensitive || mentionsSensitive || filter.included;
104
+ if (filter.dropped)
105
+ stats.sensitiveExcluded += parts.length;
106
+ if (sensitive)
107
+ stats.sensitiveIncluded += parts.length;
108
+ for (const part of parts) {
109
+ chunks.push(buildCqlChunk(table, aliases, part.text, part.suffix, schema.schemaId, sensitive));
77
110
  }
78
111
  }
79
112
  }
@@ -81,8 +114,8 @@ export function chunkSchema(sources, options = {}) {
81
114
  if (md) {
82
115
  const questions = extractExampleQuestions(md);
83
116
  const businessContext = md.sections["Business context"]?.trim() ?? "";
84
- const questionsMentionSensitive = questions.some((q) => mentionsAnyName(q, sensitiveColumnNames));
85
- const bizMentionsSensitive = mentionsAnyName(businessContext, sensitiveColumnNames);
117
+ const questionsMentionSensitive = questions.some((q) => sensitiveMentions.mentionsAny(q));
118
+ const bizMentionsSensitive = sensitiveMentions.mentionsAny(businessContext);
86
119
  // Per-table sensitive gate: if table is sensitive OR mentions exist, skip
87
120
  // the *whole* describable layer for that table by default. Authoring rule
88
121
  // in the contract notes the chunker excludes whole chunks rather than
@@ -93,25 +126,34 @@ export function chunkSchema(sources, options = {}) {
93
126
  stats.sensitiveExcluded += questions.length;
94
127
  }
95
128
  else {
96
- if (table.sensitive || questionsMentionSensitive) {
129
+ // Every question heading repeats the primary entity; one naming a
130
+ // sensitive column is dropped like it is from the table chunk.
131
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
132
+ const primaryEntity = filter.allow(table.primaryEntity) ? table.primaryEntity : undefined;
133
+ if (filter.dropped)
134
+ stats.sensitiveExcluded += questions.length;
135
+ // The questions are gated together, so in opt-in mode one naming a
136
+ // sensitive column makes every question chunk of the table sensitive.
137
+ const questionsSensitive = table.sensitive || questionsMentionSensitive || filter.included;
138
+ if (questionsSensitive)
97
139
  stats.sensitiveIncluded += questions.length;
98
- }
99
140
  questions.forEach((q, i) => {
100
- chunks.push(buildQuestionChunk(table, q, i + 1, schema.schemaId, table.sensitive || mentionsAnyName(q, sensitiveColumnNames)));
141
+ chunks.push(buildQuestionChunk(table, primaryEntity, q, i + 1, schema.schemaId, questionsSensitive));
101
142
  });
102
143
  }
103
144
  }
104
145
  // Business-context chunks are part of the describable layer; same gate.
105
146
  if (businessContext) {
106
147
  const skipBiz = (table.sensitive || bizMentionsSensitive) && !includeSensitive;
148
+ const parts = splitLong(businessContext, maxChars);
107
149
  if (skipBiz) {
108
- stats.sensitiveExcluded++;
150
+ stats.sensitiveExcluded += parts.length;
109
151
  }
110
152
  else {
111
153
  if (table.sensitive || bizMentionsSensitive) {
112
- stats.sensitiveIncluded++;
154
+ stats.sensitiveIncluded += parts.length;
113
155
  }
114
- for (const part of splitLong(businessContext, maxChars)) {
156
+ for (const part of parts) {
115
157
  chunks.push(buildBusinessContextChunk(table, part.text, part.suffix, schema.schemaId, table.sensitive || bizMentionsSensitive));
116
158
  }
117
159
  }
@@ -134,30 +176,45 @@ export function chunkSchema(sources, options = {}) {
134
176
  }
135
177
  }
136
178
  }
137
- // Concept chunks. Concepts that link to sensitive columns include link
138
- // metadata but their description is filtered if it names a sensitive col.
179
+ // Concept chunks. A concept that links to a sensitive column/table, or
180
+ // whose label/synonyms/description names a sensitive column (matched
181
+ // case-insensitively, like @askdb/enrich), is excluded by default.
139
182
  if (concepts?.frontmatter.concepts) {
140
- const allSensitiveColumnNames = collectSensitiveColumnNames(schema);
141
183
  for (const concept of concepts.frontmatter.concepts) {
142
- const conceptResult = buildConceptChunk(concept, schema.schemaId, schema, allSensitiveColumnNames, includeSensitive);
143
- if (conceptResult.excluded) {
184
+ const conceptResult = buildConceptChunk(concept, schema.schemaId, schema, sensitiveMentions);
185
+ if (conceptResult.sensitive && !includeSensitive) {
144
186
  stats.sensitiveExcluded++;
145
- if (includeSensitive) {
146
- stats.sensitiveIncluded++;
147
- chunks.push(conceptResult.chunk);
148
- }
149
- }
150
- else {
151
- if (conceptResult.sensitive)
152
- stats.sensitiveIncluded++;
153
- chunks.push(conceptResult.chunk);
187
+ continue;
154
188
  }
189
+ if (conceptResult.sensitive)
190
+ stats.sensitiveIncluded++;
191
+ chunks.push(conceptResult.chunk);
155
192
  }
156
193
  }
157
- // Tenant policy body chunks — one per H2 section.
194
+ // Tenant policy body chunks — one per H2 section. The policy is
195
+ // schema-level prose, so like concepts it is checked against every
196
+ // sensitive column in the schema; a section naming one is excluded by
197
+ // default.
158
198
  if (sources.tenantPolicy) {
159
- for (const chunk of buildTenantPolicyChunks(sources.tenantPolicy, schema.schemaId, maxChars)) {
160
- chunks.push(chunk);
199
+ for (const section of tenantPolicySections(sources.tenantPolicy)) {
200
+ const sensitive = sensitiveMentions.mentionsAny(section.body);
201
+ const parts = splitLong(section.body, maxChars);
202
+ if (sensitive && !includeSensitive) {
203
+ stats.sensitiveExcluded += parts.length;
204
+ continue;
205
+ }
206
+ if (sensitive)
207
+ stats.sensitiveIncluded += parts.length;
208
+ for (const part of parts) {
209
+ chunks.push({
210
+ id: chunkId(schema.schemaId, `tenant-policy#${section.slug}${part.suffix}`),
211
+ type: "tenant-policy",
212
+ text: `${section.heading}\n${part.text}`,
213
+ schemaId: schema.schemaId,
214
+ refs: [],
215
+ sensitive,
216
+ });
217
+ }
161
218
  }
162
219
  }
163
220
  // Final ordering: stable by id (string compare). The chunker itself emits in
@@ -171,16 +228,20 @@ export function chunkSchema(sources, options = {}) {
171
228
  return { chunks, stats };
172
229
  }
173
230
  // ---------- chunk builders ----------
174
- function buildTableChunk(table, schemaId, includeSensitive, schema) {
231
+ function buildTableChunk(table, schemaId, includeSensitive, schema, filter) {
175
232
  const lines = [];
176
233
  const qualified = `${table.schema}.${table.name}`;
234
+ // Describable text that names a sensitive column is dropped by default and
235
+ // flags the chunk as sensitive when kept in opt-in mode.
236
+ const { allow } = filter;
177
237
  lines.push(`# ${qualified}`);
178
238
  if (!table.sensitive) {
179
- if (table.description)
239
+ if (allow(table.description))
180
240
  lines.push(table.description);
181
- if (table.aliases?.length)
182
- lines.push(`Aliases: ${table.aliases.join(", ")}`);
183
- if (table.primaryEntity)
241
+ const aliases = (table.aliases ?? []).filter((a) => allow(a));
242
+ if (aliases.length)
243
+ lines.push(`Aliases: ${aliases.join(", ")}`);
244
+ if (allow(table.primaryEntity))
184
245
  lines.push(`Primary entity: ${table.primaryEntity}`);
185
246
  }
186
247
  if (table.relationships?.length) {
@@ -206,31 +267,36 @@ function buildTableChunk(table, schemaId, includeSensitive, schema) {
206
267
  flags.push("PK");
207
268
  flags.push(c.nullable ? "NULL" : "NOT NULL");
208
269
  const tag = c.sensitive || table.sensitive ? " (sensitive)" : "";
209
- const desc = !table.sensitive && !c.sensitive && c.description
270
+ const desc = !table.sensitive && !c.sensitive && allow(c.description)
210
271
  ? ` — ${c.description}`
211
272
  : "";
212
273
  lines.push(`- ${c.name} ${c.type} (${flags.join(" ")})${tag}${desc}`);
213
274
  }
214
275
  return {
215
- id: `chunk:${table.id}`,
276
+ id: chunkId(schemaId, table.id),
216
277
  type: "table",
217
278
  text: lines.join("\n").trim(),
218
279
  schemaId,
219
280
  refs: [table.id, ...referencedColumns],
220
- sensitive: table.sensitive,
281
+ sensitive: table.sensitive || filter.included,
221
282
  };
222
283
  }
223
- function buildColumnChunk(table, col, schemaId, columnNote, includeSensitive) {
224
- const sensitive = col.sensitive || table.sensitive;
284
+ /**
285
+ * @param includeDescribable emit description / aliases / enum values / note.
286
+ * @param describableSensitive the emitted describable layer references
287
+ * sensitive content (opt-in mode only); sets `chunk.sensitive`.
288
+ */
289
+ function buildColumnChunk(table, col, schemaId, columnNote, includeDescribable, describableSensitive) {
290
+ const columnSensitive = col.sensitive || table.sensitive;
225
291
  const flags = [];
226
292
  if (col.primaryKey)
227
293
  flags.push("PK");
228
294
  flags.push(col.nullable ? "NULL" : "NOT NULL");
229
295
  const lines = [];
230
296
  const qualified = `${table.schema}.${table.name}.${col.name}`;
231
- lines.push(`Column: ${qualified} ${col.type} (${flags.join(" ")})${sensitive ? " (sensitive)" : ""}`);
297
+ lines.push(`Column: ${qualified} ${col.type} (${flags.join(" ")})${columnSensitive ? " (sensitive)" : ""}`);
232
298
  lines.push(`Id: ${col.id}`);
233
- if (!sensitive || includeSensitive) {
299
+ if (includeDescribable) {
234
300
  if (col.description)
235
301
  lines.push(col.description);
236
302
  if (col.aliases?.length)
@@ -241,20 +307,30 @@ function buildColumnChunk(table, col, schemaId, columnNote, includeSensitive) {
241
307
  lines.push(`Note: ${columnNote}`);
242
308
  }
243
309
  return {
244
- id: `chunk:${col.id}`,
310
+ id: chunkId(schemaId, col.id),
245
311
  type: "column",
246
312
  text: lines.join("\n").trim(),
247
313
  schemaId,
248
314
  refs: [table.id, col.id],
249
- sensitive,
315
+ sensitive: columnSensitive || (includeDescribable && describableSensitive),
250
316
  };
251
317
  }
252
- function buildCqlChunk(table, body, suffix, schemaId, sensitive) {
253
- const aliasNote = table.aliases?.length
254
- ? ` (also: ${table.aliases.join(", ")})`
255
- : "";
318
+ function columnDescribableTexts(col, columnNote) {
319
+ const out = [];
320
+ if (col.description)
321
+ out.push(col.description);
322
+ if (col.aliases?.length)
323
+ out.push(...col.aliases);
324
+ if (col.enum?.length)
325
+ out.push(...col.enum.map(String));
326
+ if (columnNote)
327
+ out.push(columnNote);
328
+ return out;
329
+ }
330
+ function buildCqlChunk(table, aliases, body, suffix, schemaId, sensitive) {
331
+ const aliasNote = aliases.length ? ` (also: ${aliases.join(", ")})` : "";
256
332
  const text = `# ${table.schema}.${table.name}${aliasNote} — common query language\n${body.trim()}`;
257
- const id = `chunk:${table.id}#cql${suffix}`;
333
+ const id = chunkId(schemaId, `${table.id}#cql${suffix}`);
258
334
  return {
259
335
  id,
260
336
  type: "cql",
@@ -264,11 +340,11 @@ function buildCqlChunk(table, body, suffix, schemaId, sensitive) {
264
340
  sensitive,
265
341
  };
266
342
  }
267
- function buildQuestionChunk(table, question, index, schemaId, sensitive) {
268
- const entity = table.primaryEntity ? ` [${table.primaryEntity}]` : "";
343
+ function buildQuestionChunk(table, primaryEntity, question, index, schemaId, sensitive) {
344
+ const entity = primaryEntity ? ` [${primaryEntity}]` : "";
269
345
  const text = `# ${table.schema}.${table.name}${entity} — example question\n${question.trim()}`;
270
346
  return {
271
- id: `chunk:${table.id}#q:${index}`,
347
+ id: chunkId(schemaId, `${table.id}#q:${index}`),
272
348
  type: "question",
273
349
  text,
274
350
  schemaId,
@@ -279,7 +355,7 @@ function buildQuestionChunk(table, question, index, schemaId, sensitive) {
279
355
  function buildBusinessContextChunk(table, body, suffix, schemaId, sensitive) {
280
356
  const text = `# ${table.schema}.${table.name} — business context\n${body.trim()}`;
281
357
  return {
282
- id: `chunk:${table.id}#biz${suffix}`,
358
+ id: chunkId(schemaId, `${table.id}#biz${suffix}`),
283
359
  type: "table",
284
360
  text,
285
361
  schemaId,
@@ -295,9 +371,13 @@ function buildRelationshipChunk(table, rel, schema, schemaId) {
295
371
  const fromName = fromTable ? `${fromTable.schema}.${fromTable.name}` : fromCol.tableId;
296
372
  const toName = toTable ? `${toTable.schema}.${toTable.name}` : toCol.tableId;
297
373
  const text = `Relationship: ${fromName}.${fromCol.column} references ${toName}.${toCol.column}`;
298
- const sensitive = (fromTable?.sensitive ?? false) || (toTable?.sensitive ?? false);
374
+ // Sensitive when either side's table *or column* is sensitive.
375
+ const sensitive = (fromTable?.sensitive ?? false) ||
376
+ (toTable?.sensitive ?? false) ||
377
+ isSensitiveId(rel.from, schema) ||
378
+ isSensitiveId(rel.to, schema);
299
379
  return {
300
- id: `chunk:${rel.from}->${rel.to}`,
380
+ id: chunkId(schemaId, `${rel.from}->${rel.to}`),
301
381
  type: "relationship",
302
382
  text,
303
383
  schemaId,
@@ -305,12 +385,15 @@ function buildRelationshipChunk(table, rel, schema, schemaId) {
305
385
  sensitive,
306
386
  };
307
387
  }
308
- function buildConceptChunk(concept, schemaId, schema, allSensitiveColumnNames, includeSensitive) {
388
+ function buildConceptChunk(concept, schemaId, schema, sensitiveMentions) {
309
389
  const links = (concept.links ?? []).filter((id) => !isUntrackedId(id, schema));
310
390
  const linkedSensitive = links.some((id) => isSensitiveId(id, schema));
311
- const descriptionMentionsSensitive = concept.description
312
- ? mentionsAnyName(concept.description, allSensitiveColumnNames)
313
- : false;
391
+ const textMentionsSensitive = [
392
+ concept.label,
393
+ ...(concept.synonyms ?? []),
394
+ concept.description ?? "",
395
+ ].some((text) => sensitiveMentions.mentionsAny(text));
396
+ const sensitive = linkedSensitive || textMentionsSensitive;
314
397
  const lines = [];
315
398
  lines.push(`# Concept: ${concept.label}`);
316
399
  lines.push(`Id: ${concept.id}`);
@@ -318,56 +401,65 @@ function buildConceptChunk(concept, schemaId, schema, allSensitiveColumnNames, i
318
401
  lines.push(`Synonyms: ${concept.synonyms.join(", ")}`);
319
402
  if (links.length)
320
403
  lines.push(`Links: ${links.join(", ")}`);
321
- const excludeForSensitive = (linkedSensitive || descriptionMentionsSensitive) && !includeSensitive;
322
- if (concept.description && (!descriptionMentionsSensitive || includeSensitive)) {
404
+ if (concept.description)
323
405
  lines.push(concept.description);
324
- }
325
406
  return {
326
407
  chunk: {
327
- id: `chunk:${concept.id}`,
408
+ id: chunkId(schemaId, concept.id),
328
409
  type: "concept",
329
410
  text: lines.join("\n").trim(),
330
411
  schemaId,
331
412
  refs: [concept.id, ...links],
332
- sensitive: linkedSensitive || descriptionMentionsSensitive,
413
+ sensitive,
333
414
  },
334
- sensitive: linkedSensitive || descriptionMentionsSensitive,
335
- excluded: excludeForSensitive,
415
+ sensitive,
336
416
  };
337
417
  }
338
- function buildTenantPolicyChunks(tenantPolicy, schemaId, maxChars) {
339
- const chunks = [];
340
- const sections = tenantPolicy.sections;
341
- for (const [sectionName, sectionBody] of Object.entries(sections)) {
418
+ /** Tenant-policy sections to chunk: one per non-empty H2, else the whole body. */
419
+ function tenantPolicySections(tenantPolicy) {
420
+ const out = [];
421
+ for (const [sectionName, sectionBody] of Object.entries(tenantPolicy.sections)) {
342
422
  if (!sectionBody?.trim())
343
423
  continue;
344
- const slug = sectionName.toLowerCase().replace(/[^a-z0-9]+/g, "-");
345
- for (const part of splitLong(sectionBody.trim(), maxChars)) {
346
- chunks.push({
347
- id: `chunk:tenant-policy#${slug}${part.suffix}`,
348
- type: "tenant-policy",
349
- text: `# Tenant policy — ${sectionName}\n${part.text}`,
350
- schemaId,
351
- refs: [],
352
- sensitive: false,
353
- });
354
- }
424
+ out.push({
425
+ slug: sectionName.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
426
+ heading: `# Tenant policy — ${sectionName}`,
427
+ body: sectionBody.trim(),
428
+ });
355
429
  }
356
- if (chunks.length === 0 && tenantPolicy.body.trim()) {
357
- for (const part of splitLong(tenantPolicy.body.trim(), maxChars)) {
358
- chunks.push({
359
- id: `chunk:tenant-policy#body${part.suffix}`,
360
- type: "tenant-policy",
361
- text: `# Tenant policy\n${part.text}`,
362
- schemaId,
363
- refs: [],
364
- sensitive: false,
365
- });
366
- }
430
+ if (out.length === 0 && tenantPolicy.body.trim()) {
431
+ out.push({ slug: "body", heading: "# Tenant policy", body: tenantPolicy.body.trim() });
367
432
  }
368
- return chunks;
433
+ return out;
434
+ }
435
+ /**
436
+ * Per-chunk gate for describable text (descriptions, aliases, primary
437
+ * entity) that the chunker drops piece by piece instead of excluding the
438
+ * whole chunk.
439
+ */
440
+ function describableFilter(sensitiveMentions, includeSensitive) {
441
+ let dropped = false;
442
+ let included = false;
443
+ return {
444
+ allow(text) {
445
+ if (!text)
446
+ return false;
447
+ if (!sensitiveMentions.mentionsAny(text))
448
+ return true;
449
+ if (includeSensitive)
450
+ included = true;
451
+ else
452
+ dropped = true;
453
+ return includeSensitive;
454
+ },
455
+ get dropped() {
456
+ return dropped;
457
+ },
458
+ get included() {
459
+ return included;
460
+ },
461
+ };
369
462
  }
370
- // ---------- helpers ----------
371
463
  function readColumnNote(md, columnName) {
372
464
  const body = md?.sections["Column notes"];
373
465
  if (!body)
@@ -400,20 +492,6 @@ function extractExampleQuestions(md) {
400
492
  }
401
493
  return out;
402
494
  }
403
- function mentionsAnyName(text, names) {
404
- if (!text || names.length === 0)
405
- return false;
406
- for (const name of names) {
407
- // Word-boundary match (also matches when wrapped in backticks).
408
- const pattern = new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegex(name)}([^a-zA-Z0-9_]|$)`);
409
- if (pattern.test(text))
410
- return true;
411
- }
412
- return false;
413
- }
414
- function escapeRegex(s) {
415
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
416
- }
417
495
  /** Split a long body on paragraph boundaries; suffix is `""` for single-chunk, `#bc:N` (1-indexed) otherwise. */
418
496
  function splitLong(body, maxChars) {
419
497
  const trimmed = body.trim();
@@ -451,15 +529,30 @@ function parseColumnId(columnId) {
451
529
  column: columnId.slice(hashIdx + 1),
452
530
  };
453
531
  }
454
- function collectSensitiveColumnNames(schema) {
455
- const names = new Set();
532
+ /**
533
+ * Names that mark text as mentioning a sensitive column, for the whole schema
534
+ * (ADR 0017; matching is the schema-v2 contract's mention rule). A column
535
+ * marked sensitive itself counts by its bare name; a column of a sensitive
536
+ * table that is sensitive only through it counts only qualified, since its
537
+ * bare name is often generic (`id`, `org_id`, `name`).
538
+ */
539
+ function collectSensitiveNames(schema) {
540
+ const names = [];
541
+ const bare = new Set();
456
542
  for (const t of schema.tables) {
457
543
  for (const c of t.columns) {
458
- if (c.sensitive || t.sensitive)
459
- names.add(c.name);
544
+ if (!c.sensitive && !t.sensitive)
545
+ continue;
546
+ if (t.sensitive && c.sensitiveFromTable === true) {
547
+ names.push({ table: t.name, column: c.name });
548
+ }
549
+ else if (!bare.has(c.name)) {
550
+ bare.add(c.name);
551
+ names.push(c.name);
552
+ }
460
553
  }
461
554
  }
462
- return Array.from(names);
555
+ return names;
463
556
  }
464
557
  function isSensitiveId(id, schema) {
465
558
  for (const t of schema.tables) {