mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
vault_search only indexed title + path + the auto-summary, and the summary is written from the first 3000 runes of the file. Anything the summary left out (room names, prices, codes) could never be found. On top of that the FTS query used plainto_tsquery, which ANDs every word, so a normal question like "what is the price of the Deluxe Ocean Suite?" matched nothing even when the key words were indexed. - Add vault_document_chunks (migration 98): the file body split into chunks with their own tsvector and embedding. body_indexed_hash on vault_documents records which content_hash the chunks came from, so unchanged files are not re-chunked or re-embedded. - The enrich worker rebuilds chunks when a file changes. This needs no LLM, so it runs even when no provider is configured. - Rescan backfills chunks for docs indexed before this change, since they already have a summary and never go back through the worker. - FTS matches any query word; ts_rank still ranks docs with more matching words first. Both FTS and vector search look at the doc and its chunks and score each doc by its best hit. SQLite is unchanged: its vault search is LIKE on title/path only.
122 lines
3.9 KiB
Go
122 lines
3.9 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// vaultMaxEmbeddedChunks caps how many chunks of one document get an embedding.
|
|
// Every chunk is still full-text indexed; chunks past the cap only lack a vector.
|
|
const vaultMaxEmbeddedChunks = 64
|
|
|
|
// ReplaceDocumentChunks swaps the body chunks of a document and embeds them.
|
|
// No-op when the stored chunks were already built from contentHash.
|
|
func (s *PGVaultStore) ReplaceDocumentChunks(ctx context.Context, tenantID, docID, contentHash string, chunks []store.VaultChunk) error {
|
|
tid, err := parseUUID(tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("vault replace chunks: tenant: %w", err)
|
|
}
|
|
did, err := parseUUID(docID)
|
|
if err != nil {
|
|
return fmt.Errorf("vault replace chunks: doc: %w", err)
|
|
}
|
|
|
|
var indexedHash sql.NullString
|
|
err = s.db.QueryRowContext(ctx,
|
|
`SELECT body_indexed_hash FROM vault_documents WHERE id = $1 AND tenant_id = $2`,
|
|
did, tid,
|
|
).Scan(&indexedHash)
|
|
if err != nil {
|
|
return fmt.Errorf("vault replace chunks: fetch doc: %w", err)
|
|
}
|
|
if indexedHash.Valid && indexedHash.String == contentHash {
|
|
return nil
|
|
}
|
|
|
|
// Embed outside the transaction; a failed embed still leaves the chunks
|
|
// searchable by keyword.
|
|
embeddings := make([]*string, len(chunks))
|
|
if s.embProvider != nil && len(chunks) > 0 {
|
|
n := min(len(chunks), vaultMaxEmbeddedChunks)
|
|
texts := make([]string, n)
|
|
for i := range n {
|
|
texts[i] = chunks[i].Text
|
|
}
|
|
vecs, embErr := s.embProvider.Embed(ctx, texts)
|
|
if embErr != nil {
|
|
slog.Warn("vault.chunks: embed", "doc", docID, "err", embErr)
|
|
}
|
|
for i := range min(len(vecs), n) {
|
|
v := vectorToString(vecs[i])
|
|
embeddings[i] = &v
|
|
}
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("vault replace chunks: begin: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM vault_document_chunks WHERE document_id = $1 AND tenant_id = $2`, did, tid,
|
|
); err != nil {
|
|
return fmt.Errorf("vault replace chunks: delete: %w", err)
|
|
}
|
|
for i, c := range chunks {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO vault_document_chunks
|
|
(id, tenant_id, document_id, chunk_index, start_line, end_line, text, embedding)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
uuid.Must(uuid.NewV7()), tid, did, c.Index, c.StartLine, c.EndLine, c.Text, embeddings[i],
|
|
); err != nil {
|
|
return fmt.Errorf("vault replace chunks: insert: %w", err)
|
|
}
|
|
}
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE vault_documents SET body_indexed_hash = $1 WHERE id = $2 AND tenant_id = $3`,
|
|
contentHash, did, tid,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("vault replace chunks: mark indexed: %w", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return errors.New("vault replace chunks: document deleted")
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListDocsNeedingBodyIndex returns text documents whose chunks are missing or stale.
|
|
// Media and binary documents are skipped: they have no text body to index.
|
|
func (s *PGVaultStore) ListDocsNeedingBodyIndex(ctx context.Context, tenantID string, limit int) ([]store.VaultDocument, error) {
|
|
tid, err := parseUUID(tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("vault list needing body index: tenant: %w", err)
|
|
}
|
|
|
|
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type,
|
|
content_hash, summary, metadata, created_at, updated_at
|
|
FROM vault_documents
|
|
WHERE tenant_id = $1 AND doc_type NOT IN ('media', 'document')
|
|
AND body_indexed_hash IS DISTINCT FROM content_hash
|
|
ORDER BY created_at ASC`
|
|
args := []any{tid}
|
|
if limit > 0 {
|
|
q += " LIMIT $2"
|
|
args = append(args, limit)
|
|
}
|
|
|
|
var rows []vaultDocRow
|
|
if err := pkgSqlxDB.SelectContext(ctx, &rows, q, args...); err != nil {
|
|
return nil, fmt.Errorf("vault.list_needing_body_index: %w", err)
|
|
}
|
|
return vaultDocRowsToDocs(rows), nil
|
|
}
|