fix(8.0): multi-valued array fields index every element (contains no longer misses)
addToIndex builds a fieldsMap from the extracted {field,value} entries, but
only __words__ accumulated — every other repeated field did fieldsMap[field]=value,
so a multi-valued field (tags:['a','b','c'] → three 'tags' entries from
extractIndexableFields) collapsed to last-value-wins. columnStore.addEntity
expands an array to one indexed entry per element, but it only ever received the
final scalar, so `contains` matched only the last element and missed the rest.
Accumulate any repeated non-__words__ field into an array before addEntity
(promote scalar→array on the second occurrence). __words__ keeps its always-array
special-case so its multiValue manifest flag stays set even for single-word docs.
find-unified-integration.test.ts → 0 failures (was 4):
- 'array contains' (A.4): now queries first/middle/last element — all match
(previously only the last-indexed element did).
- 'complete find workflow': $contains→contains (8.0 operator) AND fixed the test
premise — find() hard-ANDs vector∩graph∩metadata, and connected:{from:X} returns
X's NEIGHBOURS not X, so the graph anchor must be 'earth' (which the ML concept
relates to), not the concept itself. Multi-signal scores are reciprocal-rank
fusion (~1/61), not [0,1] cosine, so assert a positive fusion score, not >0.5.
- 'nonsense vector query': cosine search always returns nearest neighbours, so
assert the structural array/bound contract, not length===0 (a Tier-2 concern).
- 'hard-ANDs signals': a nonexistent connected.from is an empty graph signal that
zeros the intersection — assert length===0 (documented AND semantics), was >0.
1469 unit + full find-unified integration green.
This commit is contained in:
parent
009e50681e
commit
eccf42000b
2 changed files with 71 additions and 25 deletions
|
|
@ -1434,13 +1434,31 @@ export class MetadataIndexManager implements MetadataIndexProvider {
|
||||||
|
|
||||||
// Write to column store — the single write path for all indexed data.
|
// Write to column store — the single write path for all indexed data.
|
||||||
// Converts extracted fields into a map and feeds the column store.
|
// Converts extracted fields into a map and feeds the column store.
|
||||||
|
//
|
||||||
|
// extractIndexableFields emits ONE {field,value} entry per array element for
|
||||||
|
// a multi-valued field (e.g. tags:['a','b','c'] → three 'tags' entries). We
|
||||||
|
// must accumulate every repeated field into an array, not just __words__:
|
||||||
|
// columnStore.addEntity expands an array value to one indexed entry per
|
||||||
|
// element, so a scalar overwrite (last-value-wins) would index only the final
|
||||||
|
// element and `contains` would miss the rest.
|
||||||
if (this.columnStore) {
|
if (this.columnStore) {
|
||||||
const entityIntId = this.idMapper.getOrAssign(id)
|
const entityIntId = this.idMapper.getOrAssign(id)
|
||||||
const fieldsMap: Record<string, unknown> = {}
|
const fieldsMap: Record<string, unknown> = {}
|
||||||
for (const { field, value } of fields) {
|
for (const { field, value } of fields) {
|
||||||
if (field === '__words__') {
|
if (field === '__words__') {
|
||||||
|
// Always an array (keeps the field's multiValue manifest flag set even
|
||||||
|
// for a single-word document).
|
||||||
if (!fieldsMap.__words__) fieldsMap.__words__ = []
|
if (!fieldsMap.__words__) fieldsMap.__words__ = []
|
||||||
;(fieldsMap.__words__ as number[]).push(value)
|
;(fieldsMap.__words__ as unknown[]).push(value)
|
||||||
|
} else if (field in fieldsMap) {
|
||||||
|
// Repeated field → multi-valued. Promote the scalar to an array on the
|
||||||
|
// second occurrence, then accumulate.
|
||||||
|
const existing = fieldsMap[field]
|
||||||
|
if (Array.isArray(existing)) {
|
||||||
|
;(existing as unknown[]).push(value)
|
||||||
|
} else {
|
||||||
|
fieldsMap[field] = [existing, value]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fieldsMap[field] = value
|
fieldsMap[field] = value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,17 +251,19 @@ describe('Unified Find() Integration Tests', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
// 8.0 uses the BFO `contains` operator for array membership (not Mongo `$contains`).
|
// 8.0 uses the BFO `contains` operator for array membership (not Mongo `$contains`).
|
||||||
// Correct input: tags = ['test', 'integration', 'qa'], querying for 'test'.
|
// A.4 regression guard: query EVERY element — first ('test'), middle
|
||||||
// Expected: the entity is returned (its tags array contains 'test').
|
// ('integration'), and last ('qa'). Before the fix only the LAST element
|
||||||
|
// was indexed (fieldsMap last-value-wins), so 'test'/'integration' missed.
|
||||||
|
for (const tag of ['test', 'integration', 'qa']) {
|
||||||
const results = await brain.find({
|
const results = await brain.find({
|
||||||
where: { tags: { contains: 'test' } }
|
where: { tags: { contains: tag } }
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(results.length).toBeGreaterThan(0)
|
expect(results.length).toBeGreaterThan(0)
|
||||||
results.forEach((result: any) => {
|
results.forEach((result: any) => {
|
||||||
expect(Array.isArray(result.entity.metadata?.tags)).toBe(true)
|
expect(Array.isArray(result.entity.metadata?.tags)).toBe(true)
|
||||||
expect(result.entity.metadata.tags).toContain('test')
|
expect(result.entity.metadata.tags).toContain(tag)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support complex logical operators', async () => {
|
it('should support complex logical operators', async () => {
|
||||||
|
|
@ -757,14 +759,24 @@ describe('Unified Find() Integration Tests', () => {
|
||||||
|
|
||||||
describe('4. Edge Cases', () => {
|
describe('4. Edge Cases', () => {
|
||||||
describe('Empty Results Handling', () => {
|
describe('Empty Results Handling', () => {
|
||||||
it('should handle empty vector search results', async () => {
|
it('should handle a nonsense vector query without error', async () => {
|
||||||
|
// A pure vector query has no notion of "no match": cosine similarity
|
||||||
|
// always ranks the corpus, so a nonsense string returns the nearest
|
||||||
|
// neighbours (≤ limit), not an empty set. Tier-1 asserts the structural
|
||||||
|
// contract (well-formed array, bounded by limit); whether a nonsense
|
||||||
|
// query SHOULD rank low is a Tier-2 semantic-relevance concern. The
|
||||||
|
// genuinely-empty cases are the graph/field tests below.
|
||||||
const results = await brain.find({
|
const results = await brain.find({
|
||||||
query: 'nonexistent_content_xyz123',
|
query: 'nonexistent_content_xyz123',
|
||||||
limit: 5
|
limit: 5
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(Array.isArray(results)).toBe(true)
|
expect(Array.isArray(results)).toBe(true)
|
||||||
expect(results.length).toBe(0)
|
expect(results.length).toBeLessThanOrEqual(5)
|
||||||
|
results.forEach((r: any) => {
|
||||||
|
expect(r.id).toBeDefined()
|
||||||
|
expect(typeof r.score).toBe('number')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty graph search results', async () => {
|
it('should handle empty graph search results', async () => {
|
||||||
|
|
@ -790,19 +802,23 @@ describe('Unified Find() Integration Tests', () => {
|
||||||
expect(results.length).toBe(0)
|
expect(results.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle mixed empty and non-empty results', async () => {
|
it('hard-ANDs signals — an empty graph signal zeros the intersection', async () => {
|
||||||
|
// find() is a hard AND across its signals (vector ∩ graph ∩ metadata):
|
||||||
|
// the result is the INTERSECTION, not a union. Here the vector ('person')
|
||||||
|
// and field (age >= 25) signals match entities, but `connected.from` is a
|
||||||
|
// nonexistent entity → empty graph set → the whole intersection is empty.
|
||||||
|
// (For a soft RRF UNION ranking, use getTripleIntelligence().find().)
|
||||||
const results = await brain.find({
|
const results = await brain.find({
|
||||||
query: 'person', // Should find results
|
query: 'person',
|
||||||
connected: {
|
connected: {
|
||||||
from: 'nonexistent_entity' // Should find no results
|
from: 'nonexistent_entity' // empty graph signal
|
||||||
},
|
},
|
||||||
where: { age: { gte: 25 } }, // Should find results
|
where: { age: { gte: 25 } },
|
||||||
limit: 5
|
limit: 5
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(Array.isArray(results)).toBe(true)
|
expect(Array.isArray(results)).toBe(true)
|
||||||
expect(results.length).toBeGreaterThan(0)
|
expect(results.length).toBe(0)
|
||||||
// Should still return results from vector and field searches
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -950,25 +966,37 @@ describe('Unified Find() Integration Tests', () => {
|
||||||
type: VerbType.RelatedTo
|
type: VerbType.RelatedTo
|
||||||
})
|
})
|
||||||
|
|
||||||
// Perform unified search
|
// Perform unified search. find() hard-ANDs its three signals (vector ∩
|
||||||
|
// graph ∩ metadata), so the target entity must appear in ALL of them.
|
||||||
|
// The graph anchor is 'earth' (NOT complexEntityId): a `connected:{from:X}`
|
||||||
|
// filter returns X's NEIGHBOURS, never X itself, and we related the ML
|
||||||
|
// concept → earth above, so earth's bidirectional neighbours include it.
|
||||||
const results = await brain.find({
|
const results = await brain.find({
|
||||||
query: 'artificial intelligence learning',
|
query: 'artificial intelligence learning',
|
||||||
connected: {
|
connected: {
|
||||||
from: complexEntityId,
|
from: 'earth',
|
||||||
direction: 'both'
|
direction: 'both'
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
category: 'AI',
|
category: 'AI',
|
||||||
tags: { $contains: 'AI' }
|
// 8.0 array-membership operator is `contains` (not Mongo `$contains`).
|
||||||
|
// A.4 fix: tags:['AI','ML','data science'] now indexes every element,
|
||||||
|
// so `contains:'AI'` matches (previously only the last element was kept).
|
||||||
|
tags: { contains: 'AI' }
|
||||||
},
|
},
|
||||||
limit: 5
|
limit: 5
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(results.length).toBeGreaterThan(0)
|
expect(results.length).toBeGreaterThan(0)
|
||||||
// Should find the complex entity with high relevance
|
// The ML concept satisfies all three signals, so it is in the result set.
|
||||||
const mlResult = results.find((r: any) => r.id === complexEntityId)
|
const mlResult = results.find((r: any) => r.id === complexEntityId)
|
||||||
expect(mlResult).toBeDefined()
|
expect(mlResult).toBeDefined()
|
||||||
expect(mlResult!.score).toBeGreaterThan(0.5)
|
// Multi-signal find() scores via reciprocal-rank fusion (k≈60), so the
|
||||||
|
// score is a small fusion rank (~1/61), NOT a [0,1] cosine similarity —
|
||||||
|
// asserting a relevance magnitude here is a Tier-2 concern. Tier-1 checks
|
||||||
|
// only that a matched row carries a valid positive fusion score.
|
||||||
|
expect(typeof mlResult!.score).toBe('number')
|
||||||
|
expect(mlResult!.score).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue