A search asking for more than 10 passages got at most 40 candidates from HNSW, pgvector's default ef_search. The full-text arm scored every chunk holding a word of the question before limiting, over the whole organization's GIN index; it now takes candidates by ts_rank from the collection's own partial index and scores those. New collections index halfvec on pgvector 0.7 and later (up to 4000 dimensions). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1148 lines
40 KiB
Go
1148 lines
40 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
)
|
||
|
||
// Knowledge bases (BACKLOG F): collections of documents, split into chunks
|
||
// and embedded, that a request is answered from.
|
||
//
|
||
// Which tenant may read which chunk is decided twice. The queries here filter
|
||
// by organization and by the collections the caller may read, and the table
|
||
// enforces the same with row-level security against two settings each
|
||
// transaction makes — so a query that forgot its filter returns nothing
|
||
// rather than another tenant's passages. The role llmgw connects as owns the
|
||
// table, so the policy is FORCEd; a superuser would bypass it, which is one
|
||
// more reason the store never connects as one.
|
||
|
||
// ErrKnowledgeOff is a store that cannot hold knowledge bases.
|
||
var ErrKnowledgeOff = errors.New("knowledge bases are off")
|
||
|
||
// MaxEmbeddingDims is the most dimensions pgvector can index with HNSW: on
|
||
// vector; halfvec takes up to MaxHalfvecDims.
|
||
const MaxEmbeddingDims = 2000
|
||
|
||
// MaxHalfvecDims is the most dimensions an HNSW index on halfvec takes.
|
||
const MaxHalfvecDims = 4000
|
||
|
||
// KBPerm is what a grant allows.
|
||
type KBPerm string
|
||
|
||
// The permissions. Write includes read.
|
||
const (
|
||
KBRead KBPerm = "read"
|
||
KBWrite KBPerm = "write"
|
||
)
|
||
|
||
// KBCollection is a set of documents with one owner and one embedding model.
|
||
type KBCollection struct {
|
||
ID string
|
||
OrgID string
|
||
OwnerScope Scope
|
||
OwnerID string
|
||
Name string
|
||
EmbedModel string
|
||
Dims int
|
||
// VecType is how its vectors are indexed: vector, or halfvec where
|
||
// pgvector has it (0.7 and later), at half the index's memory.
|
||
VecType string
|
||
CreatedAt time.Time
|
||
CreatedBy string
|
||
}
|
||
|
||
// KBGrant lets a scope, and everything below it, read or write a collection.
|
||
type KBGrant struct {
|
||
Scope Scope
|
||
ScopeID string
|
||
Collection string
|
||
Perm KBPerm
|
||
AddedAt time.Time
|
||
AddedBy string
|
||
}
|
||
|
||
// KBDocument is a document as added to a collection.
|
||
type KBDocument struct {
|
||
ID string
|
||
Collection string
|
||
Name string
|
||
Digest string
|
||
Bytes int64
|
||
Chunks int
|
||
AddedAt time.Time
|
||
AddedBy string
|
||
}
|
||
|
||
// KBChunk is a piece of a document and its embedding.
|
||
type KBChunk struct {
|
||
Body string
|
||
Embedding []float32
|
||
}
|
||
|
||
// KBHit is a chunk a search returned, best first.
|
||
type KBHit struct {
|
||
Collection string
|
||
DocID string
|
||
Document string
|
||
Ord int
|
||
Body string
|
||
// Score is the cosine similarity to the question, plus up to
|
||
// textWeight for a full-text match: higher is better, and only the
|
||
// order is meant to be compared.
|
||
Score float64
|
||
}
|
||
|
||
// KnowledgeAvailable says whether this store can hold knowledge bases, and
|
||
// why not when it cannot.
|
||
func (s *DB) KnowledgeAvailable() error {
|
||
if s.dialect != PostgresDialect {
|
||
return fmt.Errorf("%w: they need the PostgreSQL store (with pgvector), and this one is SQLite", ErrKnowledgeOff)
|
||
}
|
||
return s.kbErr
|
||
}
|
||
|
||
// ensureKnowledge creates kb_chunks when the vector extension allows it.
|
||
//
|
||
// At open rather than in a migration: a migration that needs an extension the
|
||
// database servers do not have would stop the control plane from starting,
|
||
// and one that skipped it would be recorded as applied and never run again
|
||
// once the extension arrived.
|
||
func (s *DB) ensureKnowledge(ctx context.Context) error {
|
||
var have bool
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')`).Scan(&have); err != nil {
|
||
return fmt.Errorf("%w: reading the database's extensions: %v", ErrKnowledgeOff, err)
|
||
}
|
||
if !have {
|
||
var available bool
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector')`).Scan(&available); err != nil {
|
||
return fmt.Errorf("%w: reading the database's extensions: %v", ErrKnowledgeOff, err)
|
||
}
|
||
if !available {
|
||
return fmt.Errorf("%w: pgvector is not installed on the database servers "+
|
||
"(package postgresql-<version>-pgvector on every one; llmgw's database setup installs it)", ErrKnowledgeOff)
|
||
}
|
||
// Works only where the extension is trusted or llmgw's role may
|
||
// create it; elsewhere a superuser has to, once.
|
||
if _, err := s.db.ExecContext(ctx, `CREATE EXTENSION IF NOT EXISTS vector`); err != nil {
|
||
return fmt.Errorf("%w: pgvector is installed but not enabled in llmgw's database; "+
|
||
"as postgres, run CREATE EXTENSION vector in it (%v)", ErrKnowledgeOff, err)
|
||
}
|
||
}
|
||
var version string
|
||
if err := s.db.QueryRowContext(ctx, `SELECT extversion FROM pg_extension WHERE extname = 'vector'`).Scan(&version); err == nil {
|
||
s.kbHalfvec = versionAtLeast(version, 0, 7)
|
||
}
|
||
var exists bool
|
||
if err := s.db.QueryRowContext(ctx, `SELECT to_regclass('kb_chunks') IS NOT NULL`).Scan(&exists); err != nil {
|
||
return fmt.Errorf("%w: %v", ErrKnowledgeOff, err)
|
||
}
|
||
if exists {
|
||
s.kbBackfillIndexes(ctx)
|
||
return nil
|
||
}
|
||
err := s.inTx(ctx, func(c conn) error {
|
||
for _, q := range []string{
|
||
// Partitioned by organization: each one's indexes stay its own
|
||
// size, and an organization's chunks go with one DROP.
|
||
`CREATE TABLE kb_chunks (
|
||
org_id TEXT NOT NULL,
|
||
collection TEXT NOT NULL,
|
||
doc_id TEXT NOT NULL REFERENCES kb_documents(id) ON DELETE CASCADE,
|
||
ord INTEGER NOT NULL,
|
||
body TEXT NOT NULL,
|
||
tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED,
|
||
-- No dimension here: collections differ. Each collection's
|
||
-- index casts to its own (kbVectorIndex).
|
||
embedding vector NOT NULL,
|
||
PRIMARY KEY (org_id, doc_id, ord)
|
||
) PARTITION BY LIST (org_id)`,
|
||
`CREATE INDEX kb_chunks_tsv ON kb_chunks USING gin (tsv)`,
|
||
`ALTER TABLE kb_chunks ENABLE ROW LEVEL SECURITY`,
|
||
`ALTER TABLE kb_chunks FORCE ROW LEVEL SECURITY`,
|
||
`CREATE POLICY kb_scope ON kb_chunks
|
||
USING (org_id = current_setting('llmgw.kb_org', true)
|
||
AND collection = ANY (string_to_array(current_setting('llmgw.kb_collections', true), ',')))
|
||
WITH CHECK (org_id = current_setting('llmgw.kb_org', true)
|
||
AND collection = ANY (string_to_array(current_setting('llmgw.kb_collections', true), ',')))`,
|
||
} {
|
||
if _, err := c.ExecContext(ctx, q); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
// Another control plane opening at the same moment made it first.
|
||
if e2 := s.db.QueryRowContext(ctx, `SELECT to_regclass('kb_chunks') IS NOT NULL`).Scan(&exists); e2 == nil && exists {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("%w: creating kb_chunks: %v", ErrKnowledgeOff, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// kbIDPattern is what a collection id looks like (NewID("kb_")); ids are put
|
||
// into DDL and partial-index predicates, so nothing else is let through.
|
||
var kbIDPattern = regexp.MustCompile(`^kb_[a-z2-7]{16}$`)
|
||
|
||
// kbPartition is the chunk partition of an organization. Hashed, because old
|
||
// organization ids were slugs of names and could hold anything.
|
||
func kbPartition(orgID string) string {
|
||
h := sha256.Sum256([]byte(orgID))
|
||
return "kb_chunks_" + hex.EncodeToString(h[:12])
|
||
}
|
||
|
||
// kbVectorIndex is the name of a collection's vector index.
|
||
func kbVectorIndex(collection string) string {
|
||
return "kb_vec_" + strings.TrimPrefix(collection, "kb_")
|
||
}
|
||
|
||
// kbTextIndex is the name of a collection's full-text index.
|
||
func kbTextIndex(collection string) string { return "kb_tsv_" + strings.TrimPrefix(collection, "kb_") }
|
||
|
||
// cast is the type a collection's vectors are compared as, which its index
|
||
// is built on: the query must say the same for the planner to use it.
|
||
func (c KBCollection) cast() string {
|
||
t := c.VecType
|
||
if t != "halfvec" {
|
||
t = "vector"
|
||
}
|
||
return "::" + t + "(" + strconv.Itoa(c.Dims) + ")"
|
||
}
|
||
|
||
// versionAtLeast compares an extension's "major.minor.patch".
|
||
func versionAtLeast(v string, major, minor int) bool {
|
||
parts := strings.SplitN(v, ".", 3)
|
||
if len(parts) < 2 {
|
||
return false
|
||
}
|
||
ma, err1 := strconv.Atoi(parts[0])
|
||
mi, err2 := strconv.Atoi(parts[1])
|
||
if err1 != nil || err2 != nil {
|
||
return false
|
||
}
|
||
return ma > major || ma == major && mi >= minor
|
||
}
|
||
|
||
// kbBackfillIndexes gives collections made before their full-text index
|
||
// existed one. Best effort, at open: a search still works without it.
|
||
func (s *DB) kbBackfillIndexes(ctx context.Context) {
|
||
cs, err := queryAll(ctx, s.db, `SELECT id, org_id FROM kb_collections`, nil, func(r *sql.Rows) ([2]string, error) {
|
||
var x [2]string
|
||
return x, r.Scan(&x[0], &x[1])
|
||
})
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, c := range cs {
|
||
if !kbIDPattern.MatchString(c[0]) {
|
||
continue
|
||
}
|
||
_, _ = s.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS `+kbTextIndex(c[0])+` ON `+kbPartition(c[1])+
|
||
` USING gin (tsv) WHERE collection = `+sqlLiteral(c[0]))
|
||
}
|
||
}
|
||
|
||
func sqlLiteral(v string) string { return "'" + strings.ReplaceAll(v, "'", "''") + "'" }
|
||
|
||
// kbScope sets, for this transaction, which rows of kb_chunks it may touch.
|
||
func kbScope(ctx context.Context, c conn, orgID string, collections []string) error {
|
||
_, err := c.ExecContext(ctx, `SELECT set_config('llmgw.kb_org', ?, true), set_config('llmgw.kb_collections', ?, true)`,
|
||
orgID, strings.Join(collections, ","))
|
||
return err
|
||
}
|
||
|
||
// orgOfScope is the organization a scope sits in. A person has none of their
|
||
// own, so a person is checked against org instead: they must be a member of
|
||
// it, or of something in it.
|
||
func (s *DB) orgOfScope(ctx context.Context, c conn, scope Scope, id, org string) (string, error) {
|
||
var q string
|
||
switch scope {
|
||
case ScopeOrg:
|
||
q = `SELECT id FROM organizations WHERE id = ?`
|
||
case ScopeTeam:
|
||
q = `SELECT org_id FROM teams WHERE id = ?`
|
||
case ScopeProject:
|
||
q = `SELECT t.org_id FROM projects p JOIN teams t ON t.id = p.team_id WHERE p.id = ?`
|
||
case ScopeKey:
|
||
q = `SELECT t.org_id FROM api_keys k JOIN projects p ON p.id = k.project_id
|
||
JOIN teams t ON t.id = p.team_id WHERE k.id = ?`
|
||
case ScopeUser:
|
||
var n int
|
||
err := c.QueryRowContext(ctx, `SELECT COUNT(*) FROM memberships m WHERE m.user_id = ? AND (
|
||
(m.scope_type = 'org' AND m.scope_id = ?)
|
||
OR (m.scope_type = 'team' AND m.scope_id IN (SELECT id FROM teams WHERE org_id = ?))
|
||
OR (m.scope_type = 'project' AND m.scope_id IN (
|
||
SELECT p.id FROM projects p JOIN teams t ON t.id = p.team_id WHERE t.org_id = ?)))`,
|
||
id, org, org, org).Scan(&n)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if n == 0 {
|
||
return "", fmt.Errorf("%s is not a member of anything in that organization", id)
|
||
}
|
||
return org, nil
|
||
default:
|
||
return "", fmt.Errorf("a collection cannot be owned by or granted to a %s", scope)
|
||
}
|
||
var got string
|
||
err := c.QueryRowContext(ctx, q, id).Scan(&got)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
if scope == ScopeKey {
|
||
return "", fmt.Errorf("no key %s in a project (a personal key reads no collection)", id)
|
||
}
|
||
return "", fmt.Errorf("no %s %s: %w", scope, id, ErrNotFound)
|
||
}
|
||
return got, err
|
||
}
|
||
|
||
// CreateCollection makes an empty collection, its organization's chunk
|
||
// partition when this is the first, and its vector index. c.OrgID is needed
|
||
// only when a person owns it; otherwise it comes from the owner.
|
||
func (s *DB) CreateCollection(ctx context.Context, c KBCollection) (KBCollection, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return KBCollection{}, err
|
||
}
|
||
c.Name = strings.TrimSpace(c.Name)
|
||
switch {
|
||
case c.Name == "":
|
||
return KBCollection{}, errors.New("a collection needs a name")
|
||
case c.EmbedModel == "":
|
||
return KBCollection{}, errors.New("a collection needs the embedding model its documents are embedded with")
|
||
case c.Dims < 1 || c.Dims > MaxHalfvecDims || c.Dims > MaxEmbeddingDims && !s.kbHalfvec:
|
||
limit := MaxEmbeddingDims
|
||
if s.kbHalfvec {
|
||
limit = MaxHalfvecDims
|
||
}
|
||
return KBCollection{}, fmt.Errorf("%d dimensions: an indexed embedding has 1 to %d", c.Dims, limit)
|
||
case c.OwnerScope == ScopeUser && c.OrgID == "":
|
||
return KBCollection{}, errors.New("a person's collection names the organization it belongs to")
|
||
case c.OwnerScope == ScopeKey:
|
||
return KBCollection{}, errors.New("a key cannot own a collection; its project or its person can")
|
||
}
|
||
c.ID = NewID("kb_")
|
||
c.VecType = "vector"
|
||
if s.kbHalfvec {
|
||
c.VecType = "halfvec"
|
||
}
|
||
if c.CreatedAt.IsZero() {
|
||
c.CreatedAt = time.Now()
|
||
}
|
||
err := s.inTx(ctx, func(tx conn) error {
|
||
org, err := s.orgOfScope(ctx, tx, c.OwnerScope, c.OwnerID, c.OrgID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.OrgID != "" && c.OrgID != org {
|
||
return fmt.Errorf("the owner is in organization %s, not %s", org, c.OrgID)
|
||
}
|
||
c.OrgID = org
|
||
var n int
|
||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM kb_collections WHERE org_id = ? AND name = ?`,
|
||
c.OrgID, c.Name).Scan(&n); err != nil {
|
||
return err
|
||
}
|
||
if n > 0 {
|
||
return fmt.Errorf("%w: collection %q", ErrDuplicateName, c.Name)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO kb_collections
|
||
(id, org_id, owner_scope, owner_id, name, embed_model, dims, vec_type, created_at, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
c.ID, c.OrgID, string(c.OwnerScope), c.OwnerID, c.Name, c.EmbedModel, c.Dims, c.VecType,
|
||
millis(c.CreatedAt), c.CreatedBy); err != nil {
|
||
return err
|
||
}
|
||
part := kbPartition(c.OrgID)
|
||
if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS `+part+
|
||
` PARTITION OF kb_chunks FOR VALUES IN (`+sqlLiteral(c.OrgID)+`)`); err != nil {
|
||
return err
|
||
}
|
||
// Partial indexes per collection, on its organization's partition
|
||
// only: the dimension is the collection's, and a search, which
|
||
// names one collection at a time, uses exactly these — a text
|
||
// search over one collection does not walk its whole
|
||
// organization's index.
|
||
if _, err := tx.ExecContext(ctx, `CREATE INDEX `+kbVectorIndex(c.ID)+` ON `+part+
|
||
` USING hnsw ((embedding`+c.cast()+`) `+c.VecType+`_cosine_ops)`+
|
||
` WHERE collection = `+sqlLiteral(c.ID)); err != nil {
|
||
return err
|
||
}
|
||
_, err = tx.ExecContext(ctx, `CREATE INDEX `+kbTextIndex(c.ID)+` ON `+part+
|
||
` USING gin (tsv) WHERE collection = `+sqlLiteral(c.ID))
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return KBCollection{}, err
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
const kbCollectionCols = `id, org_id, owner_scope, owner_id, name, embed_model, dims, vec_type, created_at, created_by`
|
||
|
||
func scanCollection(sc scanner) (KBCollection, error) {
|
||
var c KBCollection
|
||
var scope string
|
||
var at int64
|
||
err := sc.Scan(&c.ID, &c.OrgID, &scope, &c.OwnerID, &c.Name, &c.EmbedModel, &c.Dims, &c.VecType, &at, &c.CreatedBy)
|
||
c.OwnerScope, c.CreatedAt = Scope(scope), fromMillis(at)
|
||
return c, err
|
||
}
|
||
|
||
// Collections lists every collection, by organization and name.
|
||
func (s *DB) Collections(ctx context.Context) ([]KBCollection, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
return queryAll(ctx, s.db, `SELECT `+kbCollectionCols+` FROM kb_collections ORDER BY org_id, name`, nil,
|
||
func(r *sql.Rows) (KBCollection, error) { return scanCollection(r) })
|
||
}
|
||
|
||
// Collection reads one collection.
|
||
func (s *DB) Collection(ctx context.Context, id string) (KBCollection, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return KBCollection{}, err
|
||
}
|
||
c, err := scanCollection(s.db.QueryRowContext(ctx, `SELECT `+kbCollectionCols+` FROM kb_collections WHERE id = ?`, id))
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return KBCollection{}, ErrNotFound
|
||
}
|
||
return c, err
|
||
}
|
||
|
||
// DeleteCollection removes a collection, its documents, chunks, grants and
|
||
// index.
|
||
func (s *DB) DeleteCollection(ctx context.Context, id string) error {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return err
|
||
}
|
||
if !kbIDPattern.MatchString(id) {
|
||
return ErrNotFound
|
||
}
|
||
return s.inTx(ctx, func(c conn) error {
|
||
// Chunks go with their documents (a foreign-key cascade, which row
|
||
// security does not stand in the way of), documents and grants with
|
||
// the collection.
|
||
res, err := c.ExecContext(ctx, `DELETE FROM kb_collections WHERE id = ?`, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
// Out of every default that named it; a default left empty goes.
|
||
if _, err := c.ExecContext(ctx, `UPDATE kb_defaults SET collections =
|
||
trim(both ',' from replace(',' || collections || ',', ',' || ? || ',', ','))`, id); err != nil {
|
||
return err
|
||
}
|
||
if _, err := c.ExecContext(ctx, `DELETE FROM kb_defaults WHERE collections = ''`); err != nil {
|
||
return err
|
||
}
|
||
if _, err := c.ExecContext(ctx, `DROP INDEX IF EXISTS `+kbVectorIndex(id)); err != nil {
|
||
return err
|
||
}
|
||
_, err = c.ExecContext(ctx, `DROP INDEX IF EXISTS `+kbTextIndex(id))
|
||
return err
|
||
})
|
||
}
|
||
|
||
// Grant lets a scope read or write a collection; granting again replaces the
|
||
// permission. The scope must be in the collection's organization.
|
||
func (s *DB) Grant(ctx context.Context, g KBGrant) error {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return err
|
||
}
|
||
if g.Perm != KBRead && g.Perm != KBWrite {
|
||
return fmt.Errorf("permission %q: read or write", g.Perm)
|
||
}
|
||
if g.AddedAt.IsZero() {
|
||
g.AddedAt = time.Now()
|
||
}
|
||
return s.inTx(ctx, func(c conn) error {
|
||
var org string
|
||
err := c.QueryRowContext(ctx, `SELECT org_id FROM kb_collections WHERE id = ?`, g.Collection).Scan(&org)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return fmt.Errorf("collection %s: %w", g.Collection, ErrNotFound)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
got, err := s.orgOfScope(ctx, c, g.Scope, g.ScopeID, org)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if got != org {
|
||
return fmt.Errorf("%s %s is in another organization; a collection is never granted outside its own", g.Scope, g.ScopeID)
|
||
}
|
||
_, err = c.ExecContext(ctx, `INSERT INTO kb_grants (scope, scope_id, collection, perm, added_at, added_by)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT (scope, scope_id, collection) DO UPDATE SET perm = excluded.perm,
|
||
added_at = excluded.added_at, added_by = excluded.added_by`,
|
||
string(g.Scope), g.ScopeID, g.Collection, string(g.Perm), millis(g.AddedAt), g.AddedBy)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// Revoke removes a grant, reporting whether it was there.
|
||
func (s *DB) Revoke(ctx context.Context, scope Scope, scopeID, collection string) (bool, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return false, err
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM kb_grants WHERE scope = ? AND scope_id = ? AND collection = ?`,
|
||
string(scope), scopeID, collection)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
return n > 0, nil
|
||
}
|
||
|
||
// Grants lists a collection's grants, or every grant when collection is "".
|
||
func (s *DB) Grants(ctx context.Context, collection string) ([]KBGrant, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
q, args := `SELECT scope, scope_id, collection, perm, added_at, added_by FROM kb_grants`, []any(nil)
|
||
if collection != "" {
|
||
q, args = q+` WHERE collection = ?`, []any{collection}
|
||
}
|
||
return queryAll(ctx, s.db, q+` ORDER BY collection, scope, scope_id`, args, func(r *sql.Rows) (KBGrant, error) {
|
||
var g KBGrant
|
||
var scope, perm string
|
||
var at int64
|
||
err := r.Scan(&scope, &g.ScopeID, &g.Collection, &perm, &at, &g.AddedBy)
|
||
g.Scope, g.Perm, g.AddedAt = Scope(scope), KBPerm(perm), fromMillis(at)
|
||
return g, err
|
||
})
|
||
}
|
||
|
||
// chainScopes is a caller's place in the tree, widest first.
|
||
func chainScopes(e Effective) [][2]string {
|
||
var out [][2]string
|
||
add := func(s Scope, id string) {
|
||
if id != "" {
|
||
out = append(out, [2]string{string(s), id})
|
||
}
|
||
}
|
||
add(ScopeOrg, e.OrgID)
|
||
add(ScopeTeam, e.TeamID)
|
||
add(ScopeProject, e.ProjectID)
|
||
add(ScopeUser, e.UserID)
|
||
// The chat page's caller is a session, not a key row; its id matches no
|
||
// key grant, which is as it should be.
|
||
add(ScopeKey, e.KeyID)
|
||
return out
|
||
}
|
||
|
||
// CollectionsFor lists the collections a caller may read, or with write
|
||
// those it may change. A caller outside any organization — a personal key
|
||
// that named no project — may read none: joining a second team must not
|
||
// widen what every key of that person reads.
|
||
func (s *DB) CollectionsFor(ctx context.Context, e Effective, write bool) ([]KBCollection, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
if e.OrgID == "" {
|
||
return nil, nil
|
||
}
|
||
chain := chainScopes(e)
|
||
var match []string
|
||
args := []any{e.OrgID}
|
||
for _, sc := range chain {
|
||
match = append(match, `(c.owner_scope = ? AND c.owner_id = ?)`)
|
||
args = append(args, sc[0], sc[1])
|
||
}
|
||
var grant []string
|
||
for _, sc := range chain {
|
||
grant = append(grant, `(g.scope = ? AND g.scope_id = ?)`)
|
||
args = append(args, sc[0], sc[1])
|
||
}
|
||
perm := ""
|
||
if write {
|
||
perm = ` AND g.perm = 'write'`
|
||
}
|
||
q := `SELECT ` + prefixCols("c.", kbCollectionCols) + ` FROM kb_collections c WHERE c.org_id = ? AND (` +
|
||
strings.Join(match, " OR ") + ` OR EXISTS (SELECT 1 FROM kb_grants g WHERE g.collection = c.id` + perm +
|
||
` AND (` + strings.Join(grant, " OR ") + `))) ORDER BY c.name`
|
||
return queryAll(ctx, s.db, q, args, func(r *sql.Rows) (KBCollection, error) { return scanCollection(r) })
|
||
}
|
||
|
||
func prefixCols(p, cols string) string {
|
||
parts := strings.Split(cols, ", ")
|
||
for i := range parts {
|
||
parts[i] = p + parts[i]
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
// PutDocument adds a document to a collection, or replaces the one of the
|
||
// same name, in one transaction: it is searchable whole or not at all. The
|
||
// same digest again changes nothing, and reports so.
|
||
func (s *DB) PutDocument(ctx context.Context, c KBCollection, d KBDocument, chunks []KBChunk) (KBDocument, bool, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return KBDocument{}, false, err
|
||
}
|
||
if !kbIDPattern.MatchString(c.ID) {
|
||
return KBDocument{}, false, fmt.Errorf("collection %s: %w", c.ID, ErrNotFound)
|
||
}
|
||
for i, ch := range chunks {
|
||
if len(ch.Embedding) != c.Dims {
|
||
return KBDocument{}, false, fmt.Errorf("chunk %d has %d dimensions; collection %s takes %d",
|
||
i, len(ch.Embedding), c.Name, c.Dims)
|
||
}
|
||
}
|
||
d.Collection, d.Chunks = c.ID, len(chunks)
|
||
if d.AddedAt.IsZero() {
|
||
d.AddedAt = time.Now()
|
||
}
|
||
changed := true
|
||
err := s.inTx(ctx, func(tx conn) error {
|
||
var oldID, oldDigest string
|
||
err := tx.QueryRowContext(ctx, `SELECT id, digest FROM kb_documents WHERE collection = ? AND name = ?`,
|
||
c.ID, d.Name).Scan(&oldID, &oldDigest)
|
||
switch {
|
||
case err == nil && oldDigest == d.Digest:
|
||
changed = false
|
||
return nil
|
||
case err == nil:
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM kb_documents WHERE id = ?`, oldID); err != nil {
|
||
return err
|
||
}
|
||
case !errors.Is(err, sql.ErrNoRows):
|
||
return err
|
||
}
|
||
d.ID = NewID("doc_")
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO kb_documents
|
||
(id, collection, name, digest, bytes, chunks, added_at, added_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
d.ID, c.ID, d.Name, d.Digest, d.Bytes, d.Chunks, millis(d.AddedAt), d.AddedBy); err != nil {
|
||
return err
|
||
}
|
||
if err := kbScope(ctx, tx, c.OrgID, []string{c.ID}); err != nil {
|
||
return err
|
||
}
|
||
const batch = 100
|
||
for start := 0; start < len(chunks); start += batch {
|
||
end := min(start+batch, len(chunks))
|
||
var vals []string
|
||
var args []any
|
||
for i := start; i < end; i++ {
|
||
vals = append(vals, `(?, ?, ?, ?, ?, ?::vector)`)
|
||
args = append(args, c.OrgID, c.ID, d.ID, i, chunks[i].Body, vectorText(chunks[i].Embedding))
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO kb_chunks (org_id, collection, doc_id, ord, body, embedding) VALUES `+
|
||
strings.Join(vals, ", "), args...); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return KBDocument{}, false, err
|
||
}
|
||
return d, changed, nil
|
||
}
|
||
|
||
// vectorText is pgvector's text form of an embedding.
|
||
func vectorText(v []float32) string {
|
||
var b strings.Builder
|
||
b.Grow(len(v) * 10)
|
||
b.WriteByte('[')
|
||
for i, f := range v {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
b.WriteString(strconv.FormatFloat(float64(f), 'g', -1, 32))
|
||
}
|
||
b.WriteByte(']')
|
||
return b.String()
|
||
}
|
||
|
||
// Documents lists a collection's documents, by name.
|
||
func (s *DB) Documents(ctx context.Context, collection string) ([]KBDocument, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
return queryAll(ctx, s.db, `SELECT id, collection, name, digest, bytes, chunks, added_at, added_by
|
||
FROM kb_documents WHERE collection = ? ORDER BY name`, []any{collection},
|
||
func(r *sql.Rows) (KBDocument, error) {
|
||
var d KBDocument
|
||
var at int64
|
||
err := r.Scan(&d.ID, &d.Collection, &d.Name, &d.Digest, &d.Bytes, &d.Chunks, &at, &d.AddedBy)
|
||
d.AddedAt = fromMillis(at)
|
||
return d, err
|
||
})
|
||
}
|
||
|
||
// DeleteDocument removes a document and its chunks, reporting whether it was
|
||
// there.
|
||
func (s *DB) DeleteDocument(ctx context.Context, collection, name string) (bool, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return false, err
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM kb_documents WHERE collection = ? AND name = ?`, collection, name)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
return n > 0, nil
|
||
}
|
||
|
||
// KBSearch is one search: the caller's organization, the collections it may
|
||
// read and asked for, the query embedded by each collection's model, and the
|
||
// query's text for the full-text arm.
|
||
type KBSearch struct {
|
||
OrgID string
|
||
Collections []KBCollection
|
||
// Embeddings is the query embedded by each model, keyed by model name.
|
||
Embeddings map[string][]float32
|
||
Text string
|
||
K int
|
||
}
|
||
|
||
// textWeight is how much a full-text match adds to a chunk's similarity to
|
||
// the question, at most. The embedding model reads what a question means;
|
||
// full text is there for what it cannot — a code, a name, a number. Fused by
|
||
// rank, as first built, the two were equals and the gap between a close
|
||
// meaning and a distant one was thrown away: "how much leave do fathers
|
||
// get" found the annual-leave page over the paternity one on the PoC,
|
||
// though the paternity page was far nearer (0.25 against 0.44).
|
||
const textWeight = 0.3
|
||
|
||
// candidates is how many each search brings to the fusion for k results:
|
||
// with only k from each, the meaning search's third-best never met the
|
||
// text search's first, ties were broken by id, and the order was luck.
|
||
func candidates(k int) int { return max(4*k, 20) }
|
||
|
||
// SearchChunks finds the best chunks for a query across collections: a
|
||
// nearest-neighbour search and a full-text search in each, put on one scale. Every collection must be in OrgID; the caller decides
|
||
// which ones the requester may read, and the table refuses rows outside them
|
||
// regardless.
|
||
func (s *DB) SearchChunks(ctx context.Context, q KBSearch) ([]KBHit, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
if q.K <= 0 || len(q.Collections) == 0 || q.OrgID == "" {
|
||
return nil, nil
|
||
}
|
||
ids := make([]string, 0, len(q.Collections))
|
||
for _, c := range q.Collections {
|
||
if c.OrgID != q.OrgID || !kbIDPattern.MatchString(c.ID) {
|
||
return nil, fmt.Errorf("collection %s is not in organization %s", c.ID, q.OrgID)
|
||
}
|
||
ids = append(ids, c.ID)
|
||
}
|
||
type key struct {
|
||
doc string
|
||
ord int
|
||
}
|
||
type found struct {
|
||
hit KBHit
|
||
// sim is the cosine similarity to the question, when the question
|
||
// was embedded for this chunk's collection; text is the share of
|
||
// the question's words it holds, 0 when the text search did not
|
||
// find it.
|
||
sim, text float64
|
||
hasSim bool
|
||
}
|
||
hits := map[key]*found{}
|
||
add := func(h KBHit, sim sql.NullFloat64, text float64) {
|
||
k := key{h.DocID, h.Ord}
|
||
f, ok := hits[k]
|
||
if !ok {
|
||
f = &found{hit: h}
|
||
hits[k] = f
|
||
}
|
||
if sim.Valid {
|
||
f.sim, f.hasSim = sim.Float64, true
|
||
}
|
||
f.text = max(f.text, text)
|
||
}
|
||
query := func(tx conn, text bool, q string, args ...any) error {
|
||
rows, err := tx.QueryContext(ctx, q, args...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var h KBHit
|
||
var sim sql.NullFloat64
|
||
var rank float64
|
||
if err := rows.Scan(&h.Collection, &h.DocID, &h.Document, &h.Ord, &h.Body, &sim, &rank); err != nil {
|
||
return err
|
||
}
|
||
if !text {
|
||
rank = 0
|
||
}
|
||
add(h, sim, rank)
|
||
}
|
||
return rows.Err()
|
||
}
|
||
terms := queryTerms(q.Text)
|
||
tsq := strings.Join(terms, " | ")
|
||
// coverage is the share of the question's words a chunk holds: one
|
||
// everyday word in common is a little evidence, all of them a lot.
|
||
var coverage string
|
||
if len(terms) > 0 {
|
||
parts := make([]string, len(terms))
|
||
for i := range terms {
|
||
parts[i] = `(k.tsv @@ to_tsquery('simple', ?))::int`
|
||
}
|
||
coverage = `(` + strings.Join(parts, " + ") + `)::float8 / ` + strconv.Itoa(len(terms))
|
||
}
|
||
err := s.inTx(ctx, func(tx conn) error {
|
||
if err := kbScope(ctx, tx, q.OrgID, ids); err != nil {
|
||
return err
|
||
}
|
||
// pgvector's HNSW search returns at most ef_search rows (40 by
|
||
// default), whatever LIMIT asks: more candidates need it raised.
|
||
if len(q.Embeddings) > 0 {
|
||
ef := min(max(candidates(q.K), 40), 1000)
|
||
if _, err := tx.ExecContext(ctx, `SET LOCAL hnsw.ef_search = `+strconv.Itoa(ef)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, c := range q.Collections {
|
||
// The literal collection and cast are what let the planner use
|
||
// that collection's partial index.
|
||
lit := sqlLiteral(c.ID)
|
||
sim, args := "NULL::float8", []any{}
|
||
v, embedded := q.Embeddings[c.EmbedModel]
|
||
if embedded {
|
||
if len(v) != c.Dims {
|
||
return fmt.Errorf("the query has %d dimensions; collection %s takes %d", len(v), c.Name, c.Dims)
|
||
}
|
||
cast := c.cast()
|
||
sim = `1 - (k.embedding` + cast + ` <=> ?` + cast + `)`
|
||
args = append(args, vectorText(v))
|
||
if err := query(tx, false, `SELECT k.collection, k.doc_id, d.name, k.ord, k.body, `+sim+`, 0::float8
|
||
FROM kb_chunks k JOIN kb_documents d ON d.id = k.doc_id
|
||
WHERE k.org_id = ? AND k.collection = `+lit+`
|
||
ORDER BY k.embedding`+cast+` <=> ?`+cast+` LIMIT ?`,
|
||
vectorText(v), q.OrgID, vectorText(v), candidates(q.K)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if tsq != "" {
|
||
// The text search's finds are measured against the question's
|
||
// meaning too, so the two are compared on one scale.
|
||
// Candidates by the cheap rank first, from the collection's
|
||
// own index; the share of words held and the similarity are
|
||
// worked out for those alone, not for every chunk that holds
|
||
// one word of the question.
|
||
targs := append([]any{}, args...)
|
||
for _, t := range terms {
|
||
targs = append(targs, t)
|
||
}
|
||
targs = append(targs, q.OrgID, tsq, tsq, candidates(q.K))
|
||
if err := query(tx, true, `SELECT k.collection, k.doc_id, d.name, k.ord, k.body, `+sim+`, `+coverage+` AS coverage
|
||
FROM (SELECT * FROM kb_chunks k
|
||
WHERE k.org_id = ? AND k.collection = `+lit+` AND k.tsv @@ to_tsquery('simple', ?)
|
||
ORDER BY ts_rank(k.tsv, to_tsquery('simple', ?)) DESC LIMIT ?) k
|
||
JOIN kb_documents d ON d.id = k.doc_id`, targs...); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// One scale: similarity, plus the share of the question's words held,
|
||
// weighted down.
|
||
for _, f := range hits {
|
||
f.hit.Score = f.sim + textWeight*f.text
|
||
}
|
||
out := make([]KBHit, 0, len(hits))
|
||
for _, f := range hits {
|
||
out = append(out, f.hit)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Score != out[j].Score {
|
||
return out[i].Score > out[j].Score
|
||
}
|
||
if out[i].DocID != out[j].DocID {
|
||
return out[i].DocID < out[j].DocID
|
||
}
|
||
return out[i].Ord < out[j].Ord
|
||
})
|
||
if len(out) > q.K {
|
||
out = out[:q.K]
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// stopWords are words nearly every passage has, in Turkish and English:
|
||
// matching on them only ranks whatever is longest first.
|
||
var stopWords = func() map[string]bool {
|
||
m := map[string]bool{}
|
||
for _, w := range strings.Fields(`
|
||
acaba ama ancak artık aslında az bana bazı belki ben beni benim beri bile bir birçok biri birkaç
|
||
biz bize bizi bizim bu buna bunda bundan bunlar bunları bunların bunu bunun burada çok çünkü da
|
||
daha dahi de defa diye en gibi göre hangi hangisi hem hep hepsi her hiç için ile ise kaç kadar
|
||
ki kim kime kimi mı mi mu mü nasıl ne neden nerde nerede nereye niçin niye o olan olarak oldu
|
||
olduğu olsa olur ona ondan onlar onları onların onu onun orada öyle şey şu şuna şunda şundan şunu
|
||
tüm ve veya ya yani var yok mısın misin musun müsün midir mıdır nedir hangileri lütfen
|
||
a an and are as at be by can do does for from has have how i in is it its me my of on or our
|
||
should that the their there this to was what when where which who why will with you your
|
||
`) {
|
||
m[w] = true
|
||
}
|
||
return m
|
||
}()
|
||
|
||
// orQuery turns a question into a tsquery matching any of its words. All of
|
||
// them (plainto_tsquery) would match almost nothing a person types as a
|
||
// question; the rank then prefers chunks with more of them.
|
||
func orQuery(text string) string { return strings.Join(queryTerms(text), " | ") }
|
||
|
||
// queryTerms are a question's words that say something: not stop words, not
|
||
// single letters, each once whatever its case.
|
||
func queryTerms(text string) []string {
|
||
seen := map[string]bool{}
|
||
var words []string
|
||
// Not lowercased here: to_tsquery does that with the database's own
|
||
// rules, the same ones to_tsvector used on the chunks — Go's lower case
|
||
// of a Turkish İ is not the database's.
|
||
for _, w := range strings.FieldsFunc(text, func(r rune) bool {
|
||
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsMark(r)
|
||
}) {
|
||
low := strings.ToLowerSpecial(unicode.TurkishCase, w)
|
||
if len([]rune(w)) < 2 || seen[low] || stopWords[low] {
|
||
continue
|
||
}
|
||
seen[low] = true
|
||
words = append(words, w)
|
||
if len(words) == 32 {
|
||
break
|
||
}
|
||
}
|
||
return words
|
||
}
|
||
|
||
// kbOwnedBy counts the collections a scope owns, for refusing to delete it
|
||
// from under them. Zero on a store without knowledge bases.
|
||
func (s *DB) kbOwnedBy(ctx context.Context, c conn, scope Scope, id string) (int, error) {
|
||
if s.dialect != PostgresDialect {
|
||
return 0, nil
|
||
}
|
||
var n int
|
||
q := `SELECT COUNT(*) FROM kb_collections WHERE owner_scope = ? AND owner_id = ?`
|
||
args := []any{string(scope), id}
|
||
if scope == ScopeOrg {
|
||
q, args = `SELECT COUNT(*) FROM kb_collections WHERE org_id = ?`, []any{id}
|
||
}
|
||
err := c.QueryRowContext(ctx, q, args...).Scan(&n)
|
||
return n, err
|
||
}
|
||
|
||
// kbForgetScope removes what a deleted scope had: its grants, and for a
|
||
// person the collections they owned.
|
||
func (s *DB) kbForgetScope(ctx context.Context, c conn, scope Scope, id string) error {
|
||
if s.dialect != PostgresDialect {
|
||
return nil
|
||
}
|
||
if _, err := c.ExecContext(ctx, `DELETE FROM kb_grants WHERE scope = ? AND scope_id = ?`, string(scope), id); err != nil {
|
||
return err
|
||
}
|
||
if _, err := c.ExecContext(ctx, `DELETE FROM kb_defaults WHERE scope = ? AND scope_id = ?`, string(scope), id); err != nil {
|
||
return err
|
||
}
|
||
if scope != ScopeUser {
|
||
return nil
|
||
}
|
||
owned, err := queryAll(ctx, c, `SELECT id FROM kb_collections WHERE owner_scope = 'user' AND owner_id = ?`,
|
||
[]any{id}, func(r *sql.Rows) (string, error) {
|
||
var v string
|
||
return v, r.Scan(&v)
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, cid := range owned {
|
||
if _, err := c.ExecContext(ctx, `DELETE FROM kb_collections WHERE id = ?`, cid); err != nil {
|
||
return err
|
||
}
|
||
if kbIDPattern.MatchString(cid) {
|
||
for _, idx := range []string{kbVectorIndex(cid), kbTextIndex(cid)} {
|
||
if _, err := c.ExecContext(ctx, `DROP INDEX IF EXISTS `+idx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// kbDropPartition drops a deleted organization's chunk partition, empty by
|
||
// then: an organization with collections is not deleted.
|
||
func (s *DB) kbDropPartition(ctx context.Context, c conn, scope Scope, id string) error {
|
||
if s.dialect != PostgresDialect || scope != ScopeOrg || s.kbErr != nil {
|
||
return nil
|
||
}
|
||
_, err := c.ExecContext(ctx, `DROP TABLE IF EXISTS `+kbPartition(id))
|
||
return err
|
||
}
|
||
|
||
// OwnerName is how people know a collection's owner: "Acme / hr", a
|
||
// project's path, a person's name. The ID when it cannot be found.
|
||
func (s *DB) OwnerName(ctx context.Context, scope Scope, id string) string {
|
||
var q string
|
||
switch scope {
|
||
case ScopeOrg:
|
||
q = `SELECT name FROM organizations WHERE id = ?`
|
||
case ScopeTeam:
|
||
q = `SELECT o.name || ' / ' || t.name FROM teams t JOIN organizations o ON o.id = t.org_id WHERE t.id = ?`
|
||
case ScopeProject:
|
||
q = `SELECT o.name || ' / ' || t.name || ' / ' || p.name FROM projects p
|
||
JOIN teams t ON t.id = p.team_id JOIN organizations o ON o.id = t.org_id WHERE p.id = ?`
|
||
case ScopeUser:
|
||
q = `SELECT CASE WHEN name <> '' THEN name ELSE email END FROM users WHERE id = ?`
|
||
default:
|
||
return id
|
||
}
|
||
var name string
|
||
if err := s.db.QueryRowContext(ctx, q, id).Scan(&name); err != nil {
|
||
return id
|
||
}
|
||
return name
|
||
}
|
||
|
||
// KBDefault is the collections a scope's requests answer from when they name
|
||
// none.
|
||
type KBDefault struct {
|
||
Scope Scope
|
||
ScopeID string
|
||
Collections []string
|
||
AddedAt time.Time
|
||
AddedBy string
|
||
}
|
||
|
||
// SetKBDefault makes collections a scope's default; none clears it. Each must
|
||
// be in the scope's organization.
|
||
func (s *DB) SetKBDefault(ctx context.Context, d KBDefault) error {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return err
|
||
}
|
||
switch d.Scope {
|
||
case ScopeOrg, ScopeTeam, ScopeProject, ScopeKey:
|
||
default:
|
||
return fmt.Errorf("a default is set on an org, team, project or key, not a %s", d.Scope)
|
||
}
|
||
if len(d.Collections) == 0 {
|
||
_, err := s.db.ExecContext(ctx, `DELETE FROM kb_defaults WHERE scope = ? AND scope_id = ?`, string(d.Scope), d.ScopeID)
|
||
return err
|
||
}
|
||
if d.AddedAt.IsZero() {
|
||
d.AddedAt = time.Now()
|
||
}
|
||
return s.inTx(ctx, func(c conn) error {
|
||
for _, id := range d.Collections {
|
||
var org string
|
||
err := c.QueryRowContext(ctx, `SELECT org_id FROM kb_collections WHERE id = ?`, id).Scan(&org)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return fmt.Errorf("collection %s: %w", id, ErrNotFound)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
got, err := s.orgOfScope(ctx, c, d.Scope, d.ScopeID, org)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if got != org {
|
||
return fmt.Errorf("collection %s is in another organization than %s %s", id, d.Scope, d.ScopeID)
|
||
}
|
||
}
|
||
_, err := c.ExecContext(ctx, `INSERT INTO kb_defaults (scope, scope_id, collections, added_at, added_by)
|
||
VALUES (?, ?, ?, ?, ?) ON CONFLICT (scope, scope_id) DO UPDATE SET collections = excluded.collections,
|
||
added_at = excluded.added_at, added_by = excluded.added_by`,
|
||
string(d.Scope), d.ScopeID, strings.Join(d.Collections, ","), millis(d.AddedAt), d.AddedBy)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// KBDefaults lists every default.
|
||
func (s *DB) KBDefaults(ctx context.Context) ([]KBDefault, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
return queryAll(ctx, s.db, `SELECT scope, scope_id, collections, added_at, added_by FROM kb_defaults ORDER BY scope, scope_id`, nil,
|
||
func(r *sql.Rows) (KBDefault, error) {
|
||
var d KBDefault
|
||
var scope, cs string
|
||
var at int64
|
||
err := r.Scan(&scope, &d.ScopeID, &cs, &at, &d.AddedBy)
|
||
d.Scope, d.AddedAt = Scope(scope), fromMillis(at)
|
||
d.Collections = strings.Split(cs, ",")
|
||
return d, err
|
||
})
|
||
}
|
||
|
||
// KBDefaultFor is the default in force for a caller: its key's, else its
|
||
// project's, team's or organization's. Nil when none.
|
||
func (s *DB) KBDefaultFor(ctx context.Context, e Effective) ([]string, error) {
|
||
if s.KnowledgeAvailable() != nil {
|
||
return nil, nil
|
||
}
|
||
for _, sc := range []struct {
|
||
scope Scope
|
||
id string
|
||
}{{ScopeKey, e.KeyID}, {ScopeProject, e.ProjectID}, {ScopeTeam, e.TeamID}, {ScopeOrg, e.OrgID}} {
|
||
if sc.id == "" {
|
||
continue
|
||
}
|
||
var cs string
|
||
err := s.db.QueryRowContext(ctx, `SELECT collections FROM kb_defaults WHERE scope = ? AND scope_id = ?`,
|
||
string(sc.scope), sc.id).Scan(&cs)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return strings.Split(cs, ","), nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// CollectionChunks lists a collection's chunks in document order, for
|
||
// building training data from them (BACKLOG F4). At most limit.
|
||
func (s *DB) CollectionChunks(ctx context.Context, c KBCollection, limit int) ([]KBHit, error) {
|
||
if err := s.KnowledgeAvailable(); err != nil {
|
||
return nil, err
|
||
}
|
||
if !kbIDPattern.MatchString(c.ID) {
|
||
return nil, ErrNotFound
|
||
}
|
||
var out []KBHit
|
||
err := s.inTx(ctx, func(tx conn) error {
|
||
if err := kbScope(ctx, tx, c.OrgID, []string{c.ID}); err != nil {
|
||
return err
|
||
}
|
||
var err error
|
||
out, err = queryAll(ctx, tx, `SELECT k.collection, k.doc_id, d.name, k.ord, k.body
|
||
FROM kb_chunks k JOIN kb_documents d ON d.id = k.doc_id
|
||
WHERE k.org_id = ? AND k.collection = `+sqlLiteral(c.ID)+`
|
||
ORDER BY d.name, k.ord LIMIT ?`, []any{c.OrgID, limit},
|
||
func(r *sql.Rows) (KBHit, error) {
|
||
var h KBHit
|
||
err := r.Scan(&h.Collection, &h.DocID, &h.Document, &h.Ord, &h.Body)
|
||
return h, err
|
||
})
|
||
return err
|
||
})
|
||
return out, err
|
||
}
|