Files
Chandan Kumar 65dbbcfd59 fix(vault): search document bodies and match any query word (#1554)
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.
2026-09-27 13:13:40 +05:30

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
}