Files
llmgw/internal/store/knowledge.go
Ilker ManapandClaude Opus 5.5 9c675e7293 Knowledge bases at scale: halfvec, a text index per collection, ef_search raised to the candidates wanted
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>
2026-10-08 22:08:29 +02:00

1148 lines
40 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}