-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPgVectorKnowledgeBaseSearch.cs
More file actions
90 lines (82 loc) · 3.18 KB
/
Copy pathPgVectorKnowledgeBaseSearch.cs
File metadata and controls
90 lines (82 loc) · 3.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OperationsCopilot.Domain.Abstractions;
using OperationsCopilot.Domain.Knowledge;
using OperationsCopilot.Infrastructure.Persistence;
using Pgvector;
namespace OperationsCopilot.Infrastructure.Knowledge;
/// <summary>
/// Nearest-neighbour search over <c>document_chunks</c> using pgvector's cosine distance
/// operator <c><=></c>.
/// </summary>
/// <remarks>
/// This is deliberately raw SQL rather than LINQ. The <c><=></c> operator is what the HNSW
/// index is built on, and writing it out makes the ordering that drives index usage explicit —
/// the ORDER BY must reference the same operator for PostgreSQL to choose the index.
/// </remarks>
public sealed class PgVectorKnowledgeBaseSearch(
OperationsDbContext dbContext,
IEmbeddingService embeddingService,
ILogger<PgVectorKnowledgeBaseSearch> logger) : IKnowledgeBaseSearch
{
public async Task<IReadOnlyList<KnowledgeSearchResult>> SearchAsync(
string query,
int topK,
double minimumScore,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var limit = Math.Clamp(topK, 1, 25);
var embedding = new Vector(await embeddingService.EmbedAsync(query, cancellationToken));
// Cosine distance is in [0, 2]; for normalized embeddings it lands in [0, 1].
// Similarity is reported as 1 - distance so that higher always means closer.
var rows = await dbContext.Database
.SqlQuery<VectorSearchRow>(
$"""
SELECT id,
source_file,
document_title,
heading,
chunk_index,
content,
(embedding <=> {embedding}) AS distance
FROM document_chunks
ORDER BY embedding <=> {embedding}
LIMIT {limit}
""")
.ToListAsync(cancellationToken);
var results = rows
.Select(row => new KnowledgeSearchResult(
row.Id,
row.SourceFile,
row.DocumentTitle,
row.Heading,
row.ChunkIndex,
row.Content,
Score: 1d - row.Distance))
.Where(result => result.Score >= minimumScore)
.ToList();
logger.LogDebug(
"Knowledge search returned {Kept} of {Retrieved} chunks above score {MinimumScore}.",
results.Count,
rows.Count,
minimumScore);
return results;
}
/// <summary>
/// Row shape for the raw vector query. The snake_case naming convention applies to
/// <c>SqlQuery<T></c> results too, so the SQL selects the underlying column names
/// directly and EF maps them onto these properties.
/// </summary>
private sealed record VectorSearchRow(
Guid Id,
string SourceFile,
string DocumentTitle,
string Heading,
int ChunkIndex,
string Content,
double Distance);
}