mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: hybrid search (#37127)
Implement hybrid search for the /docs/api/graphql searchDocs endpoint. Prepend a more descriptive title and introduction to database advisor docs so they rank more highly when directly searched for.
This commit is contained in:
1 parent
d8b623a0d0
commit
ce14f305ac
7 files changed
+148
-7
No files matched your search
@@ -13,7 +13,7 @@ vi.mock(import('~/lib/openAi'), () => ({
|
||||
}))
|
||||
|
||||
const rpcSpy = vi.fn().mockImplementation((funcName, params) => {
|
||||
if (funcName === 'search_content') {
|
||||
if (funcName === 'search_content_hybrid') {
|
||||
const limit = params?.max_result || 2
|
||||
const mockResults = [
|
||||
{
|
||||
@@ -88,7 +88,7 @@ describe('/api/graphql searchDocs', () => {
|
||||
expect(json.data).toBeDefined()
|
||||
expect(json.data.searchDocs).toBeDefined()
|
||||
expect(json.data.searchDocs.nodes).toBeInstanceOf(Array)
|
||||
expect(json.data.searchDocs.nodes).toHaveLength(2)
|
||||
expect(json.data.searchDocs.nodes).toHaveLength(3)
|
||||
expect(json.data.searchDocs.nodes[0]).toMatchObject({
|
||||
title: 'Test Guide',
|
||||
href: '/guides/test',
|
||||
@@ -117,7 +117,7 @@ describe('/api/graphql searchDocs', () => {
|
||||
expect(json.data.searchDocs.nodes).toHaveLength(1)
|
||||
expect(json.data.searchDocs.nodes[0].title).toBe('Test Guide')
|
||||
expect(rpcSpy).toHaveBeenCalledWith(
|
||||
'search_content',
|
||||
'search_content_hybrid',
|
||||
expect.objectContaining({
|
||||
max_result: 1,
|
||||
})
|
||||
@@ -146,7 +146,7 @@ describe('/api/graphql searchDocs', () => {
|
||||
expect(json.errors).toBeUndefined()
|
||||
expect(json.data.searchDocs.nodes[0].content).toBe('Test content')
|
||||
expect(rpcSpy).toHaveBeenCalledWith(
|
||||
'search_content',
|
||||
'search_content_hybrid',
|
||||
expect.objectContaining({
|
||||
include_full_content: true,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@ type Database = {
|
||||
public: {
|
||||
Tables: DatabaseGenerated['public']['Tables']
|
||||
Views: DatabaseGenerated['public']['Views']
|
||||
Functions: Omit<DatabaseGenerated['public']['Functions'], 'search_content'> & {
|
||||
Functions: Omit<
|
||||
DatabaseGenerated['public']['Functions'],
|
||||
'search_content' | 'search_content_hybrid'
|
||||
> & {
|
||||
search_content: {
|
||||
Args: Omit<
|
||||
DatabaseGenerated['public']['Functions']['search_content']['Args'],
|
||||
@@ -28,6 +31,26 @@ type Database = {
|
||||
}
|
||||
>
|
||||
}
|
||||
search_content_hybrid: {
|
||||
Args: Omit<
|
||||
DatabaseGenerated['public']['Functions']['search_content_hybrid']['Args'],
|
||||
'query_embedding'
|
||||
> & { query_embedding: Array<number> }
|
||||
Returns: Array<
|
||||
Omit<
|
||||
DatabaseGenerated['public']['Functions']['search_content_hybrid']['Returns'][number],
|
||||
'subsections' | 'metadata'
|
||||
> & {
|
||||
metadata: {
|
||||
subtitle?: string
|
||||
language?: string
|
||||
methodName?: string
|
||||
platform?: string
|
||||
}
|
||||
subsections: Array<{ title?: string; href?: string; content?: string }>
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
Enums: DatabaseGenerated['public']['Enums']
|
||||
CompositeTypes: DatabaseGenerated['public']['CompositeTypes']
|
||||
|
||||
@@ -40,6 +40,34 @@ export abstract class SearchResultModel {
|
||||
return matchResult
|
||||
})
|
||||
}
|
||||
|
||||
static async searchHybrid(
|
||||
args: RootQueryTypeSearchDocsArgs,
|
||||
requestedFields: Array<string>
|
||||
): Promise<Result<SearchResultModel[], ApiErrorGeneric>> {
|
||||
const query = args.query.trim()
|
||||
const includeFullContent = requestedFields.includes('content')
|
||||
const embeddingResult = await openAI().createContentEmbedding(query)
|
||||
|
||||
return embeddingResult.flatMapAsync(async (embedding) => {
|
||||
const matchResult = new Result(
|
||||
await supabase().rpc('search_content_hybrid', {
|
||||
query_text: query,
|
||||
query_embedding: embedding,
|
||||
include_full_content: includeFullContent,
|
||||
max_result: args.limit ?? 30,
|
||||
})
|
||||
)
|
||||
.map((matches) =>
|
||||
matches
|
||||
.map(createModelFromMatch)
|
||||
.filter((item): item is SearchResultInterface => item !== null)
|
||||
)
|
||||
.mapError(convertPostgrestToApiError)
|
||||
|
||||
return matchResult
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createModelFromMatch({
|
||||
|
||||
@@ -53,7 +53,7 @@ async function resolveSearchImpl(
|
||||
): Promise<Result<Array<SearchResultModel>, ApiErrorGeneric>> {
|
||||
const fieldsInfo = graphQLFields(info)
|
||||
const requestedFields = Object.keys(fieldsInfo.nodes ?? fieldsInfo.edges?.node ?? {})
|
||||
return await SearchResultModel.search(args, requestedFields)
|
||||
return await SearchResultModel.searchHybrid(args, requestedFields)
|
||||
}
|
||||
|
||||
export const searchRoot = {
|
||||
|
||||
@@ -111,6 +111,6 @@ export class LintWarningsGuideSource extends BaseSource {
|
||||
extractIndexedContent(): string {
|
||||
const sections = this.sections ?? []
|
||||
const sectionText = sections.map(({ content }) => content).join('\n\n')
|
||||
return `# ${this.lint.path}\n\n${sectionText}`
|
||||
return `# Database Advisor: Lint ${this.lint.path}\n\nThis is a database lint rule for Supabase, targeting the lint ID ${this.lint.path}. Lint rules help enforce performance and security best practices for your Supabase database.\n\n${sectionText}`
|
||||
}
|
||||
}
|
||||
@@ -655,6 +655,27 @@ export type Database = {
|
||||
subsections: Json[]
|
||||
}[]
|
||||
}
|
||||
search_content_hybrid: {
|
||||
Args: {
|
||||
query_text: string
|
||||
query_embedding: string
|
||||
max_result?: number
|
||||
full_text_weight?: number
|
||||
semantic_weight?: number
|
||||
rrf_k?: number
|
||||
match_threshold?: number
|
||||
include_full_content?: boolean
|
||||
}
|
||||
Returns: {
|
||||
id: number
|
||||
page_title: string
|
||||
type: string
|
||||
href: string
|
||||
content: string
|
||||
metadata: Json
|
||||
subsections: Json[]
|
||||
}[]
|
||||
}
|
||||
update_last_changed_checksum: {
|
||||
Args: {
|
||||
new_parent_page: string
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Hybrid search: combines FTS and vector search using reciprocal rank fusion (RRF)
|
||||
create or replace function search_content_hybrid(
|
||||
query_text text,
|
||||
query_embedding vector(1536),
|
||||
max_result int default 30,
|
||||
full_text_weight float default 1,
|
||||
semantic_weight float default 1,
|
||||
rrf_k int default 50,
|
||||
match_threshold float default 0.78,
|
||||
include_full_content boolean default false
|
||||
)
|
||||
returns table (
|
||||
id bigint,
|
||||
page_title text,
|
||||
type text,
|
||||
href text,
|
||||
content text,
|
||||
metadata json,
|
||||
subsections json[]
|
||||
)
|
||||
language sql
|
||||
set search_path = ''
|
||||
as $$
|
||||
with full_text as (
|
||||
select
|
||||
id,
|
||||
row_number() over(order by greatest(
|
||||
least(10 * ts_rank(title_tokens, websearch_to_tsquery(query_text)), 1),
|
||||
ts_rank(fts_tokens, websearch_to_tsquery(query_text))
|
||||
) desc) as rank_ix
|
||||
from public.page
|
||||
where title_tokens @@ websearch_to_tsquery(query_text) or fts_tokens @@ websearch_to_tsquery(query_text)
|
||||
order by rank_ix
|
||||
limit least(max_result, 30) * 2
|
||||
),
|
||||
semantic as (
|
||||
select
|
||||
page_id as id,
|
||||
row_number() over () as rank_ix
|
||||
from public.match_embedding(query_embedding, match_threshold, max_result * 2)
|
||||
),
|
||||
rrf as (
|
||||
select
|
||||
coalesce(full_text.id, semantic.id) as id,
|
||||
coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) * full_text_weight +
|
||||
coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0) * semantic_weight as rrf_score
|
||||
from full_text
|
||||
full outer join semantic on full_text.id = semantic.id
|
||||
)
|
||||
select
|
||||
page.id,
|
||||
page.meta ->> 'title' as page_title,
|
||||
page.type,
|
||||
public.get_full_content_url(page.type, page.path, null) as href,
|
||||
case when include_full_content then page.content else null end as content,
|
||||
page.meta as metadata,
|
||||
array_agg(json_build_object(
|
||||
'title', page_section.heading,
|
||||
'href', public.get_full_content_url(page.type, page.path, page_section.slug),
|
||||
'content', page_section.content
|
||||
)) as subsections
|
||||
from rrf
|
||||
join public.page on page.id = rrf.id
|
||||
left join public.page_section on page_section.page_id = page.id
|
||||
where rrf.rrf_score > 0
|
||||
group by page.id
|
||||
order by max(rrf.rrf_score) desc
|
||||
limit max_result;
|
||||
$$;
|
||||
Reference in new issue
Block a user