@lunora/ai 1.0.0-alpha.55 → 1.0.0-alpha.57
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.
- package/dist/packem_shared/defineRag-Drf-Vn0B.mjs +8 -0
- package/dist/packem_shared/ragSyncTriggers-BY8QgX4d.mjs +1 -0
- package/dist/rag/index.d.mts +104 -1
- package/dist/rag/index.d.ts +104 -1
- package/dist/rag/index.mjs +1 -1
- package/package.json +2 -2
- package/dist/packem_shared/defineRag-B1Z_pMVl.mjs +0 -8
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import{LunoraError as b}from"@lunora/errors";import{tool as ee,jsonSchema as te,embed as ne}from"ai";import re from"./fixedWindowChunks-XJRXHEoz.mjs";import{contentHash as ae}from"./contentHash-BIn6ECP8.mjs";import oe from"./hybridRank-U6PmGuz1.mjs";const ie=8,se=async(e,s,m)=>{if(!Number.isInteger(s)||s<1)throw new RangeError("concurrentMap: `limit` must be a positive integer");if(e.length===0)return[];const x=Math.max(1,Math.min(s,e.length)),y=Array.from({length:e.length});let v=0,w=!1,N;const k=async()=>{for(;;){if(w)return;const l=v;if(v+=1,l>=e.length)return;try{y[l]=await m(e[l],l)}catch($){w||(w=!0,N=$);return}}},_=Array.from({length:x},()=>k());if(await Promise.all(_),w)throw N;return y},ce=1e3,ue=200,de=5,le=20,he=100,M=10*1024,me=2*1024,F="__ragChunk",P="__ragSource",E="__ragText",R="__ragHash",B="__ragChunks",A="__ragImportance",W="__ragModel",pe=new Set([F,B,R,A,W,P,E]),ge=(e,s,m)=>{const x=new TextEncoder().encode(JSON.stringify(e)).length;if(x<=M)return;const y=(typeof e[E]=="string"?new TextEncoder().encode(e[E]).length:0)*2>x?"lower `chunkSize` (it counts characters, not bytes — multibyte text costs up to 3 bytes each), or supply `textStore` to move chunk text out of metadata entirely":"attach less per-source `metadata`";throw new b("BAD_REQUEST",`@lunora/ai/rag: chunk ${String(s)} of "${m}" carries ${String(x)} bytes of metadata, over Vectorize's ${String(M)}-byte per-vector ceiling — ${y}`)},fe=/^[\w.-]{1,40}$/,H=e=>e===void 0?"":`${encodeURIComponent(e)}#`,I=(e,s,m)=>`${H(e)}${s}#${String(m)}`,O=(e,s)=>{const m=H(s),x=m!==""&&e.startsWith(m)?e.slice(m.length):e,y=x.lastIndexOf("#"),v=y===-1?Number.NaN:Number(x.slice(y+1));return y===-1||!Number.isInteger(v)||v<0?{chunkIndex:0,sourceId:x}:{chunkIndex:v,sourceId:x.slice(0,y)}},we=async e=>ae(new TextEncoder().encode(e)),K=e=>{if(!e)return;const s=Object.entries(e).filter(([m])=>!pe.has(m));return s.length>0?Object.fromEntries(s):void 0},V=new Set,xe=e=>{V.has(e)||(V.add(e),console.warn(`[@lunora/ai/rag] index "${e}" is used without a namespace — in a multi-tenant/sharded
|
|
2
|
+
app this shares one tenant's chunks (text included) with every other tenant, since
|
|
3
|
+
Vectorize indexes are account-global. Pass \`namespace\` (the shard/tenant key) on both
|
|
4
|
+
index() and retrieve(). Single-tenant apps suppress this via { allowSharedNamespace: true }.`))},ye=e=>e.map(s=>`[source:${s.sourceId}#${String(s.chunkIndex)}]
|
|
5
|
+
${s.text}`).join(`
|
|
6
|
+
|
|
7
|
+
`),be=(e,s)=>{if(typeof e=="object")return e;if(s===void 0)throw new b("INTERNAL","@lunora/ai/rag: `embeddingModel` is a Workers AI model id (or omitted) but the bound context has no `ai` (env.AI). Pass an AI SDK EmbeddingModel object to embed without Workers AI, or bind a context whose `ctx.ai` is wired.");return s.embeddingModel(e)},ve=e=>{const s=e.modelId;return typeof s=="string"&&s.length>0?s:void 0},ke=e=>{if(!(typeof e!="object"||e===null)){for(const s of Object.values(e))if(typeof s=="object"&&s!==null){const{cost:m}=s;if(typeof m=="number"&&Number.isFinite(m))return m}}},$e=e=>{if(typeof e.index!="string"||e.index.length===0)throw new b("BAD_REQUEST","@lunora/ai/rag: `index` must be a non-empty Vectorize index name");const s=e.chunkSize??ce,m=e.chunkOverlap??ue;if(!Number.isInteger(s)||s<1)throw new b("BAD_REQUEST","@lunora/ai/rag: `chunkSize` must be a positive integer");if(!Number.isInteger(m)||m<0||m>=s)throw new b("BAD_REQUEST","@lunora/ai/rag: `chunkOverlap` must be a non-negative integer smaller than `chunkSize`");const x=M-me;if(!e.chunk&&!e.textStore&&s>x)throw new b("BAD_REQUEST",`@lunora/ai/rag: \`chunkSize\` of ${String(s)} leaves no room under Vectorize's ${String(M)}-byte metadata limit, which also carries this chunk's bookkeeping and any \`metadata\` you attach — keep it under ${String(x)}, or supply \`textStore\` to move chunk text out of metadata entirely`);const y=e.topK??de;if(!Number.isInteger(y)||y<1)throw new b("BAD_REQUEST","@lunora/ai/rag: `topK` must be a positive integer");if(e.embeddingModelVersion!==void 0&&!fe.test(e.embeddingModelVersion))throw new b("BAD_REQUEST",'@lunora/ai/rag: `embeddingModelVersion` must match /^[A-Za-z0-9._-]{1,40}$/ (a short, stable tag like "bge-v1.5")');const v=e.chunk??(l=>re(l,s,m)),{textStore:w}=e,N=w?he:le,k=e.embeddingModelVersion,_=l=>k===void 0?l:l===void 0?k:`${k}::${l}`;return l=>{let $;const D=typeof l.trace=="function"?l.trace:void 0,U=async t=>{$??=be(e.embeddingModel,l.ai);const n=$,o=async d=>{const{embedding:a,providerMetadata:u,usage:h}=await ne({model:n,value:t});if(d!==void 0){const c=h.tokens;typeof c=="number"&&Number.isFinite(c)&&d.setAttribute("gen_ai.usage.input_tokens",c);const p=ke(u);p!==void 0&&d.setAttribute("gen_ai.usage.cost",p)}return a};if(D===void 0)return o();const i=ve(n),r=typeof l.conversationId=="string"&&l.conversationId.length>0?l.conversationId:void 0;return D("ai.embed",(d,a)=>o(a),{"gen_ai.operation.name":"embeddings",...i===void 0?{}:{"gen_ai.request.model":i},...r===void 0?{}:{"gen_ai.conversation.id":r}})},T=t=>{if(t===void 0){if(e.requireNamespace)throw new b("BAD_REQUEST",`@lunora/ai/rag: index "${e.index}" requires a namespace (requireNamespace is set) — pass the tenant/shard key on index()/retrieve()/remove()`);e.allowSharedNamespace||xe(e.index)}},q=async(t,n)=>{const[o]=await l.vectors.getByIds(e.index,[I(n,t,0)],n),i=o?.metadata?.[R],r=o?.metadata?.[B];return{chunks:typeof r=="number"&&Number.isInteger(r)&&r>0?r:void 0,hash:typeof i=="string"?i:void 0}},C=async(t,n,o,i)=>{const r=Array.from({length:o-n},(d,a)=>I(i,t,n+a));r.length!==0&&(await l.vectors.deleteByIds(e.index,r,i),await w?.remove?.(r,{namespace:i}),await e.lexicalStore?.remove?.(r,{namespace:i}))},L=async t=>{if(T(t.namespace),t.importance!==void 0&&(typeof t.importance!="number"||t.importance<0||t.importance>1))throw new b("BAD_REQUEST","@lunora/ai/rag: `importance` must be a number in [0, 1]");const n=_(t.namespace),o=await we(t.text),i=await q(t.id,n);if(i.hash===o&&i.chunks!==void 0)return{chunks:i.chunks,ids:Array.from({length:i.chunks},(a,u)=>I(n,t.id,u)),unchanged:!0};const r=v(t.text),d=r.map((a,u)=>I(n,t.id,u));if(r.length===0&&t.allowEmptySources===!1)throw new b("BAD_REQUEST",`@lunora/ai/rag: source "${t.id}" produced zero chunks — set allowEmptySources: true to allow this`);if(r.length>0){const a=r.map((u,h)=>({chunkIndex:h,id:d[h],sourceId:t.id,text:u}));w&&await w.put(a,{namespace:n}),e.lexicalStore&&await e.lexicalStore.index(a,{namespace:n})}return await se(r,ie,async(a,u)=>{const h=d[u],c={...t.metadata,[F]:u,[P]:t.id};w||(c[E]=a),t.importance!==void 0&&(c[A]=t.importance),u===0&&(c[R]=o,c[B]=r.length,k!==void 0&&(c[W]=k)),ge(c,u,t.id),await l.vectors.upsert(e.index,{embed:U,id:h,input:a,metadata:c,namespace:n}),t.onChunk?.({chunkIndex:u,id:h,text:a,total:r.length})}),i.chunks!==void 0&&i.chunks>r.length&&await C(t.id,r.length,i.chunks,n),{chunks:r.length,ids:d,unchanged:!1}},J=async t=>{T(t.namespace);const n=_(t.namespace),o=(await q(t.id,n)).chunks??1;await C(t.id,0,o,n)},j=async(t,n)=>{const o=new Map;if(t.length===0)return o;if(w){const r=await w.getMany(t,{namespace:n});for(const[d,a]of t.entries()){const u=r[d];typeof u=="string"&&o.set(a,u)}return o}const i=await l.vectors.getByIds(e.index,t,n);for(const r of i){const d=r.metadata?.[E];typeof d=="string"&&o.set(r.id,d)}return o},X=async(t,n,o)=>{const i=n?.chunkContext?.before??0,r=n?.chunkContext?.after??0;if(i===0&&r===0)return t;if(!Number.isInteger(i)||i<0||!Number.isInteger(r)||r<0)throw new b("BAD_REQUEST","@lunora/ai/rag: `chunkContext.before`/`chunkContext.after` must be non-negative integers");const d=new Map(t.map(c=>[c.id,c.text])),a=new Set;for(const c of t)for(let p=-i;p<=r;p+=1){const f=c.chunkIndex+p,g=I(o,c.sourceId,f);p!==0&&f>=0&&!d.has(g)&&a.add(g)}const u=await j([...a],o),h=(c,p)=>{const f=I(o,c,p);return d.get(f)??u.get(f)};return t.map(c=>{const p=[];for(let f=-i;f<=r;f+=1){const g=f===0?c.text:h(c.sourceId,c.chunkIndex+f);g!==void 0&&p.push(g)}return{...c,text:p.join(`
|
|
8
|
+
`)}})},Y=t=>{if(typeof t=="string"){const n=e.filters?.[t];if(!n)throw new b("NOT_FOUND",`@lunora/ai/rag: unknown named filter "${t}" — must be one of the keys declared in RagConfig.filters`);return n.filter}return t},Z=(t,n)=>t.matches.map(o=>{const i=o.metadata??{},r=O(o.id,n),d=i[E],a=i[A],u=typeof a=="number"&&a>=0&&a<=1?a:1;return{chunkIndex:r.chunkIndex,id:o.id,importance:u,metadata:K(i),score:o.score*u,sourceId:r.sourceId,text:typeof d=="string"?d:""}}),G=async(t,n)=>{if(!w)return t;const o=t.map(a=>a.id),[i,r]=await Promise.all([j(o,n),l.vectors.getByIds(e.index,o,n)]),d=new Map(r.map(a=>[a.id,a.metadata]));return t.flatMap(a=>{const u=i.get(a.id);if(u===void 0)return[];const h=d.get(a.id),c=h?.[A],p=typeof c=="number"&&c>=0&&c<=1?c:a.importance,f=(a.importance===0?0:a.score/a.importance)*p;return[{...a,importance:p,metadata:K(h)??a.metadata,score:f,text:u}]})},z=async(t,n)=>{T(n?.namespace);const o=_(n?.namespace),i=Y(n?.filter),r=e.rlsFilter?await e.rlsFilter(l.auth):void 0,d=r?{...i,...r}:i,a=Math.min(n?.topK??y,N),u=await l.vectors.query(e.index,{embed:U,filter:d,input:t,namespace:o,returnMetadata:w?"indexed":"all",topK:a});let h=await G(Z(u,o),o);const c=n?.minScore;if(c!==void 0&&(h=h.filter(g=>g.score>=c)),e.lexicalStore){const g=(await e.lexicalStore.search(t,{filter:d,namespace:o,topK:e.lexicalTopK??a})).map(S=>{const Q=O(S.id,o);return{chunkIndex:Q.chunkIndex,id:S.id,importance:1,metadata:void 0,score:S.score,sourceId:Q.sourceId,text:S.text}});h=[...oe(h,g)]}h.sort((g,S)=>S.score-g.score),h=[...await X(h,n,o)];const p=[],f=new Set;for(const g of h)f.has(g.sourceId)||(f.add(g.sourceId),p.push({id:g.sourceId,metadata:g.metadata,weight:g.importance}));return n?.onRetrieve?.({matches:h.length,query:t}),{chunks:h,context:ye(h),sources:p}};return{asTool:t=>ee({description:t?.description??`Search the "${e.index}" knowledge base for passages relevant to a natural-language query.`,execute:async({query:n})=>z(n,{namespace:t?.namespace,topK:t?.topK}),inputSchema:te({properties:{query:{description:"The natural-language search query.",type:"string"}},required:["query"],type:"object"})}),index:L,remove:J,retrieve:z}}};export{$e as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const f=t=>{const u=t.delayMs??0,r=(i,d)=>t.id===void 0?d:t.id(i),c=i=>i===void 0?void 0:t.text(i),o=async(i,d)=>{await i.scheduler.runAfter(u,t.action,d)};return{afterDelete:async(i,d)=>{const e=d.previous??d.doc;await o(i,{deleted:!0,id:e===void 0?d.id:r(e,d.id)})},afterInsert:async(i,d)=>{const e=c(d.doc);e===void 0||d.doc===void 0||await o(i,{id:r(d.doc,d.id),text:e})},afterUpdate:async(i,d)=>{if(d.doc===void 0)return;const e=c(d.doc),v=c(d.previous),a=r(d.doc,d.id),s=d.previous===void 0?a:r(d.previous,d.id);if(s!==a)await o(i,{deleted:!0,id:s});else if(e===v)return;await(e===void 0?o(i,{deleted:!0,id:a}):o(i,{id:a,text:e}))}}};export{f as ragSyncTriggers};
|
package/dist/rag/index.d.mts
CHANGED
|
@@ -555,4 +555,107 @@ declare const hybridRank: (vectorResults: ReadonlyArray<RetrievedChunk>, textRes
|
|
|
555
555
|
* @experimental
|
|
556
556
|
*/
|
|
557
557
|
declare const bm25LexicalStore: () => RagLexicalStore;
|
|
558
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Keep a RAG index in step with a table, so the table IS the index.
|
|
560
|
+
*
|
|
561
|
+
* `rag.index(...)` is a manual call, which means an app that edits a document
|
|
562
|
+
* has to remember to re-index it — and a forgotten call is invisible: retrieval
|
|
563
|
+
* keeps answering, just from stale text. The fix is to hang the re-index off the
|
|
564
|
+
* write itself.
|
|
565
|
+
*
|
|
566
|
+
* Embedding is network I/O, so it cannot run inside the mutation that wrote the
|
|
567
|
+
* row. The bridge is the two seams that already exist: a table `.triggers()`
|
|
568
|
+
* handler runs in the write path and can `ctx.scheduler.runAfter(...)`, so the
|
|
569
|
+
* trigger records the intent and an internal ACTION does the embedding a moment
|
|
570
|
+
* later. That is what {@link ragSyncTriggers} wires up.
|
|
571
|
+
*
|
|
572
|
+
* Re-indexing unchanged text is already cheap — `rag.index` short-circuits on a
|
|
573
|
+
* content hash — but this skips scheduling entirely when an update didn't touch
|
|
574
|
+
* the indexed text, so an unrelated column edit costs nothing at all.
|
|
575
|
+
*/
|
|
576
|
+
/**
|
|
577
|
+
* A dispatchable function reference — the `internal.docs.reindex` you pass as
|
|
578
|
+
* `action`. Typed structurally (rather than `unknown`) so passing the wrong
|
|
579
|
+
* thing is a compile error: mis-wiring the action is the mistake this API is
|
|
580
|
+
* most likely to see, and it would otherwise surface as a silent no-op.
|
|
581
|
+
*/
|
|
582
|
+
interface RagSyncActionReference {
|
|
583
|
+
readonly __lunoraRef: string;
|
|
584
|
+
}
|
|
585
|
+
/** Structural slice of `ctx.scheduler` — enough to defer the re-index. */
|
|
586
|
+
interface RagSyncScheduler {
|
|
587
|
+
runAfter: (delayMs: number, target: unknown, args?: Record<string, unknown>) => Promise<string>;
|
|
588
|
+
}
|
|
589
|
+
/** Structural slice of the `TriggerCtx` a `.triggers()` handler receives. */
|
|
590
|
+
interface RagSyncTriggerContext {
|
|
591
|
+
readonly scheduler: RagSyncScheduler;
|
|
592
|
+
}
|
|
593
|
+
/** The trigger events this helper handles, narrowed to what it reads. */
|
|
594
|
+
interface RagSyncEvent {
|
|
595
|
+
readonly doc?: Record<string, unknown>;
|
|
596
|
+
readonly id: string;
|
|
597
|
+
readonly previous?: Record<string, unknown>;
|
|
598
|
+
}
|
|
599
|
+
/** What the scheduled action receives — one document to re-index, or one to drop. */
|
|
600
|
+
interface RagSyncArgs extends Record<string, unknown> {
|
|
601
|
+
/** `true` when the source row was deleted: call `rag.remove({ id })`. */
|
|
602
|
+
deleted?: boolean;
|
|
603
|
+
/** The source id — the same `id` you pass to `rag.index`/`rag.remove`. */
|
|
604
|
+
id: string;
|
|
605
|
+
/** The text to embed. Absent on a delete. */
|
|
606
|
+
text?: string;
|
|
607
|
+
}
|
|
608
|
+
interface RagSyncOptions<Document extends Record<string, unknown> = Record<string, unknown>> {
|
|
609
|
+
/**
|
|
610
|
+
* The internal action to dispatch — it receives {@link RagSyncArgs} and calls
|
|
611
|
+
* `rag.index` / `rag.remove`. An action, not a mutation: embedding is network
|
|
612
|
+
* I/O and never runs in the deterministic write path.
|
|
613
|
+
*/
|
|
614
|
+
action: RagSyncActionReference;
|
|
615
|
+
/**
|
|
616
|
+
* How long to wait before re-indexing. A small delay coalesces nothing by
|
|
617
|
+
* itself, but it keeps the embed off the write's own tail latency. Default
|
|
618
|
+
* `0` — as soon as the mutation commits.
|
|
619
|
+
*/
|
|
620
|
+
delayMs?: number;
|
|
621
|
+
/** The source id to index under. Defaults to the row's own id. */
|
|
622
|
+
id?: (document: Document) => string;
|
|
623
|
+
/** The text to embed. Return `undefined` to skip the row (a draft, an empty body). */
|
|
624
|
+
text: (document: Document) => string | undefined;
|
|
625
|
+
}
|
|
626
|
+
/** One trigger definition, structurally — matches what `.triggers((t) => …)` returns. */
|
|
627
|
+
type RagSyncHandler = (context: RagSyncTriggerContext, event: RagSyncEvent) => Promise<void>;
|
|
628
|
+
/**
|
|
629
|
+
* Build the three write-path handlers that keep a RAG index in step with a
|
|
630
|
+
* table. Wire them into the table's `.triggers()`:
|
|
631
|
+
*
|
|
632
|
+
* ```ts
|
|
633
|
+
* const sync = ragSyncTriggers({ action: internal.docs.reindex, text: (doc) => doc.body });
|
|
634
|
+
*
|
|
635
|
+
* export const schema = defineSchema({
|
|
636
|
+
* docs: defineTable({ body: v.string(), title: v.string() }).triggers((t) => ({
|
|
637
|
+
* ragDelete: t.afterDelete(sync.afterDelete),
|
|
638
|
+
* ragInsert: t.afterInsert(sync.afterInsert),
|
|
639
|
+
* ragUpdate: t.afterUpdate(sync.afterUpdate),
|
|
640
|
+
* })),
|
|
641
|
+
* });
|
|
642
|
+
* ```
|
|
643
|
+
*
|
|
644
|
+
* The action on the other end is three lines:
|
|
645
|
+
*
|
|
646
|
+
* ```ts
|
|
647
|
+
* export const reindex = internalAction.input({ deleted: v.optional(v.boolean()), id: v.string(), text: v.optional(v.string()) }).action(
|
|
648
|
+
* async ({ args, ctx }) => {
|
|
649
|
+
* const rag = docsRag(ctx);
|
|
650
|
+
*
|
|
651
|
+
* await (args.deleted === true || args.text === undefined ? rag.remove({ id: args.id }) : rag.index({ id: args.id, text: args.text }));
|
|
652
|
+
* },
|
|
653
|
+
* );
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
declare const ragSyncTriggers: <Document extends Record<string, unknown> = Record<string, unknown>>(options: RagSyncOptions<Document>) => {
|
|
657
|
+
afterDelete: RagSyncHandler;
|
|
658
|
+
afterInsert: RagSyncHandler;
|
|
659
|
+
afterUpdate: RagSyncHandler;
|
|
660
|
+
};
|
|
661
|
+
export { type IndexInput, type IndexResult, type LexicalMatch, type Rag, type RagConfig, type RagContext, type RagEmbedder, type RagLexicalStore, type RagNamedFilter, type RagSource, type RagSyncActionReference, type RagSyncArgs, type RagSyncOptions, type RagTextStore, type RagToolOptions, type RagVectorMatch, type RagVectorMatches, type RagVectorQueryInput, type RagVectorRecord, type RagVectorUpsertInput, type RagVectors, type RemoveInput, type RetrieveOptions, type RetrieveResult, type RetrievedChunk, type StoredRagChunk, bm25LexicalStore, contentHash, defineRag, fixedWindowChunks, guessMimeTypeFromExtension, hybridRank, ragSyncTriggers };
|
package/dist/rag/index.d.ts
CHANGED
|
@@ -555,4 +555,107 @@ declare const hybridRank: (vectorResults: ReadonlyArray<RetrievedChunk>, textRes
|
|
|
555
555
|
* @experimental
|
|
556
556
|
*/
|
|
557
557
|
declare const bm25LexicalStore: () => RagLexicalStore;
|
|
558
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Keep a RAG index in step with a table, so the table IS the index.
|
|
560
|
+
*
|
|
561
|
+
* `rag.index(...)` is a manual call, which means an app that edits a document
|
|
562
|
+
* has to remember to re-index it — and a forgotten call is invisible: retrieval
|
|
563
|
+
* keeps answering, just from stale text. The fix is to hang the re-index off the
|
|
564
|
+
* write itself.
|
|
565
|
+
*
|
|
566
|
+
* Embedding is network I/O, so it cannot run inside the mutation that wrote the
|
|
567
|
+
* row. The bridge is the two seams that already exist: a table `.triggers()`
|
|
568
|
+
* handler runs in the write path and can `ctx.scheduler.runAfter(...)`, so the
|
|
569
|
+
* trigger records the intent and an internal ACTION does the embedding a moment
|
|
570
|
+
* later. That is what {@link ragSyncTriggers} wires up.
|
|
571
|
+
*
|
|
572
|
+
* Re-indexing unchanged text is already cheap — `rag.index` short-circuits on a
|
|
573
|
+
* content hash — but this skips scheduling entirely when an update didn't touch
|
|
574
|
+
* the indexed text, so an unrelated column edit costs nothing at all.
|
|
575
|
+
*/
|
|
576
|
+
/**
|
|
577
|
+
* A dispatchable function reference — the `internal.docs.reindex` you pass as
|
|
578
|
+
* `action`. Typed structurally (rather than `unknown`) so passing the wrong
|
|
579
|
+
* thing is a compile error: mis-wiring the action is the mistake this API is
|
|
580
|
+
* most likely to see, and it would otherwise surface as a silent no-op.
|
|
581
|
+
*/
|
|
582
|
+
interface RagSyncActionReference {
|
|
583
|
+
readonly __lunoraRef: string;
|
|
584
|
+
}
|
|
585
|
+
/** Structural slice of `ctx.scheduler` — enough to defer the re-index. */
|
|
586
|
+
interface RagSyncScheduler {
|
|
587
|
+
runAfter: (delayMs: number, target: unknown, args?: Record<string, unknown>) => Promise<string>;
|
|
588
|
+
}
|
|
589
|
+
/** Structural slice of the `TriggerCtx` a `.triggers()` handler receives. */
|
|
590
|
+
interface RagSyncTriggerContext {
|
|
591
|
+
readonly scheduler: RagSyncScheduler;
|
|
592
|
+
}
|
|
593
|
+
/** The trigger events this helper handles, narrowed to what it reads. */
|
|
594
|
+
interface RagSyncEvent {
|
|
595
|
+
readonly doc?: Record<string, unknown>;
|
|
596
|
+
readonly id: string;
|
|
597
|
+
readonly previous?: Record<string, unknown>;
|
|
598
|
+
}
|
|
599
|
+
/** What the scheduled action receives — one document to re-index, or one to drop. */
|
|
600
|
+
interface RagSyncArgs extends Record<string, unknown> {
|
|
601
|
+
/** `true` when the source row was deleted: call `rag.remove({ id })`. */
|
|
602
|
+
deleted?: boolean;
|
|
603
|
+
/** The source id — the same `id` you pass to `rag.index`/`rag.remove`. */
|
|
604
|
+
id: string;
|
|
605
|
+
/** The text to embed. Absent on a delete. */
|
|
606
|
+
text?: string;
|
|
607
|
+
}
|
|
608
|
+
interface RagSyncOptions<Document extends Record<string, unknown> = Record<string, unknown>> {
|
|
609
|
+
/**
|
|
610
|
+
* The internal action to dispatch — it receives {@link RagSyncArgs} and calls
|
|
611
|
+
* `rag.index` / `rag.remove`. An action, not a mutation: embedding is network
|
|
612
|
+
* I/O and never runs in the deterministic write path.
|
|
613
|
+
*/
|
|
614
|
+
action: RagSyncActionReference;
|
|
615
|
+
/**
|
|
616
|
+
* How long to wait before re-indexing. A small delay coalesces nothing by
|
|
617
|
+
* itself, but it keeps the embed off the write's own tail latency. Default
|
|
618
|
+
* `0` — as soon as the mutation commits.
|
|
619
|
+
*/
|
|
620
|
+
delayMs?: number;
|
|
621
|
+
/** The source id to index under. Defaults to the row's own id. */
|
|
622
|
+
id?: (document: Document) => string;
|
|
623
|
+
/** The text to embed. Return `undefined` to skip the row (a draft, an empty body). */
|
|
624
|
+
text: (document: Document) => string | undefined;
|
|
625
|
+
}
|
|
626
|
+
/** One trigger definition, structurally — matches what `.triggers((t) => …)` returns. */
|
|
627
|
+
type RagSyncHandler = (context: RagSyncTriggerContext, event: RagSyncEvent) => Promise<void>;
|
|
628
|
+
/**
|
|
629
|
+
* Build the three write-path handlers that keep a RAG index in step with a
|
|
630
|
+
* table. Wire them into the table's `.triggers()`:
|
|
631
|
+
*
|
|
632
|
+
* ```ts
|
|
633
|
+
* const sync = ragSyncTriggers({ action: internal.docs.reindex, text: (doc) => doc.body });
|
|
634
|
+
*
|
|
635
|
+
* export const schema = defineSchema({
|
|
636
|
+
* docs: defineTable({ body: v.string(), title: v.string() }).triggers((t) => ({
|
|
637
|
+
* ragDelete: t.afterDelete(sync.afterDelete),
|
|
638
|
+
* ragInsert: t.afterInsert(sync.afterInsert),
|
|
639
|
+
* ragUpdate: t.afterUpdate(sync.afterUpdate),
|
|
640
|
+
* })),
|
|
641
|
+
* });
|
|
642
|
+
* ```
|
|
643
|
+
*
|
|
644
|
+
* The action on the other end is three lines:
|
|
645
|
+
*
|
|
646
|
+
* ```ts
|
|
647
|
+
* export const reindex = internalAction.input({ deleted: v.optional(v.boolean()), id: v.string(), text: v.optional(v.string()) }).action(
|
|
648
|
+
* async ({ args, ctx }) => {
|
|
649
|
+
* const rag = docsRag(ctx);
|
|
650
|
+
*
|
|
651
|
+
* await (args.deleted === true || args.text === undefined ? rag.remove({ id: args.id }) : rag.index({ id: args.id, text: args.text }));
|
|
652
|
+
* },
|
|
653
|
+
* );
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
declare const ragSyncTriggers: <Document extends Record<string, unknown> = Record<string, unknown>>(options: RagSyncOptions<Document>) => {
|
|
657
|
+
afterDelete: RagSyncHandler;
|
|
658
|
+
afterInsert: RagSyncHandler;
|
|
659
|
+
afterUpdate: RagSyncHandler;
|
|
660
|
+
};
|
|
661
|
+
export { type IndexInput, type IndexResult, type LexicalMatch, type Rag, type RagConfig, type RagContext, type RagEmbedder, type RagLexicalStore, type RagNamedFilter, type RagSource, type RagSyncActionReference, type RagSyncArgs, type RagSyncOptions, type RagTextStore, type RagToolOptions, type RagVectorMatch, type RagVectorMatches, type RagVectorQueryInput, type RagVectorRecord, type RagVectorUpsertInput, type RagVectors, type RemoveInput, type RetrieveOptions, type RetrieveResult, type RetrievedChunk, type StoredRagChunk, bm25LexicalStore, contentHash, defineRag, fixedWindowChunks, guessMimeTypeFromExtension, hybridRank, ragSyncTriggers };
|
package/dist/rag/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{default as
|
|
1
|
+
import{default as o}from"../packem_shared/fixedWindowChunks-XJRXHEoz.mjs";import{default as a}from"../packem_shared/defineRag-Drf-Vn0B.mjs";import{contentHash as s,guessMimeTypeFromExtension as m}from"../packem_shared/contentHash-BIn6ECP8.mjs";import{default as x}from"../packem_shared/hybridRank-U6PmGuz1.mjs";import{default as i}from"../packem_shared/bm25LexicalStore-RA9sesFC.mjs";import{ragSyncTriggers as u}from"../packem_shared/ragSyncTriggers-BY8QgX4d.mjs";export{i as bm25LexicalStore,s as contentHash,a as defineRag,o as fixedWindowChunks,m as guessMimeTypeFromExtension,x as hybridRank,u as ragSyncTriggers};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/ai",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.57",
|
|
4
4
|
"description": "Workers AI helper for Lunora: provider-agnostic AI SDK access from functions, Workers AI by default",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
56
|
+
"@lunora/errors": "1.0.0-alpha.21",
|
|
57
57
|
"ai": "7.0.37",
|
|
58
58
|
"workers-ai-provider": "4.0.0"
|
|
59
59
|
},
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import{LunoraError as x}from"@lunora/errors";import{tool as G,jsonSchema as J,embed as ee}from"ai";import te from"./fixedWindowChunks-XJRXHEoz.mjs";import{contentHash as ne}from"./contentHash-BIn6ECP8.mjs";import ae from"./hybridRank-U6PmGuz1.mjs";const re=8,oe=async(e,c,p)=>{if(!Number.isInteger(c)||c<1)throw new RangeError("concurrentMap: `limit` must be a positive integer");if(e.length===0)return[];const b=Math.max(1,Math.min(c,e.length)),y=Array.from({length:e.length});let f=0,k=!1,v;const E=async()=>{for(;;){if(k)return;const I=f;if(f+=1,I>=e.length)return;try{y[I]=await p(e[I],I)}catch(N){k||(k=!0,v=N);return}}},h=Array.from({length:b},()=>E());if(await Promise.all(h),k)throw v;return y},ie=1e3,se=200,ce=5,de=20,ue=100,z="__ragChunk",V="__ragSource",A="__ragText",$="__ragHash",T="__ragChunks",M="__ragImportance",F="__ragModel",me=new Set([z,T,$,M,F,V,A]),he=/^[\w.-]{1,40}$/,P=e=>e===void 0?"":`${encodeURIComponent(e)}#`,_=(e,c,p)=>`${P(e)}${c}#${String(p)}`,K=(e,c)=>{const p=P(c),b=p!==""&&e.startsWith(p)?e.slice(p.length):e,y=b.lastIndexOf("#"),f=y===-1?Number.NaN:Number(b.slice(y+1));return y===-1||!Number.isInteger(f)||f<0?{chunkIndex:0,sourceId:b}:{chunkIndex:f,sourceId:b.slice(0,y)}},pe=async e=>ne(new TextEncoder().encode(e)),O=e=>{if(!e)return;const c=Object.entries(e).filter(([p])=>!me.has(p));return c.length>0?Object.fromEntries(c):void 0},Q=new Set,le=e=>{Q.has(e)||(Q.add(e),console.warn(`[@lunora/ai/rag] index "${e}" is used without a namespace — in a multi-tenant/sharded
|
|
2
|
-
app this shares one tenant's chunks (text included) with every other tenant, since
|
|
3
|
-
Vectorize indexes are account-global. Pass \`namespace\` (the shard/tenant key) on both
|
|
4
|
-
index() and retrieve(). Single-tenant apps suppress this via { allowSharedNamespace: true }.`))},ge=e=>e.map(c=>`[source:${c.sourceId}#${String(c.chunkIndex)}]
|
|
5
|
-
${c.text}`).join(`
|
|
6
|
-
|
|
7
|
-
`),fe=(e,c)=>{if(typeof e=="object")return e;if(c===void 0)throw new x("INTERNAL","@lunora/ai/rag: `embeddingModel` is a Workers AI model id (or omitted) but the bound context has no `ai` (env.AI). Pass an AI SDK EmbeddingModel object to embed without Workers AI, or bind a context whose `ctx.ai` is wired.");return c.embeddingModel(e)},we=e=>{const c=e.modelId;return typeof c=="string"&&c.length>0?c:void 0},xe=e=>{if(!(typeof e!="object"||e===null)){for(const c of Object.values(e))if(typeof c=="object"&&c!==null){const{cost:p}=c;if(typeof p=="number"&&Number.isFinite(p))return p}}},Se=e=>{if(typeof e.index!="string"||e.index.length===0)throw new x("BAD_REQUEST","@lunora/ai/rag: `index` must be a non-empty Vectorize index name");const c=e.chunkSize??ie,p=e.chunkOverlap??se;if(!Number.isInteger(c)||c<1)throw new x("BAD_REQUEST","@lunora/ai/rag: `chunkSize` must be a positive integer");if(!Number.isInteger(p)||p<0||p>=c)throw new x("BAD_REQUEST","@lunora/ai/rag: `chunkOverlap` must be a non-negative integer smaller than `chunkSize`");const b=e.topK??ce;if(!Number.isInteger(b)||b<1)throw new x("BAD_REQUEST","@lunora/ai/rag: `topK` must be a positive integer");if(e.embeddingModelVersion!==void 0&&!he.test(e.embeddingModelVersion))throw new x("BAD_REQUEST",'@lunora/ai/rag: `embeddingModelVersion` must match /^[A-Za-z0-9._-]{1,40}$/ (a short, stable tag like "bge-v1.5")');const y=e.chunk??(h=>te(h,c,p)),{textStore:f}=e,k=f?ue:de,v=e.embeddingModelVersion,E=h=>v===void 0?h:h===void 0?v:`${v}::${h}`;return h=>{let I;const N=typeof h.trace=="function"?h.trace:void 0,B=async t=>{I??=fe(e.embeddingModel,h.ai);const n=I,o=async d=>{const{embedding:r,providerMetadata:u,usage:m}=await ee({model:n,value:t});if(d!==void 0){const s=m.tokens;typeof s=="number"&&Number.isFinite(s)&&d.setAttribute("gen_ai.usage.input_tokens",s);const l=xe(u);l!==void 0&&d.setAttribute("gen_ai.usage.cost",l)}return r};if(N===void 0)return o();const i=we(n),a=typeof h.conversationId=="string"&&h.conversationId.length>0?h.conversationId:void 0;return N("ai.embed",(d,r)=>o(r),{"gen_ai.operation.name":"embeddings",...i===void 0?{}:{"gen_ai.request.model":i},...a===void 0?{}:{"gen_ai.conversation.id":a}})},R=t=>{if(t===void 0){if(e.requireNamespace)throw new x("BAD_REQUEST",`@lunora/ai/rag: index "${e.index}" requires a namespace (requireNamespace is set) — pass the tenant/shard key on index()/retrieve()/remove()`);e.allowSharedNamespace||le(e.index)}},q=async(t,n)=>{const[o]=await h.vectors.getByIds(e.index,[_(n,t,0)],n),i=o?.metadata?.[$],a=o?.metadata?.[T];return{chunks:typeof a=="number"&&Number.isInteger(a)&&a>0?a:void 0,hash:typeof i=="string"?i:void 0}},C=async(t,n,o,i)=>{const a=Array.from({length:o-n},(d,r)=>_(i,t,n+r));a.length!==0&&(await h.vectors.deleteByIds(e.index,a,i),await f?.remove?.(a,{namespace:i}),await e.lexicalStore?.remove?.(a,{namespace:i}))},W=async t=>{if(R(t.namespace),t.importance!==void 0&&(typeof t.importance!="number"||t.importance<0||t.importance>1))throw new x("BAD_REQUEST","@lunora/ai/rag: `importance` must be a number in [0, 1]");const n=E(t.namespace),o=await pe(t.text),i=await q(t.id,n);if(i.hash===o&&i.chunks!==void 0)return{chunks:i.chunks,ids:Array.from({length:i.chunks},(r,u)=>_(n,t.id,u)),unchanged:!0};const a=y(t.text),d=a.map((r,u)=>_(n,t.id,u));if(a.length===0&&t.allowEmptySources===!1)throw new x("BAD_REQUEST",`@lunora/ai/rag: source "${t.id}" produced zero chunks — set allowEmptySources: true to allow this`);if(a.length>0){const r=a.map((u,m)=>({chunkIndex:m,id:d[m],sourceId:t.id,text:u}));f&&await f.put(r,{namespace:n}),e.lexicalStore&&await e.lexicalStore.index(r,{namespace:n})}return await oe(a,re,async(r,u)=>{const m=d[u],s={...t.metadata,[z]:u,[V]:t.id};f||(s[A]=r),t.importance!==void 0&&(s[M]=t.importance),u===0&&(s[$]=o,s[T]=a.length,v!==void 0&&(s[F]=v)),await h.vectors.upsert(e.index,{embed:B,id:m,input:r,metadata:s,namespace:n}),t.onChunk?.({chunkIndex:u,id:m,text:r,total:a.length})}),i.chunks!==void 0&&i.chunks>a.length&&await C(t.id,a.length,i.chunks,n),{chunks:a.length,ids:d,unchanged:!1}},H=async t=>{R(t.namespace);const n=E(t.namespace),o=(await q(t.id,n)).chunks??1;await C(t.id,0,o,n)},D=async(t,n)=>{const o=new Map;if(t.length===0)return o;if(f){const a=await f.getMany(t,{namespace:n});for(const[d,r]of t.entries()){const u=a[d];typeof u=="string"&&o.set(r,u)}return o}const i=await h.vectors.getByIds(e.index,t,n);for(const a of i){const d=a.metadata?.[A];typeof d=="string"&&o.set(a.id,d)}return o},L=async(t,n,o)=>{const i=n?.chunkContext?.before??0,a=n?.chunkContext?.after??0;if(i===0&&a===0)return t;if(!Number.isInteger(i)||i<0||!Number.isInteger(a)||a<0)throw new x("BAD_REQUEST","@lunora/ai/rag: `chunkContext.before`/`chunkContext.after` must be non-negative integers");const d=new Map(t.map(s=>[s.id,s.text])),r=new Set;for(const s of t)for(let l=-i;l<=a;l+=1){const w=s.chunkIndex+l,g=_(o,s.sourceId,w);l!==0&&w>=0&&!d.has(g)&&r.add(g)}const u=await D([...r],o),m=(s,l)=>{const w=_(o,s,l);return d.get(w)??u.get(w)};return t.map(s=>{const l=[];for(let w=-i;w<=a;w+=1){const g=w===0?s.text:m(s.sourceId,s.chunkIndex+w);g!==void 0&&l.push(g)}return{...s,text:l.join(`
|
|
8
|
-
`)}})},X=t=>{if(typeof t=="string"){const n=e.filters?.[t];if(!n)throw new x("NOT_FOUND",`@lunora/ai/rag: unknown named filter "${t}" — must be one of the keys declared in RagConfig.filters`);return n.filter}return t},Y=(t,n)=>t.matches.map(o=>{const i=o.metadata??{},a=K(o.id,n),d=i[A],r=i[M],u=typeof r=="number"&&r>=0&&r<=1?r:1;return{chunkIndex:a.chunkIndex,id:o.id,importance:u,metadata:O(i),score:o.score*u,sourceId:a.sourceId,text:typeof d=="string"?d:""}}),Z=async(t,n)=>{if(!f)return t;const o=t.map(r=>r.id),[i,a]=await Promise.all([D(o,n),h.vectors.getByIds(e.index,o,n)]),d=new Map(a.map(r=>[r.id,r.metadata]));return t.flatMap(r=>{const u=i.get(r.id);if(u===void 0)return[];const m=d.get(r.id),s=m?.[M],l=typeof s=="number"&&s>=0&&s<=1?s:r.importance,w=(r.importance===0?0:r.score/r.importance)*l;return[{...r,importance:l,metadata:O(m)??r.metadata,score:w,text:u}]})},U=async(t,n)=>{R(n?.namespace);const o=E(n?.namespace),i=X(n?.filter),a=e.rlsFilter?await e.rlsFilter(h.auth):void 0,d=a?{...i,...a}:i,r=Math.min(n?.topK??b,k),u=await h.vectors.query(e.index,{embed:B,filter:d,input:t,namespace:o,returnMetadata:f?"indexed":"all",topK:r});let m=await Z(Y(u,o),o);const s=n?.minScore;if(s!==void 0&&(m=m.filter(g=>g.score>=s)),e.lexicalStore){const g=(await e.lexicalStore.search(t,{filter:d,namespace:o,topK:e.lexicalTopK??r})).map(S=>{const j=K(S.id,o);return{chunkIndex:j.chunkIndex,id:S.id,importance:1,metadata:void 0,score:S.score,sourceId:j.sourceId,text:S.text}});m=[...ae(m,g)]}m.sort((g,S)=>S.score-g.score),m=[...await L(m,n,o)];const l=[],w=new Set;for(const g of m)w.has(g.sourceId)||(w.add(g.sourceId),l.push({id:g.sourceId,metadata:g.metadata,weight:g.importance}));return n?.onRetrieve?.({matches:m.length,query:t}),{chunks:m,context:ge(m),sources:l}};return{asTool:t=>G({description:t?.description??`Search the "${e.index}" knowledge base for passages relevant to a natural-language query.`,execute:async({query:n})=>U(n,{namespace:t?.namespace,topK:t?.topK}),inputSchema:J({properties:{query:{description:"The natural-language search query.",type:"string"}},required:["query"],type:"object"})}),index:W,remove:H,retrieve:U}}};export{Se as default};
|