From b33aaac51aa6da40f0ca0f5dae6f871b2fef4c32 Mon Sep 17 00:00:00 2001 From: Michele Riva Date: Thu, 3 Aug 2023 12:19:33 +0200 Subject: [PATCH] feat(orama): adds vector search --- packages/orama/src/cjs/index.cts | 13 ++ .../orama/src/components/cosine-similarity.ts | 43 ++++++ packages/orama/src/components/defaults.ts | 31 ++++- packages/orama/src/components/index.ts | 124 ++++++++++++------ packages/orama/src/components/sorter.ts | 43 +++--- packages/orama/src/errors.ts | 3 + packages/orama/src/index.ts | 1 + packages/orama/src/methods/insert.ts | 7 +- packages/orama/src/methods/search-vector.ts | 71 ++++++++++ packages/orama/src/methods/search.ts | 24 ++-- packages/orama/src/types.ts | 8 +- .../orama/tests/cosine-similarity.test.ts | 63 +++++++++ packages/orama/tests/search-vector.test.ts | 65 +++++++++ scripts/release.mjs | 1 - 14 files changed, 421 insertions(+), 76 deletions(-) create mode 100644 packages/orama/src/components/cosine-similarity.ts create mode 100644 packages/orama/src/methods/search-vector.ts create mode 100644 packages/orama/tests/cosine-similarity.test.ts create mode 100644 packages/orama/tests/search-vector.test.ts diff --git a/packages/orama/src/cjs/index.cts b/packages/orama/src/cjs/index.cts index 6ad6f0ccb..f72544f62 100644 --- a/packages/orama/src/cjs/index.cts +++ b/packages/orama/src/cjs/index.cts @@ -3,6 +3,7 @@ import type { count as esmCount, getByID as esmGetByID } from '../methods/docs.j import type { insert as esmInsert, insertMultiple as esminsertMultiple } from '../methods/insert.js' import type { remove as esmRemove, removeMultiple as esmRemoveMultiple } from '../methods/remove.js' import type { search as esmSearch } from '../methods/search.js' +import type { searchVector as esmSearchVector } from '../methods/search-vector.js' import type { load as esmLoad, save as esmSave } from '../methods/serialization.js' import type { update as esmUpdate, updateMultiple as esmUpdateMultiple } from '../methods/update.js' @@ -18,6 +19,7 @@ let _esmSave: typeof esmSave let _esmSearch: typeof esmSearch let _esmUpdate: typeof esmUpdate let _esmUpdateMultiple: typeof esmUpdateMultiple +let _esmSearchVector: typeof esmSearchVector export async function count(...args: Parameters): ReturnType { if (!_esmCount) { @@ -133,5 +135,16 @@ export async function updateMultiple( return _esmUpdateMultiple(...args) } +export async function searchVector( + ...args: Parameters +): ReturnType { + if (!_esmSearchVector) { + const imported = await import('../methods/search-vector.js') + _esmSearchVector = imported.searchVector + } + + return _esmSearchVector(...args) +} + export * as components from './components/defaults.cjs' export * as internals from './internals.cjs' diff --git a/packages/orama/src/components/cosine-similarity.ts b/packages/orama/src/components/cosine-similarity.ts new file mode 100644 index 000000000..07b1e6b7d --- /dev/null +++ b/packages/orama/src/components/cosine-similarity.ts @@ -0,0 +1,43 @@ +import type { Magnitude, VectorType } from '../types.js' + +export type SimilarVector = { + id: string + score: number +} + +export function getMagnitude(vector: Float32Array, vectorLength: number): number { + let magnitude = 0 + for (let i = 0; i < vectorLength; i++) { + magnitude += vector[i] * vector[i] + } + return Math.sqrt(magnitude) +} + +// @todo: Write plugins for Node and Browsers to use parallel computation for this function +export function findSimilarVectors( + targetVector: Float32Array, + vectors: Record, + length: number, + threshold = 0.8 +) { + const targetMagnitude = getMagnitude(targetVector, length); + + const similarVectors: SimilarVector[] = [] + + for (const [vectorId, [magnitude, vector]] of Object.entries(vectors)) { + let dotProduct = 0 + + for (let i = 0; i < length; i++) { + dotProduct += targetVector[i] * vector[i] + } + + const similarity = dotProduct / (targetMagnitude * magnitude) + + if (similarity >= threshold) { + similarVectors.push({ id: vectorId, score: similarity }) + } + } + + return similarVectors.sort((a, b) => b.score - a.score) +} + diff --git a/packages/orama/src/components/defaults.ts b/packages/orama/src/components/defaults.ts index 6b9a82ed3..5b63f147b 100644 --- a/packages/orama/src/components/defaults.ts +++ b/packages/orama/src/components/defaults.ts @@ -34,6 +34,15 @@ export async function validateSchema(doc: Document, s const typeOfType = typeof type + if (isVectorType(type as string)) { + // TODO: validate vector size + if (!Array.isArray(value)) { + // TODO: run actual validation + return undefined + } + continue + } + if (typeOfType === 'string' && isArrayType(type as SearchableType)) { if (!Array.isArray(value)) { return prop @@ -78,14 +87,34 @@ const IS_ARRAY_TYPE: Record = { 'number[]': true, 'boolean[]': true, } + const INNER_TYPE: Record = { 'string[]': 'string', 'number[]': 'number', 'boolean[]': 'boolean', } -export function isArrayType(type: SearchableType) { + +export function isVectorType(type: string): boolean { + return /^vector\[\d+\]$/.test(type) +} + +export function isArrayType(type: SearchableType): boolean { return IS_ARRAY_TYPE[type] } + export function getInnerType(type: ArraySearchableType): ScalarSearchableType { return INNER_TYPE[type] } + +export function getVectorSize(type: string): number { + const size = Number(type.slice(7, -1)) + + switch (true) { + case isNaN(size): + throw createError('INVALID_VECTOR_VALUE', type) + case size <= 0: + throw createError('INVALID_VECTOR_SIZE', type) + default: + return size + } +} \ No newline at end of file diff --git a/packages/orama/src/components/index.ts b/packages/orama/src/components/index.ts index 3a9764416..97a534135 100644 --- a/packages/orama/src/components/index.ts +++ b/packages/orama/src/components/index.ts @@ -1,3 +1,21 @@ +import type { +ArraySearchableType, +BM25Params, +ComparisonOperator, +IIndex, +Magnitude, +OpaqueDocumentStore, +OpaqueIndex, +Orama, +ScalarSearchableType, +Schema, +SearchableType, +SearchableValue, +SearchContext, +Tokenizer, +TokenScore, +VectorType, +} from '../types.js' import { createError } from '../errors.js' import { create as avlCreate, @@ -16,31 +34,16 @@ import { Node as RadixNode, removeDocumentByWord as radixRemoveDocument, } from '../trees/radix.js' -import { - ArraySearchableType, - BM25Params, - ComparisonOperator, - IIndex, - OpaqueDocumentStore, - OpaqueIndex, - Orama, - ScalarSearchableType, - Schema, - SearchableType, - SearchableValue, - SearchContext, - Tokenizer, - TokenScore, -} from '../types.js' import { intersect } from '../utils.js' import { BM25 } from './algorithms.js' -import { getInnerType, isArrayType } from './defaults.js' +import { getInnerType, getVectorSize, isArrayType, isVectorType } from './defaults.js' import { DocumentID, getInternalDocumentId, InternalDocumentID, InternalDocumentIDStore, } from './internal-document-id-store.js' +import { getMagnitude } from './cosine-similarity.js' export type FrequencyMap = { [property: string]: { @@ -57,9 +60,17 @@ export type BooleanIndex = { false: InternalDocumentID[] } +export type VectorIndex = { + size: number + vectors: { + [docID: string]: [Magnitude, VectorType] + } +} + export interface Index extends OpaqueIndex { sharedInternalDocumentStore: InternalDocumentIDStore indexes: Record | BooleanIndex> + vectorIndexes: Record searchableProperties: string[] searchablePropertiesWithTypes: Record frequencies: FrequencyMap @@ -181,6 +192,7 @@ export async function create( index = { sharedInternalDocumentStore, indexes: {}, + vectorIndexes: {}, searchableProperties: [], searchablePropertiesWithTypes: {}, frequencies: {}, @@ -200,29 +212,38 @@ export async function create( continue } - switch (type) { - case 'boolean': - case 'boolean[]': - index.indexes[path] = { true: [], false: [] } - break - case 'number': - case 'number[]': - index.indexes[path] = avlCreate(0, []) - break - case 'string': - case 'string[]': - index.indexes[path] = radixCreate() - index.avgFieldLength[path] = 0 - index.frequencies[path] = {} - index.tokenOccurrences[path] = {} - index.fieldLengths[path] = {} - break - default: - throw createError('INVALID_SCHEMA_TYPE', Array.isArray(type) ? 'array' : (type as unknown as string), path) - } + if (isVectorType(type as string)) { + index.searchableProperties.push(path) + index.searchablePropertiesWithTypes[path] = (type as SearchableType) + index.vectorIndexes[path] = { + size: getVectorSize(type as string), + vectors: {}, + } + } else { + switch (type) { + case 'boolean': + case 'boolean[]': + index.indexes[path] = { true: [], false: [] } + break + case 'number': + case 'number[]': + index.indexes[path] = avlCreate(0, []) + break + case 'string': + case 'string[]': + index.indexes[path] = radixCreate() + index.avgFieldLength[path] = 0 + index.frequencies[path] = {} + index.tokenOccurrences[path] = {} + index.fieldLengths[path] = {} + break + default: + throw createError('INVALID_SCHEMA_TYPE', Array.isArray(type) ? 'array' : (type as unknown as string), path) + } - index.searchableProperties.push(path) - index.searchablePropertiesWithTypes[path] = type + index.searchableProperties.push(path) + index.searchablePropertiesWithTypes[path] = type + } } return index @@ -276,6 +297,11 @@ export async function insert( tokenizer: Tokenizer, docsCount: number, ): Promise { + + if (isVectorType(schemaType)) { + return insertVector(index, prop, value as number[] | Float32Array, id) + } + if (!isArrayType(schemaType)) { return insertScalar( implementation, @@ -299,6 +325,17 @@ export async function insert( } } +function insertVector(index: Index, prop: string, value: number[] | VectorType, id: DocumentID): void { + if (!(value instanceof Float32Array)) { + value = new Float32Array(value) + } + + const size = index.vectorIndexes[prop].size + const magnitude = getMagnitude(value, size) + + index.vectorIndexes[prop].vectors[id] = [magnitude, value] +} + async function removeScalar( implementation: IIndex, index: Index, @@ -525,6 +562,7 @@ function loadNode(node: RadixNode): RadixNode { export async function load(sharedInternalDocumentStore: InternalDocumentIDStore, raw: R): Promise { const { indexes: rawIndexes, + vectorIndexes: rawVectorIndexes, searchableProperties, searchablePropertiesWithTypes, frequencies, @@ -534,6 +572,7 @@ export async function load(sharedInternalDocumentStore: InternalDoc } = raw as Index const indexes: Index['indexes'] = {} + const vectorIndexes: Index['vectorIndexes'] = {} for (const prop of Object.keys(rawIndexes)) { const value = rawIndexes[prop] @@ -547,9 +586,14 @@ export async function load(sharedInternalDocumentStore: InternalDoc indexes[prop] = loadNode(value) } + for (const prop of Object.keys(rawVectorIndexes)) { + // TODO: load vector indexes, convert arrays into Float32Arrays + } + return { sharedInternalDocumentStore, indexes, + vectorIndexes, searchableProperties, searchablePropertiesWithTypes, frequencies, @@ -562,6 +606,7 @@ export async function load(sharedInternalDocumentStore: InternalDoc export async function save(index: Index): Promise { const { indexes, + vectorIndexes, searchableProperties, searchablePropertiesWithTypes, frequencies, @@ -572,6 +617,7 @@ export async function save(index: Index): Promise { return { indexes, + vectorIndexes, searchableProperties, searchablePropertiesWithTypes, frequencies, diff --git a/packages/orama/src/components/sorter.ts b/packages/orama/src/components/sorter.ts index 4b8471364..dd3d151f4 100644 --- a/packages/orama/src/components/sorter.ts +++ b/packages/orama/src/components/sorter.ts @@ -1,5 +1,6 @@ import { createError } from '../errors.js' import { ISorter, OpaqueSorter, Orama, Schema, SorterConfig, SorterParams, SortType, SortValue } from '../types.js' +import { isVectorType } from './defaults.js' import { DocumentID, getInternalDocumentId, @@ -70,26 +71,28 @@ function innerCreate( continue } - switch (type) { - case 'boolean': - case 'number': - case 'string': - sorter.sortableProperties.push(path) - sorter.sortablePropertiesWithTypes[path] = type - sorter.sorts[path] = { - docs: new Map(), - orderedDocsToRemove: new Map(), - orderedDocs: [], - type: type, - } - break - case 'boolean[]': - case 'number[]': - case 'string[]': - // We don't allow to sort by arrays - continue - default: - throw createError('INVALID_SORT_SCHEMA_TYPE', Array.isArray(type) ? 'array' : (type as unknown as string), path) + if (!isVectorType(type as string)) { + switch (type) { + case 'boolean': + case 'number': + case 'string': + sorter.sortableProperties.push(path) + sorter.sortablePropertiesWithTypes[path] = type + sorter.sorts[path] = { + docs: new Map(), + orderedDocsToRemove: new Map(), + orderedDocs: [], + type: type, + } + break + case 'boolean[]': + case 'number[]': + case 'string[]': + // We don't allow to sort by arrays + continue + default: + throw createError('INVALID_SORT_SCHEMA_TYPE', Array.isArray(type) ? 'array' : (type as unknown as string), path) + } } } diff --git a/packages/orama/src/errors.ts b/packages/orama/src/errors.ts index e850a33c5..eea4c7c7c 100644 --- a/packages/orama/src/errors.ts +++ b/packages/orama/src/errors.ts @@ -29,6 +29,9 @@ const errors = { UNKNOWN_GROUP_BY_PROPERTY: `Unknown groupBy property "%s".`, INVALID_GROUP_BY_PROPERTY: `Invalid groupBy property "%s". Allowed types: "%s", but given "%s".`, UNKNOWN_FILTER_PROPERTY: `Unknown filter property "%s".`, + INVALID_VECTOR_SIZE: `Vector size must be a number greater than 0. Got "%s" instead.`, + INVALID_VECTOR_VALUE: `Vector value must be a number greater than 0. Got "%s" instead.`, + WRONG_VECTOR_SIZE: `Vector size must be %s. Got a vector of %s dimensions instead.` } export type ErrorCode = keyof typeof errors diff --git a/packages/orama/src/index.ts b/packages/orama/src/index.ts index c924be4f3..e6ead913a 100644 --- a/packages/orama/src/index.ts +++ b/packages/orama/src/index.ts @@ -3,6 +3,7 @@ export { count, getByID } from './methods/docs.js' export { insert, insertMultiple } from './methods/insert.js' export { remove, removeMultiple } from './methods/remove.js' export { search } from './methods/search.js' +export { searchVector } from './methods/search-vector.js' export { load, save } from './methods/serialization.js' export { update, updateMultiple } from './methods/update.js' diff --git a/packages/orama/src/methods/insert.ts b/packages/orama/src/methods/insert.ts index b1c950db8..b2612ee36 100644 --- a/packages/orama/src/methods/insert.ts +++ b/packages/orama/src/methods/insert.ts @@ -1,4 +1,4 @@ -import { isArrayType } from '../components.js' +import { isArrayType, isVectorType } from '../components.js' import { runMultipleHook, runSingleHook } from '../components/hooks.js' import { trackInsertion } from '../components/sync-blocking-checker.js' import { createError } from '../errors.js' @@ -44,6 +44,11 @@ async function innerInsert(orama: Orama, doc: Document, language?: string, skipH const actualType = typeof value const expectedType = indexablePropertiesWithTypes[key] + if (isVectorType(expectedType) && Array.isArray(value)) { + // @todo: Validate vector type. It should be of a given length and contain only floats. + continue + } + if (isArrayType(expectedType) && Array.isArray(value)) { continue } diff --git a/packages/orama/src/methods/search-vector.ts b/packages/orama/src/methods/search-vector.ts new file mode 100644 index 000000000..abb1f4ef8 --- /dev/null +++ b/packages/orama/src/methods/search-vector.ts @@ -0,0 +1,71 @@ +import type { Magnitude, Orama, Result, Results, VectorType } from '../types.js' +import { getNanosecondsTime, formatNanoseconds } from '../utils.js' +import { findSimilarVectors } from '../components/cosine-similarity.js' +import { getInternalDocumentId } from '../components/internal-document-id-store.js' +import { createError } from '../errors.js' + +export type SearchVectorParams = { + vector: number[] | Float32Array + property: string + similarity?: number + limit?: number + offset?: number + includeVectors?: boolean +} + +export async function searchVector(orama: Orama, params: SearchVectorParams): Promise { + const timeStart = await getNanosecondsTime() + let { vector } = params + const { property, limit = 10, offset = 0, includeVectors = false } = params + const vectorIndex = (orama.data.index as any).vectorIndexes[property] + const vectorSize = vectorIndex.size + const vectors = vectorIndex.vectors as Record + + if (vector.length !== vectorSize) { + throw createError('WRONG_VECTOR_SIZE', vectorSize, vector.length) + } + + if (!(vector instanceof Float32Array)) { + vector = new Float32Array(vector) + } + + const results = findSimilarVectors(vector, vectors, vectorSize, params.similarity) + + const docs = Array.from({ length: limit }) + + for (let i = 0; i < limit; i++) { + const result = results[i + offset] + if (!result) { + break + } + + const originalID = getInternalDocumentId(orama.internalDocumentIDStore, result.id) + const doc = (orama.data.docs as any).docs[originalID] + + if (doc) { + // TODO: manage multiple vector properties + if (!includeVectors) { + doc[property] = null + } + + const newDoc = { + id: result.id, + score: result.score, + document: doc + } + docs[i] = newDoc + } + } + + const timeEnd = await getNanosecondsTime() + const elapsedTime = timeEnd - timeStart + + return { + count: results.length, + hits: docs.filter(Boolean) as Result[], + elapsed: { + raw: Number(elapsedTime), + formatted: await formatNanoseconds(elapsedTime), + } + } +} \ No newline at end of file diff --git a/packages/orama/src/methods/search.ts b/packages/orama/src/methods/search.ts index 24660dacf..3884f2d96 100644 --- a/packages/orama/src/methods/search.ts +++ b/packages/orama/src/methods/search.ts @@ -1,15 +1,4 @@ -import { prioritizeTokenScores } from '../components/algorithms.js' -import { getFacets } from '../components/facets.js' -import { intersectFilteredIDs } from '../components/filters.js' -import { getGroups } from '../components/groups.js' -import { runAfterSearch } from '../components/hooks.js' -import { - getDocumentIdFromInternalId, - getInternalDocumentId, - InternalDocumentID, -} from '../components/internal-document-id-store.js' -import { createError } from '../errors.js' -import { +import type { BM25Params, IndexMap, Orama, @@ -28,6 +17,17 @@ import { SearchableValue, TokenScore, } from '../types.js' +import { prioritizeTokenScores } from '../components/algorithms.js' +import { getFacets } from '../components/facets.js' +import { intersectFilteredIDs } from '../components/filters.js' +import { getGroups } from '../components/groups.js' +import { runAfterSearch } from '../components/hooks.js' +import { + getDocumentIdFromInternalId, + getInternalDocumentId, + InternalDocumentID, +} from '../components/internal-document-id-store.js' +import { createError } from '../errors.js' import { getNanosecondsTime, getNested, sortTokenScorePredicate } from '../utils.js' const defaultBM25Params: BM25Params = { diff --git a/packages/orama/src/types.ts b/packages/orama/src/types.ts index 14cf7a7b2..6cc87f5b4 100644 --- a/packages/orama/src/types.ts +++ b/packages/orama/src/types.ts @@ -22,12 +22,16 @@ export interface Schema extends Record {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Document extends Record {} +export type Magnitude = number +export type Vector = `vector[${number}]` +export type VectorType = Float32Array + export type ScalarSearchableType = 'string' | 'number' | 'boolean' -export type ArraySearchableType = 'string[]' | 'number[]' | 'boolean[]' +export type ArraySearchableType = 'string[]' | 'number[]' | 'boolean[]' | Vector export type SearchableType = ScalarSearchableType | ArraySearchableType export type ScalarSearchableValue = string | number | boolean -export type ArraySearchableValue = string[] | number[] | boolean[] +export type ArraySearchableValue = string[] | number[] | boolean[] | VectorType export type SearchableValue = ScalarSearchableValue | ArraySearchableValue export type SortType = 'string' | 'number' | 'boolean' diff --git a/packages/orama/tests/cosine-similarity.test.ts b/packages/orama/tests/cosine-similarity.test.ts new file mode 100644 index 000000000..1a8032a45 --- /dev/null +++ b/packages/orama/tests/cosine-similarity.test.ts @@ -0,0 +1,63 @@ +import t from 'tap' +import { findSimilarVectors, getMagnitude } from '../src/components/cosine-similarity.js' + +function toF32(vector: number[]): Float32Array { + return new Float32Array(vector) +} + +t.test('cosine similarity', t => { + t.plan(2) + + t.test('getMagnitude', t => { + t.plan(1) + + t.test('should return the magnitude of a vector', t => { + t.plan(3) + + { + const vector = toF32([1, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + const magnitude = getMagnitude(vector, vector.length) + + t.equal(magnitude, 1) + } + + { + const vector = toF32([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) + const magnitude = getMagnitude(vector, vector.length) + + t.equal(magnitude, Math.sqrt(10)) + } + + { + const vector = toF32([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + const magnitude = getMagnitude(vector, vector.length) + + t.equal(magnitude, Math.sqrt(385)) + } + + }) + }) + + t.test('findSimilarVectors', t => { + t.plan(1) + + t.test('should return the most similar vectors', t => { + t.plan(3) + + const targetVector = toF32([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) + const vectors = { + '1': [1, toF32([1, 0, 0, 0, 0, 0, 0, 0, 0, 0])], + '2': [1, toF32([0, 1, 1, 1, 1, 1, 1, 1, 1, 1])], + '3': [1, toF32([0, 0, 1, 1, 1, 1, 1, 1, 1, 1])] + } + + // @ts-expect-error - @todo: fix types + const similarVectors = findSimilarVectors(targetVector, vectors, targetVector.length) + + t.same(similarVectors.length, 2) + t.same(similarVectors[0].id, '2') + t.same(similarVectors[1].id, '3') + }) + + }) +}) \ No newline at end of file diff --git a/packages/orama/tests/search-vector.test.ts b/packages/orama/tests/search-vector.test.ts new file mode 100644 index 000000000..ade604b51 --- /dev/null +++ b/packages/orama/tests/search-vector.test.ts @@ -0,0 +1,65 @@ +import t from 'tap' +import { create, insertMultiple, searchVector } from '../src/index.js' + +t.test('create', t => { + t.plan(2) + + t.test('should create a vector instance', async t => { + const db = await create({ + schema: { + title: 'string', + description: 'string', + embedding: 'vector[1536]' + } + }) + + t.ok(db, 'db instance created') + }) + + t.test('should throw an error if no vector size is provided', async t => { + try { + await create({ + schema: { + title: 'string', + description: 'string', + // @ts-expect-error error case + embedding: 'vector[]' + } + }) + } catch (err) { + t.ok(err, 'error thrown') + } + }) + +}) + +t.test('searchVector', t => { + t.plan(1) + + t.test('should return the most similar vectors', async t => { + t.plan(3) + + const db = await create({ + schema: { + vector: 'vector[5]' + } + }) + + await insertMultiple(db, [ + { vector: [1, 1, 1, 1, 1] }, + { vector: [0, 1, 1, 1, 1] }, + { vector: [0, 0, 1, 1, 1] } + ]) + + const results = await searchVector(db, { + vector: [1, 1, 1, 1, 1], + property: 'vector', + includeVectors: true + }) + + t.same(results.count, 2) + t.same(results.hits[0].document.vector, [1, 1, 1, 1, 1]) + t.same(results.hits[1].document.vector, [0, 1, 1, 1, 1]) + + }) +}) \ No newline at end of file diff --git a/scripts/release.mjs b/scripts/release.mjs index e07d94c17..3ddd69755 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,5 +1,4 @@ import { spawn } from 'node:child_process' -import { cp } from 'node:fs/promises' import { resolve, relative } from 'node:path' const rootDir = process.cwd()