Wissensgraph
Technische Architektur von Owlats typisiertem Wissensspeicher — wie Organisationswissen abgelegt, durchsucht, im Wert gemindert und gepflegt wird.
Architektur des Wissensgraphen
Jede Organisation sammelt Wissen durch Kommunikation an: Kundenpräferenzen, interne Entscheidungen, Projektkontext, Beziehungsgeschichte. Der Wissensgraph fängt dieses Wissen als typisierte, durchsuchbare, verfallende Einträge ein — kein reines Schreibprotokoll, sondern ein lebendiges System, das korrekt bleibt, während sich die Organisation weiterentwickelt.
Die Kernschleife läuft inzwischen Ende zu Ende. Heute ausgeliefert: die typisierten Tabellen knowledgeEntries / knowledgeRelations, CRUD plus Volltextsuche (apps/api/convex/knowledge/graph.ts), der typbezogene Cron für den Konfidenzverfall (apps/api/convex/knowledge/maintenance.ts), die aktive Extraktion pro Nachricht, verdrahtet in die Agenten-Strecke (idempotent), der echte vektorbasierte semantische Abruf (apps/api/convex/knowledge/retrieval.ts) und das Einspeisen semantischen Wissens in das Kontext-Briefing des Agenten (apps/api/convex/agent/steps/context_retrieval/index.ts). Der einmalige Backfill-Job (apps/api/convex/agent/knowledgeBackfill.ts) sät beim erstmaligen Aktivieren des Flags weiterhin historischen Kontext ein.
Einige der unten beschriebenen Fähigkeiten sind weiterhin geplant und haben noch keinen produktiven Aufrufer: Deduplizierung während der Extraktion / Widerspruchsprüfung / das inline vor dem Speichern erfolgende Anlegen der Relationen supports / contradicts / supersedes, Memory als Werkzeuge, Widerspruchsauflösung und der Validierungsschub. Jede Stelle ist im Text markiert. (Zwei Kantenverknüpfer werden bereits ausgeliefert und vom Extraktor eingeplant: der deterministische strukturelle relates_to-Verknüpfer, abgesichert über ai.knowledge, und — hinter ai.knowledge.autoLink — ein LLM-Durchlauf, der die semantischen Kanten supports / contradicts / supersedes nach dem Speichern erschließt. Der graphgestützte Abruf über diese Kanten wird hinter ai.knowledge.graphRetrieval ausgeliefert.)
Isolation durch ein Deployment
Owlat betreibt eine Organisation pro Deployment (siehe die Laufzeitinvariante in apps/api/convex/lib/sessionOrganization.ts). Deshalb geschieht die Wissensisolation an der Deployment-Grenze und nicht durch Filterung je Abfrage: Es gibt kein Feld organizationId auf knowledgeEntries oder knowledgeRelations, und keine Abfrage grenzt nach Organisation ein. Jeder Eintrag in einem Deployment gehört zu der einen Organisation dieses Deployments.
Speichermodell
Der Wissensgraph baut auf Convex-Tabellen auf — nicht auf einer separaten Graphdatenbank. Das Schema reserviert einen Convex-Vektorindex für die semantische Suche, und indizierte Joins übernehmen das Traversieren von Beziehungen. Das hält den selbst gehosteten Stack einfach: kein Neo4j, kein Pinecone, keine zusätzlichen Dienste.
Wissenseinträge
Jedes Stück Organisationswissen ist ein typisierter Eintrag:
| Typ | Beschreibung | Beispiel |
|---|---|---|
| Fakt | Überprüfbare Information über eine Entität | „Acme Corp nutzt unseren Enterprise-Tarif“ |
| Entscheidung | Eine getroffene Wahl samt Begründung | „Beschlossen, Acmes Testphase um 2 Wochen zu verlängern (genehmigt von Sarah)“ |
| Ereignis | Etwas, das zu einem bestimmten Zeitpunkt geschehen ist | „Acmes CTO am 5. März auf der SaaStr-Konferenz getroffen“ |
| Präferenz | Wie jemand die Dinge gern gehandhabt hat | „Acme bevorzugt für Support E-Mail statt Telefon“ |
| Ziel | Ein Vorhaben, auf das jemand hinarbeitet | „Acme will sein E-Mail-Programm bis September starten“ |
| Beziehung | Eine Verbindung zwischen Entitäten | „Alice bei Acme berichtet an Bob“ |
| Aufgabe | Eine in einer Konversation identifizierte Zusage oder Aufgabe | „Acme bis Freitag das aktualisierte Angebot schicken“ |
Jeder Eintrag hat:
- Inhalt — das Wissen selbst (
title+ ausführlichercontent) - Quellenangabe — woher dieses Wissen stammt (
email,chat,manual,file,agent_extracted) - Entitätsverknüpfungen — optionale Verbindungen zu Kontakten (
contactIds) und zu einem Konversations-Thread (threadId) - Embedding — ein Vektor mit
1536Dimensionen plus dasembeddingModelund der ZeitpunktembeddingGeneratedAt, die ihn erzeugt haben, damit veraltete Embeddings neu erzeugt werden können - Konfidenzwert — wie verlässlich dieses Wissen ist (0–1), mit einem Zeitstempel
lastValidatedAt - Ablauf — optionale
expiresAt-TTL für zeitkritische Fakten
Wissensrelationen
Einträge sind über typisierte Kanten miteinander verbunden:
| Relation | Bedeutung |
|---|---|
supports | Ein Eintrag liefert Belege für einen anderen |
contradicts | Ein Eintrag widerspricht einem anderen |
supersedes | Ein Eintrag ersetzt einen anderen (neuere Information) |
relates_to | Allgemeiner Bezug |
causes | Kausale Beziehung |
blocks | Ein Eintrag blockiert einen anderen |
Relationen werden heute gespeichert (createRelation in graph.ts, indiziert über by_from / by_to), und getEntry liefert einen Eintrag samt seiner ein- und ausgehenden Relationen zurück. Auch das Traversieren von Relationen wird inzwischen ausgeliefert: Hinter ai.knowledge.graphRetrieval läuft der Abruf über diese Kanten, um stützenden Kontext zu finden und Widersprüche zu markieren — siehe Graphgestützter Abruf weiter unten.
Extraktion
Die Extraktion läuft nun je Nachricht, sobald sie eintrifft. Ist die Klassifizierung abgeschlossen, gibt der Verarbeitungslebenszyklus (apps/api/convex/inbox/processingLifecycle.ts) einen Effekt schedule_knowledge_extraction aus, der internal.knowledge.extraction.extractFromMessage einplant — sowohl auf der normalen Kante classifying → drafting als auch auf der den Entwurf überspringenden Kante classifying → draft_ready (Beschwerden / dringende Nachrichten), sodass er exakt einmal pro Nachricht feuert. Das umfasst eingehende E-Mail und eingehende Kanal-Nachrichten (SMS / WhatsApp / generisch), die durch die Agenten-Strecke laufen.
Der Lauf ist idempotent: extractFromMessage kehrt frühzeitig zurück, wenn countBySource bereits Einträge für die Quelle (agent_extracted, inboundMessageId) findet, und saveEntry dedupliziert einen zweiten Schreiber über die exakte Kombination source + title unter Convex-OCC (apps/api/convex/knowledge/graph.ts). Ein erneuter Lauf (etwa ein Cron-Retry) erzeugt keine Dubletten.
Der einmalige Backfill-Job (apps/api/convex/agent/knowledgeBackfill.ts) durchläuft beim erstmaligen Umschalten des Feature-Flags ai.agent von false auf true (setFeatureFlag in apps/api/convex/organizations/featureFlags.ts) weiterhin die bestehenden inboundMessages des Deployments, damit der Entwurfsschritt von Tag eins an historischen Kontext hat. Interner Chat zwischen MEMBERn (unifiedMessages.sendChatMessage) löst keine Extraktion aus — das tun nur Nachrichten der Eingangsstrecke.
Der Extraktor (extractFromMessage in apps/api/convex/knowledge/extraction.ts) ist eine Node-internalAction, die drei Dinge tut:
extractFromMessage(inboundMessageId)
0. Idempotency guard: countBySource → early-return if already extracted
1. LLM extraction: single structured-output call producing entries[]
2. Embed each entry (text-embedding-3-small)
3. saveEntry → insert into knowledgeEntries (dedup by source + title)
Ein Gegenstück extractFromFile spiegelt dies für verarbeitete Dokumente (siehe die Vision Dateisystem). Ein deterministischer struktureller relates_to-Verknüpfer läuft nach dem Speichern (eingeplant vom Extraktor); saveEntry dedupliziert über source + title und über den quellenübergreifenden contentHash (bei übereinstimmendem Kontakt-Geltungsbereich). Die vor dem Speichern inline erfolgende Widerspruchsprüfung und das Erschließen von supports / contradicts / supersedes bleiben Teil der Vision.
LLM-Extraktion
Die Extraktion nutzt einen einzigen Aufruf mit strukturierter Ausgabe gegen die Modellrolle extract und erzeugt ein flaches Array entries[]:
const extractionSchema = z.object({
entries: z.array(z.object({
type: z.enum(['fact', 'decision', 'event', 'preference', 'goal', 'relationship', 'action_item']),
title: z.string(),
content: z.string(),
confidence: z.number().min(0).max(1),
tags: z.array(z.string()).optional(),
})),
})
const { object: extraction } = await runLlmObject({
model: getLLMProvider('extract'),
schema: extractionSchema,
prompt: `Extract organizational knowledge from this email message...`,
temperature: 0.1,
})
Jeder extrahierte Eintrag wird eingebettet und über internal.knowledge.graph.saveEntry gespeichert, mit sourceType: 'agent_extracted' und einer sourceId, die auf die ursprüngliche inboundMessageId gesetzt ist (was sowohl der aktive Extraktor als auch der Backfill-Job über countBySource und den Index by_source für die Idempotenz nutzen).
Eine künftige Fassung des Extraktors würde vor dem Speichern eine Vektorsuche nach nahezu identischen Einträgen ausführen (inline zusammenführen / verknüpfen / ersetzen), auf Widersprüche prüfen und die oben beschriebenen Relationen supports / contradicts / supersedes anlegen. Die inline vor dem Speichern arbeitende Variante ist weiterhin nur geplant.
Das nachgelagerte Zusammenführen naher Dubletten wird allerdings heute ausgeliefert, und zwar als separater täglicher Wartungs-Cron: runKnowledgeDedup → dedupeContactEntries → mergeEntryInto (apps/api/convex/knowledge/maintenance.ts), angetrieben vom Cron knowledge graph dedup (apps/api/convex/crons.ts). Er clustert die Einträge eines Kontakts nach Kosinusähnlichkeit mit dem Schwellwert 0.95 (apps/api/convex/lib/knowledgeDedup.ts), faltet dann die Inhalte zusammen, vereinigt contactIds + Tags, richtet die Junction-Tabelle und die Relationen auf den Überlebenden um und löscht den Unterlegenen. Die Widerspruchsprüfung und der LLM-gestützte Durchlauf für die Kanten supports / contradicts / supersedes bleiben unimplementiert — der deterministische Schritt zum Anlegen der relates_to-Relation wird heute ausgeliefert.
Abruf
Der Wissensgraph bedient heute sowohl den produktinternen Browser als auch den aktiven Abruf durch den Agenten.
Volltextsuche (ausgeliefert)
Genutzt vom produktinternen Browser. Die öffentliche search-Query führt eine Convex-Volltextsuche über searchableText aus, optional gefiltert nach Eintragstyp:
ctx.db
.query('knowledgeEntries')
.withSearchIndex('search_knowledge', (q) => {
let sq = q.search('searchableText', args.searchQuery)
if (args.entryType) sq = sq.eq('entryType', args.entryType)
return sq
})
.take(limit)
Der Pfad zum Durchstöbern nach Typ (listByType) liest über den Index by_entry_type. Beide liegen dem Composable useKnowledgeGraph zugrunde (apps/web/app/composables/useKnowledgeGraph.ts).
Kontaktbezogener Abruf (ausgeliefert)
Convex kann Array-Felder nicht direkt indizieren, deshalb wird knowledgeEntries.contactIds in eine eigene Junction-Tabelle gespiegelt — knowledgeEntryContacts (apps/api/convex/schema/knowledge.ts), eine Zeile pro Paar (entryId, contactId) mit einem Index by_contact. getByContact fragt diesen Index ab und hydratisiert die passenden Einträge, sodass die Suche vollständig und O(Treffer) ist — keine Kürzung, kein Scan im Arbeitsspeicher:
const links = await ctx.db
.query('knowledgeEntryContacts')
.withIndex('by_contact', (q) => q.eq('contactId', args.contactId))
.collect()
const entryMap = await batchGet(ctx, links.map((link) => link.entryId))
return [...entryMap.values()]
.filter((e) => e !== null && !(e.expiresAt !== undefined && e.expiresAt < now))
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, args.limit ?? 20)
Die Junction-Zeilen werden von denselben saveEntry-/Update-Mutationen geschrieben und aufgeräumt, die contactIds setzen oder leeren, beim Zusammenführen von Kontakten umgerichtet (lib/contactMutations.ts) und beim Ablauf / Löschen / Organisations-Wipe zusammen mit dem übergeordneten Eintrag abgeräumt — sie enthält also nie verwaiste Zeilen. Das hat den früheren, auf 500 Zeilen begrenzten Filter im Arbeitsspeicher abgelöst.
Semantische Suche (ausgeliefert)
semanticSearch (apps/api/convex/knowledge/retrieval.ts) ist inzwischen eine echte internalAction — die Vektorsuche lebt am Action-Kontext (ctx.vectorSearch), weshalb die Naht aus graph.ts in ein eigenes Abrufmodul gewandert ist. Sie akzeptiert entweder einen queryText (hier mit text-embedding-3-small eingebettet) oder ein vorberechnetes embedding, dazu ein erforderliches Argument scopeToContact (eine contactId, 'org-general-only' oder 'org-wide') für die Nachfilterung zur Kontaktisolation. Statt eines einzelnen Vektordurchlaufs ist sie hybrid: Sie führt zwei Beine aus — eine Vektorsuche über den Index vector_knowledge und eine Volltextsuche (internal.knowledge.graph.ftsRankedIds) — und verschmilzt deren Ranglisten mittels Reciprocal Rank Fusion (degradiert zu reiner Vektorsuche, wenn kein Abfragetext vorliegt). Anschließend hydratisiert sie die verschmolzene Reihenfolge über internal.knowledge.graph.getByIds zu vollständigen Dokumenten, filtert nach dem Kontakt-Geltungsbereich und versieht jeden Überlebenden mit seinem Ähnlichkeitswert _score:
// Leg 1 — semantic vector search
const hits = await ctx.vectorSearch('knowledgeEntries', 'vector_knowledge', {
vector,
limit: fetchLimit,
filter: args.entryType ? (q) => q.eq('entryType', args.entryType) : undefined,
})
const vectorRanked = hits.map((h) => h._id)
// Leg 2 — full-text search (only when we have query text)
let ftsRanked = []
if (queryText) {
ftsRanked = await ctx.runQuery(internal.knowledge.graph.ftsRankedIds, {
queryText,
entryType: args.entryType,
limit: fetchLimit,
})
}
// Fuse the two rankings (vector-only when FTS is empty), then hydrate
const fusedIds = reciprocalRankFusion([vectorRanked, ftsRanked])
const entries = await ctx.runQuery(internal.knowledge.graph.getByIds, {
ids: fusedIds,
})
Der Index vector_knowledge (filterField ['entryType']) liegt dem zugrunde. Die alte semanticSearch-internalQuery in graph.ts, die jüngste Zeilen zurückgab, ist verschwunden — getByIds hat sie als Naht zur Dokumenthydratisierung abgelöst.
Graphgestützter Abruf: erst säen, dann ausweiten (ausgeliefert)
Die obige hybride Fusion erzeugt eine flache Menge nächster Nachbarn. Übergibt die aufrufende Stelle expandGraph: true — gesetzt aus dem Feature-Flag ai.knowledge.graphRetrieval sowohl vom Assistenz-Werkzeug searchKnowledge (apps/api/convex/assistant/tools.ts) als auch vom Agentenschritt context_retrieval (apps/api/convex/agent/steps/context_retrieval/index.ts) —, schaltet semanticSearch auf erst säen, dann ausweiten um: Die obersten für den Kontakt sichtbaren Treffer werden zu Saatkörnern, und expandNeighbors (apps/api/convex/knowledge/graphTraversal.ts) läuft ein bis zwei Sprünge weit über die Kanten in knowledgeRelations, um den verbundenen Teilgraphen hereinzuholen. lib/graphRank.ts bewertet die Vereinigung anschließend neu — unter Einbezug von Kantengewicht (RELATION_WEIGHTS), Sprungdistanz und dem ursprünglichen Fusionswert —, bevor in Rangfolge hydratisiert wird:
// Flat path OR graph-augmented — expandGraph is the kill switch.
let returned: ScoredKnowledgeEntry[];
if (args.expandGraph && visible.length > 0) {
try {
returned = await expandAndRank(ctx, { visible, vectorRanked, ftsRanked, scope, entryType, limit, hops: args.hops, neighborBudget: args.neighborBudget });
} catch {
returned = visible.slice(0, limit); // fail soft to flat
}
} else {
returned = visible.slice(0, limit);
}
Jeder ausgeweitete Eintrag kann drei Annotationen tragen (auf dem flachen Pfad fehlen sie):
_via— die typisierten Beziehungen, die ihn innerhalb des zurückgegebenen Teilgraphen mit einem Saatkorn verbunden haben, sodass eine konsumierende Stelle darstellen kann, warum er aufgetaucht ist._stale— er ist das Ziel einersupersedes-Kante (ein neuerer Eintrag ersetzt ihn); das Agenten-Briefing stellt ihm einen Hinweis voran, damit der Entwurfsschritt ihn vorsichtig behandelt._caveat— er ist Endpunkt einercontradicts-Kante; er bleibt im Ergebnis, wird aber als umstritten markiert statt vorbehaltlos für bare Münze genommen.
Der gesamte Pfad fällt weich aus: Jeder Traversierungsfehler fällt auf das flache sichtbare Teilstück zurück, statt den Abruf ganz fallen zu lassen, und bei ausgeschaltetem Flag nimmt der Code byte-für-byte den flachen Zweig.
Kanten haben keinen eigenen Kontakt-Geltungsbereich, und das Dedup-Zusammenführen vereinigt die contactIds eines Knotens — eine Kante kann also einen Knoten von Kontakt A mit einem nur für Kontakt B bestimmten Knoten verbinden; ihr zu folgen wäre ein Primitiv zur Rechteausweitung. expandNeighbors prüft deshalb jeden hydratisierten Nachbarn erneut mit isContactScopeVisible(neighbour.contactIds, scope), bevor er ins Ergebnis gelangen darf (nur ein ausdrücklicher Geltungsbereich 'org-wide' überspringt diese Prüfung). Ein Nachbar, der durchfällt, wird verworfen: keine Nachbarzeile, keine Kante zu ihm (sein Inhalt und seine Existenz bleiben verborgen), und er wird nie zur Grenze der Ausweitung — ein 2-Sprung-Lauf kann also auch seine Nachbarn nicht erreichen. apps/api/scripts/check-graph-scope.sh stellt positiv sicher, dass diese Datei isContactScopeVisible aufruft.
Der Agent liest den Graphen
Der aktive Schritt context_retrieval (apps/api/convex/agent/steps/context_retrieval/index.ts) faltet inzwischen semantisches Wissen in sein Briefing hinein. Neben dem Kontaktprofil, den jüngsten Aktivitäten, dem Thread-Verlauf und der aktuellen Nachricht baut er aus Betreff und Text der eingehenden Nachricht eine Abfrage und führt hinter dem Tokenbudget des Schritts zwei action-gestützte Abrufe aus:
internal.knowledge.retrieval.semanticSearch(LimitknowledgeEntryLimit: 10) → ein Abschnitt[KNOWLEDGE]mit den relevantesten typisierten Einträgen, jeweils mit Typ und Konfidenzinternal.semanticFileProcessing.semanticSearch(LimitfileLimit: 3) → ein Abschnitt[RELEVANT FILES]mit relevanten Quelldokumenten (siehe die Vision Dateisystem)
Die Budgetkonstante knowledgeEntryLimit ist inzwischen verdrahtet. Die drei Verdichtungsstufen (normal / compacted / emergency) begrenzen das zusammengestellte Briefing weiterhin. Wie sich der Kontextabruf in den Rest der Strecke einfügt, steht unter Agenten-Strecke.
Geplant: Memory als Werkzeuge
Die Verhaltensweisen in diesem Abschnitt beschreiben den angestrebten Endzustand. In apps/api/convex/agent/ gibt es heute keine tool()-Definitionen, und die aktive Strecke hat fünf Schritte — security_scan, context_retrieval, classify, draft, route (apps/api/convex/agent/steps/index.ts) — ohne separaten Schritt zur Aktionsplanung. Die Schrittnummern weiter unten entsprechen der visionären Darstellung im Visionsdokument Agenten-Strecke, nicht dem Code.
Die Extraktion (oben) ist passiv — sie läuft, nachdem eine Nachricht verarbeitet wurde. Die Vision sieht vor, dass der Agent Wissen während der Ausführung der Strecke auch aktiv speichert und abruft, indem ihm Werkzeugdefinitionen zum Aufruf bereitstehen.
Aktives Speichern
Entdeckt der Agent während einer Konversation etwas Wichtiges — einen neuen Fakt, eine geänderte Präferenz, eine Zusage —, würde er es unmittelbar während des Schritts zur Aktionsplanung (Schritt 3) festhalten:
const saveKnowledge = tool({
description: 'Save a piece of organizational knowledge discovered during this conversation',
parameters: z.object({
type: z.enum(['fact', 'decision', 'event', 'preference', 'goal', 'relationship', 'action_item']),
title: z.string(),
content: z.string(),
contactId: z.string().optional(),
confidence: z.number().min(0).max(1),
expiresInDays: z.number().optional(),
}),
execute: async (args) => {
// Would run dedup + contradiction check before storing
return await ctx.runMutation(internal.knowledge.graph.saveEntry, { /* ... */ })
},
})
Aktives Abrufen
Während der Entwurfserzeugung (Schritt 4) würde der Agent den Graphen ausdrücklich nach relevantem Kontext jenseits dessen abfragen, was der Kontextabruf ohnehin schon zutage gefördert hat. Die Vektorsuch-Naht, die er aufrufen würde (internal.knowledge.retrieval.semanticSearch), existiert und wird heute ausgeliefert — was fehlt, ist ihre Bereitstellung als Werkzeug, das das LLM bei Bedarf aufrufen kann:
const recallKnowledge = tool({
description: 'Search organizational knowledge for information relevant to the current task',
parameters: z.object({
query: z.string(),
contactId: z.string().optional(),
type: z.enum([/* entry types */]).optional(),
limit: z.number().default(5),
}),
execute: async (args) => {
// The underlying vector search already exists; this would wrap it as a tool
return await ctx.runAction(internal.knowledge.retrieval.semanticSearch, { queryText: args.query, /* ... */ })
},
})
Beide bauen auf der oben beschriebenen Vektorsuche auf, die bereits aktiv ist — es fehlen nur noch die Werkzeug-Hüllen.
Verfall und Pflege
Der Wissensgraph ist nicht rein anfügend. Veraltetes Wissen verliert mit der Zeit an Wert.
Konfidenzverfall (ausgeliefert)
Ein geplanter Convex-Cron läuft alle 24 Stunden — crons.interval('knowledge graph maintenance', { hours: 24 }, internal.knowledge.maintenance.runDecay) (apps/api/convex/crons.ts). Jeder runDecay-Durchlauf verarbeitet einen Stapel von Einträgen und tut zwei Dinge (apps/api/convex/knowledge/maintenance.ts):
- Ablauf — Einträge löschen, die ihren
expiresAt-Zeitstempel überschritten haben, wobei zuvor ihre Relationen abgebaut werden (das Löschen von Relationen ist je Eintrag gedeckelt, damit ein stark verbundener Knoten das Transaktionsbudget nicht sprengt; Übriggebliebenes wird beim nächsten Lauf abgeholt) - Zeitverfall — die
confidencejedes Eintrags anhand seiner typbezogenen Verfallsrate und der Tage seitlastValidatedAtsenken, mit einer Untergrenze von0.1. Der Verfallsfaktor wird durch einen Nutzungsaktualitäts-Schub moduliert (überaccessCount/lastAccessedAt, die bei jedem Abruf eines Eintrags hochgezählt werden), sodass häufig abgerufene Einträge langsamer verfallen
Ein separater täglicher Dedup-Merge-Cron (knowledge graph dedup → runKnowledgeDedup) läuft neben runDecay — siehe den Abschnitt Extraktion weiter oben.
Zwei weitere Pflegeverhalten sind entworfen, aber nicht implementiert: die Widerspruchsauflösung (Markieren der älteren Seite eines contradicts-Paars) und der Validierungsschub (Anheben der Konfidenz eines Eintrags, wenn der Agent ihn nutzt und ein Mensch den resultierenden Entwurf freigibt).
Wissenstypen verfallen unterschiedlich schnell
| Typ | Verfallsrate (pro Tag) | Begründung |
|---|---|---|
| Fakt | 0,5 % | Fakten wie „der Tarif einer Kundin“ ändern sich selten |
| Entscheidung | 0,2 % | Entscheidungen haben Bestand, sofern sie nicht ausdrücklich zurückgenommen werden |
| Ereignis | keine | Historische Ereignisse werden mit der Zeit nicht weniger wahr |
| Präferenz | 1,5 % | Präferenzen entwickeln sich mit der Beziehung weiter |
| Ziel | 3 % | Ziele haben Fristen und verschieben sich häufig |
| Beziehung | 1 % | Organisationsstrukturen ändern sich |
| Aufgabe | 5 % | Zusagen haben Fristen und erledigen sich schnell |
Schema
Definiert in apps/api/convex/schema/knowledge.ts.
knowledgeEntries
knowledgeEntries: defineTable({
entryType: v.union(
v.literal('fact'),
v.literal('decision'),
v.literal('event'),
v.literal('preference'),
v.literal('goal'),
v.literal('relationship'),
v.literal('action_item')
),
title: v.string(),
content: v.string(),
sourceType: v.union(
v.literal('email'),
v.literal('chat'),
v.literal('manual'),
v.literal('file'),
v.literal('agent_extracted')
),
sourceId: v.optional(v.string()),
contactIds: v.optional(v.array(v.id('contacts'))),
threadId: v.optional(v.id('conversationThreads')),
embedding: v.array(v.float64()), // 1536 dims (text-embedding-3-small)
embeddingModel: v.optional(v.string()),
embeddingGeneratedAt: v.optional(v.number()),
confidence: v.number(), // 0-1
lastValidatedAt: v.number(),
accessCount: v.optional(v.number()), // usage signal — bumped on recall
lastAccessedAt: v.optional(v.number()), // usage signal — modulates decay
expiresAt: v.optional(v.number()),
tags: v.optional(v.array(v.string())),
searchableText: v.optional(v.string()),
contentHash: v.optional(v.string()), // sha256 fingerprint — cross-source dedup leg in saveEntry
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_entry_type', ['entryType'])
.index('by_created_at', ['createdAt'])
.index('by_thread', ['threadId'])
.index('by_source', ['sourceType', 'sourceId'])
.index('by_content_hash', ['contentHash'])
.searchIndex('search_knowledge', {
searchField: 'searchableText',
filterFields: ['entryType'],
})
.vectorIndex('vector_knowledge', {
vectorField: 'embedding',
dimensions: 1536,
filterFields: ['entryType'],
})
knowledgeRelations
knowledgeRelations: defineTable({
fromEntryId: v.id('knowledgeEntries'),
toEntryId: v.id('knowledgeEntries'),
relationType: v.union(
v.literal('supports'),
v.literal('contradicts'),
v.literal('supersedes'),
v.literal('relates_to'),
v.literal('causes'),
v.literal('blocks')
),
confidenceTag: v.union( // coarse, sortable companion to `confidence`
v.literal('extracted'),
v.literal('inferred'),
v.literal('ambiguous')
),
confidence: v.number(), // 0-1
weight: v.optional(v.number()), // edge strength for graph-augmented retrieval
provenance: v.union( // strongest first: manual > deterministic > llm
v.literal('deterministic'),
v.literal('llm'),
v.literal('manual')
),
rationale: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_from', ['fromEntryId'])
.index('by_to', ['toEntryId'])
.index('by_pair', ['fromEntryId', 'toEntryId'])
.index('by_confidence_tag', ['confidenceTag'])