Skip to content

Commit a7f43f5

Browse files
authored
fix(mongodb): return all stored fields from search for CF/PG parity (+ README parity) (#63)
* docs: add MongoDB to adapters/README parity table and fix CF where-spec claim * fix(mongodb): return all stored fields from search for CF/PG parity Mongo search projected only the reserved fields plus filterableFields, so stored extension fields that weren't marked filterable were silently dropped from results — diverging from findByIds (which returns all non-reserved fields) and from the CF/PG adapters (whose search returns all stored fields). Fetch all fields except the embedding vector ($addFields score + $project { embedding: 0 }) and map every non-reserved field. The heavy embedding stays excluded, so only the small extension fields that were missing now round-trip. Adds a spec asserting a non-filterable stored field is returned by search.
1 parent afb3fd7 commit a7f43f5

3 files changed

Lines changed: 49 additions & 35 deletions

File tree

adapters/README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
> **If you just want to use an existing adapter**, jump to the package READMEs:
66
> - [`@payloadcms-vectorize/pg`](./pg/README.md) — PostgreSQL + pgvector
77
> - [`@payloadcms-vectorize/cf`](./cf/README.md) — Cloudflare Vectorize
8+
> - [`@payloadcms-vectorize/mongodb`](./mongodb/README.md) — MongoDB Atlas + self-hosted 8.2+
89
> - Or see the [main README](../README.md) for end-to-end setup.
910
1011
---
@@ -380,7 +381,7 @@ The dev harness in [`dev/`](../dev) runs the integration suite against any adapt
380381
3. Run the suites that matter:
381382
- `pnpm test:int` — full integration suite (real Payload, real DB).
382383
- `pnpm test:e2e` — Playwright E2E.
383-
- `pnpm vitest adapters/pg/dev/specs/vectorSearchWhere.spec.ts` — the `where` operator suite (31 tests). **Every adapter should pass this.**
384+
- `pnpm vitest adapters/pg/dev/specs/vectorSearchWhere.spec.ts` — the `where` operator suite (31 tests). **Every adapter should pass this** (or an equivalent — CF's equivalent is `where.spec.ts`).
384385

385386
If your store doesn't support an operator, document it in [Adapter feature parity](#adapter-feature-parity) and have your `where` translation throw a clear error rather than silently returning wrong results.
386387

@@ -398,14 +399,14 @@ If your store doesn't support an operator, document it in [Adapter feature parit
398399
399400
## Adapter feature parity
400401
401-
| Feature | PG | CF | Notes |
402-
|---|---|---|---|
403-
| Real-time ingest (`storeChunk`) | ✅ | ✅ | |
404-
| Bulk ingest (`hasEmbeddingVersion` + `storeChunk`) | ✅ | ✅ | |
405-
| `where` operators | full | full | Both pass `vectorSearchWhere.spec.ts`. |
406-
| Server-side `like` regex | ✅ | ✅ (with regex escape — see CHANGELOG 0.7.1) | |
407-
| Migration CLI bin | ✅ (`vectorize:migrate`) | ❌ | CF uses indexes managed via Cloudflare API. |
408-
| Score range | `[0, 1]` (cosine similarity) | varies by index metric | Document yours. |
402+
| Feature | PG | CF | MongoDB | Notes |
403+
|---|---|---|---|---|
404+
| Real-time ingest (`storeChunk`) | ✅ | ✅ | ✅ | |
405+
| Bulk ingest (`hasEmbeddingVersion` + `storeChunk`) | ✅ | ✅ | ✅ | |
406+
| `where` operators | full | full | full | PG & MongoDB pass `vectorSearchWhere.spec.ts` (31 tests each); CF covers `where` in `where.spec.ts` (59 tests). MongoDB has documented pre/post-filter behavior — see [Limitations](./mongodb/README.md#limitations). |
407+
| Server-side `like` regex | ✅ | ✅ (with regex escape — see CHANGELOG 0.7.1) | ✅ | |
408+
| Migration CLI bin | ✅ (`vectorize:migrate`) | ❌ | ❌ | CF uses indexes managed via Cloudflare API. MongoDB creates search indexes at runtime via `ensureSearchIndex` (no bin). |
409+
| Score range | `[0, 1]` (cosine similarity) | varies by index metric | `vectorSearchScore`; range depends on the index similarity metric | Document yours. |
409410
410411
If something here is out of date, please [open an issue](https://github.com/techiejd/payloadcms-vectorize/issues) — adapter parity drift is exactly what this table exists to surface.
411412

adapters/mongodb/dev/specs/extensionFields.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,24 @@ describe('Extension fields (mongodb)', () => {
106106
expect((hit as any).category).toBe('cat-a')
107107
expect((hit as any).priority).toBe(7)
108108
}, 90_000)
109+
110+
test('stored fields outside filterableFields are also returned by search (CF/PG parity)', async () => {
111+
const target = Array(DIMS).fill(0.66)
112+
await adapter.storeChunk(payload, 'default', {
113+
sourceCollection: 'posts',
114+
docId: 'doc-nonfilterable',
115+
chunkIndex: 0,
116+
chunkText: 'parity',
117+
embeddingVersion: testEmbeddingVersion,
118+
embedding: target,
119+
extensionFields: { category: 'cat-a', priority: 9, note: 'not-a-filterable-field' },
120+
})
121+
122+
await new Promise((r) => setTimeout(r, 1500))
123+
124+
const r = await adapter.search(payload, target, 'default', 5)
125+
const hit = r.find((x) => x.docId === 'doc-nonfilterable')
126+
expect(hit).toBeDefined()
127+
expect((hit as any).note).toBe('not-a-filterable-field')
128+
}, 90_000)
109129
})

adapters/mongodb/src/search.ts

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { convertWhereToMongo, evaluatePostFilter } from './convertWhere.js'
55
import { ensureSearchIndex } from './indexes.js'
66
import { RESERVED_FIELDS, type ResolvedPoolConfig } from './types.js'
77

8+
const RESERVED_AND_META = new Set<string>([
9+
...RESERVED_FIELDS,
10+
'_id',
11+
'score',
12+
'createdAt',
13+
'updatedAt',
14+
])
15+
816
export interface MongoSearchCtx {
917
uri: string
1018
dbName: string
@@ -53,20 +61,10 @@ export async function searchImpl(
5361
if (pool.forceExact) vectorSearchStage.exact = true
5462
if (preFilter) vectorSearchStage.filter = preFilter
5563

56-
const projection: Record<string, unknown> = {
57-
_id: 1,
58-
score: { $meta: 'vectorSearchScore' },
59-
sourceCollection: 1,
60-
docId: 1,
61-
chunkIndex: 1,
62-
chunkText: 1,
63-
embeddingVersion: 1,
64-
}
65-
for (const f of pool.filterableFields) projection[f] = 1
66-
6764
const pipeline: Record<string, unknown>[] = [
6865
{ $vectorSearch: vectorSearchStage },
69-
{ $project: projection },
66+
{ $addFields: { score: { $meta: 'vectorSearchScore' } } },
67+
{ $project: { embedding: 0 } },
7068
]
7169

7270
const collection = client.db(ctx.dbName).collection(pool.collectionName)
@@ -76,19 +74,19 @@ export async function searchImpl(
7674
? rawDocs.filter((d) => evaluatePostFilter(d as Record<string, unknown>, postFilter!))
7775
: rawDocs
7876

79-
return filtered.map((d) => mapDocToResult(d as Record<string, unknown>, pool.filterableFields))
77+
return filtered.map((d) => mapDocToResult(d as Record<string, unknown>))
8078
}
8179

82-
function mapDocToResult(
83-
doc: Record<string, unknown>,
84-
filterable: string[],
85-
): VectorSearchResult {
80+
function mapDocToResult(doc: Record<string, unknown>): VectorSearchResult {
8681
if (typeof doc.score !== 'number') {
8782
throw new Error(
88-
`[@payloadcms-vectorize/mongodb] Search result is missing numeric "score" field; ensure $project includes { score: { $meta: 'vectorSearchScore' } }`,
83+
`[@payloadcms-vectorize/mongodb] Search result is missing numeric "score" field; ensure the pipeline adds { score: { $meta: 'vectorSearchScore' } }`,
8984
)
9085
}
91-
const result: Record<string, unknown> = {
86+
const extensionFields = Object.fromEntries(
87+
Object.entries(doc).filter(([k]) => !RESERVED_AND_META.has(k)),
88+
)
89+
return {
9290
id: String(doc._id),
9391
score: doc.score,
9492
sourceCollection: String(doc.sourceCollection ?? ''),
@@ -97,11 +95,6 @@ function mapDocToResult(
9795
typeof doc.chunkIndex === 'number' ? doc.chunkIndex : Number(doc.chunkIndex ?? 0),
9896
chunkText: String(doc.chunkText ?? ''),
9997
embeddingVersion: String(doc.embeddingVersion ?? ''),
100-
}
101-
for (const f of filterable) {
102-
if (f in doc && !(RESERVED_FIELDS as readonly string[]).includes(f)) {
103-
result[f] = doc[f]
104-
}
105-
}
106-
return result as VectorSearchResult
98+
...extensionFields,
99+
} as VectorSearchResult
107100
}

0 commit comments

Comments
 (0)