[1] Skip to main content
Search [2][ ]
[3][Search]
[4] Home
Main navigation Menu
• [6]Who we are
□ [7]About Inviqa
□ [8]About Havas
□ [9]Our Sustainability Journey
• [10]What we do
□ [11]Digital Strategy Consulting
□ [12]Digital Roadmap Development
□ [13]Digital Product Design
□ [14]User Research
□ [15]Usability Testing
□ [16]Technical Architecture Consulting & Development
□ [17]Digital Platform Implementation
□ [18]Experience Optimisation
□ [19]All services
• [20]Case studies
□ [21]B2B case studies
□ [22]Fashion & Luxury case studies
□ [23]Not-For-Profit case studies
□ [24]Retail & DTC case studies
□ [25]Sport, Leisure & Entertainment case studies
□ [26]Travel & Hotels case studies
□ [27]All case studies
• [28]Partners
□ [29]Akeneo
□ [30]BigCommerce
□ [31]Drupal
□ [32]Magento / Adobe Commerce
□ [33]Spryker
□ [34]All partners
• [35]Careers
□ [36]Life at Inviqa
□ [37]Current Vacancies
• [38]Insights
□ [39]DTC Ecommerce Report 2023
□ [40]PIM Readiness Framework
□ [41]Retail Optimisation Whitepaper
□ [42]Blog
□ [43]All insights
• [44]Contact
□ [45]Get in Touch
• [46] EN
• [47] DE
Storing graphs in the database: SQL meets social network
By Lorenzo Alberton
7 September 2009 [48]Technology engineering
Graphs are ubiquitous. Social or P2P networks, thesauri, route planning
systems, recommendation systems, collaborative filtering, even the World Wide
Web itself is ultimately a graph!
Given their importance, it's surely worth spending some time in studying some
algorithms and models to represent and work with them effectively. In this
short article, we're going to see how we can store a graph in a DBMS. Given how
much attention my talk about storing a tree data structure in the db received,
it's probably going to be interesting to many. Unfortunately, the Tree models/
techniques do not apply to generic graphs, so let's discover how we can deal
with them.
What's a graph
A graph is a set of nodes (vertices) interconnected by links (edges). When the
edges have no orientation, the graph is called an undirected graph. In
contrast, a graph where the edges have a specific orientation from a node to
another is called directed:
" "
A graph is called complete when there's an edge between any two nodes, dense
when the number of edges is close to the maximal number of edges, and sparse
when it has only a few edges:
" "
Representing a graph
Two main data structures for the representation of graphs are used in practice.
The first is called an adjacency list, and is implemented as an array with one
linked list for each source node, containing the destination nodes of the edges
that leave each node. The second is a two-dimensional boolean adjacency matrix,
in which the rows and columns are the source and destination vertices, and
entries in the array indicate whether an edge exists between the vertices.
Adjacency lists are preferred for sparse graphs; otherwise, an adjacency matrix
is a good choice. [1]
" "" "
When dealing with databases, most of the times the adjacency matrix is not a
viable option, for two reasons: there is a hard limit in the number of columns
that a table can have, and adding or removing a node requires a DDL statement.
Joe Celko dedicates a short chapter to graphs in his '[49]SQL for Smarties'
book, but the topic is treated in a quite hasty way, which is surprising given
his usual high standards.
One of the basic rules of a successful representation is to separate the nodes
and the edges, to avoid [50]DKNF problems. Thus, we create two tables:
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
name VARCHAR(10) NOT NULL,
feat1 CHAR(1), -- e.g., age
feat2 CHAR(1) -- e.g., school attended or company
);
CREATE TABLE edges (
a INTEGER NOT NULL REFERENCES nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
b INTEGER NOT NULL REFERENCES nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (a, b)
);
CREATE INDEX a_idx ON edges (a);
CREATE INDEX b_idx ON edges (b);
The first table (nodes) contains the actual node payload, with all the
interesting information we need to store about a node (in the example, feat1
and feat2 represent two node features, like the age of the person, or the
location, etc.).
If we want to represent an undirected graph, we need to add a CHECK constraint
on the uniqueness of the pair.
Since the SQL standard does not allow a subquery in the CHECK constraint, we
first create a function and then we use it in the CHECK constraint (this
example is for PostgreSQL, but can be easily ported to other DBMS):
CREATE FUNCTION check_unique_pair(IN id1 INTEGER, IN id2 INTEGER) RETURNS INTEGER AS $body$
DECLARE retval INTEGER DEFAULT 0;
BEGIN
SELECT COUNT(*) INTO retval FROM (
SELECT * FROM edges WHERE a = id1 AND b = id2
UNION ALL
SELECT * FROM edges WHERE a = id2 AND b = id1
) AS pairs;
RETURN retval;
END
$body$
LANGUAGE 'plpgsql';
ALTER TABLE edges ADD CONSTRAINT unique_pair CHECK (check_unique_pair(a, b) < 1);
NB: a UDF in a CHECK constraint might be a bit slow [4]. An alternative is to
have a materialized view [5] or force an order in the node pair (i.e. "CHECK (a
< b)", and then using a stored procedure to insert the nodes in the correct
order).
If we also want to prevent self-loops (i.e. a node linking to itself), we can
add another CHECK constraint:
ALTER TABLE edges ADD CONSTRAINT no_self_loop CHECK (a <> b)
" "" "
Traversing the graph
Now that we know how to store the graph, we might want to know which nodes are
connected. Listing the directly connected nodes is very simple:
SELECT *
FROM nodes n
LEFT JOIN edges e ON n.id = e.b
WHERE e.a = 1; -- retrieve nodes connected to node 1
or, in the case of undirected edges:
SELECT * FROM nodes WHERE id IN (
SELECT a FROM edges WHERE b = 1
UNION
SELECT b FROM edges WHERE a = 1
);
-- or alternatively:
SELECT * FROM nodes where id IN (
SELECT CASE WHEN a = 1 THEN b ELSE a END
FROM edges
WHERE 1 IN (a, b)
);
Traversing the full graph usually requires more than a query: we can either
loop through the connected nodes, one level a time, or we can create a
temporary table holding all the possible paths between two nodes.
We could use Oracle’s CONNECT BY syntax or SQL standard’s Common Table
Expressions (CTEs) to recurse through the nodes, but since the graph can
contain loops, we’d get errors (unless we’re very careful, as we’ll see in a
moment).
Kendall Willets [2] proposes a way of traversing (BFS) the graph using a
temporary table. It is quite robust, since it doesn’t fail on graphs with
cycles (and when dealing with trees, he shows there are better algorithms
available). His solution is just one of the many available, but quite good.
The problem with temporary tables holding all the possible paths is it has to
be maintained. Depending on how frequently the data is accessed and updated it
might still be worth it, but it’s quite expensive. If you do resort to such a
solution, these references may be of use [13] [14].
Before going further in our analysis, we need to introduce a new concept: the
transitive closure of a graph.
Transitive closure
The transitive closure of a graph G = (V,E) is a graph G* = (V,E*) such that E*
contains an edge (u,v) if and only if G contains a path from u to v.
In other words, the transitive closure of a graph is a graph which contains an
edge (u,v) whenever there is a directed path from u to v.
" "
Graph: transitive closure
As already mentioned, SQL has historically been unable [3] to express recursive
functions needed to maintain the transitive closure of a graph without an
auxiliary table. There are many solutions to solve this problem with a
temporary table (some even elegant [2]), but I still haven't found one to do it
dynamically.
Here's my clumsy attempt at a possible solution using CTEs
First, this is how we can write the WITH RECURSIVE statement for a Directed
(Cyclic) Graph:
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges AS e
JOIN transitive_closure AS tc
ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
ORDER BY a, b, distance;
Notice the WHERE condition, which stops the recursion in the presence of loops.
This is very important to avoid errors.
Sample output:
" "
This is a slightly modified version of the same query to deal with Undirected
graphs (NB: this is probably going to be rather slow if done in real time):
-- DROP VIEW edges2;
CREATE VIEW edges2 (a, b) AS (
SELECT a, b FROM edges
UNION ALL
SELECT b, a FROM edges
);
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
ORDER BY a, b, distance;
Linkedin: Degrees of separation
One of the fundamental characteristics of networks (or graphs in general) is
connectivity. We might want to know how to go from A to B, or how two people
are connected, and we also want to know how many "hops" separate two nodes, to
have an idea about the distance.
For instance, social networks like LinkedIN show our connections or search
results sorted by degree of separation, and trip planning sites show how many
flights you have to take to reach your destination, usually listing direct
connections first.
There are some database extensions or hybrid solutions like SPARQL on Virtuoso
[11] that add a TRANSITIVE clause [12] to make this kind of queries both easy
and efficient, but we want to see how to reach the same goal with standard SQL.
As you might guess, this becomes really easy once you have the transitive
closure of the graph, we only have to add a WHERE clause specifying what our
source and destination nodes are:
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges
WHERE a = 1 -- source
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT * FROM transitive_closure
WHERE b=6 -- destination
ORDER BY a, b, distance;
" "
If we're showing the trip planning results, then we have a list of all possible
travel solutions; instead of sorting by distance, we might sort by price or
other parameters with little changes.
If we're showing how two people are connected (LinkedIN), then we can limit the
result set to the first row, since we're probably interested in showing the
shortest distance only and not all the other alternatives.
Instead of adding a LIMIT clause, it's probably more efficient to add "AND
tc.distance = 0" to the WHERE clause of the recursive part of the CTE, or a
GROUP BY clause as follows:
WITH RECURSIVE transitive_closure(a, b, distance, path_string)
AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
)
SELECT a, b, min(distance) AS dist FROM transitive_closure
--WHERE a = 1 AND b=6
GROUP BY a, b
ORDER BY a, dist, b;
" "
If you are interested in the immediate connections of a certain node, then
specify the starting node and a distance equals to one (by limiting the
recursion at the first level)
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0 -- limit recursion at the first level
)
SELECT b FROM transitive_closure;
Of course to get the immediate connections there's no need for a recursive
query (just use the one presented at the previous paragraph), but I thought I'd
show it anyway as a first step towards more complex queries.
LinkedIN has a nice feature to show "How this user is connected to you" for non
directly connected nodes.
If the distance between the two nodes is equal to 2, you can show the shared
connections:
SELECT b FROM (
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0
)
SELECT b FROM transitive_closure
UNION ALL
(WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance, a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 4 -- set the target node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance = 0
)
SELECT b FROM transitive_closure
)) AS immediate_connections
GROUP BY b
HAVING COUNT(b) > 1;
In the above query, we select the immediate connections of the two nodes
separately, and then select the shared ones.
For nodes having a distance equals to 3, the approach is slightly different.
First, you check that the two nodes are indeed at a minimum distance of 3 nodes
(you're probably not interested in showing the relationship between two nodes
when the distance is bigger):
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 3 -- stop the recursion after 3 levels
)
SELECT a, b, min(distance) FROM transitive_closure
WHERE b=4 -- set the target node
GROUP BY a, b
HAVING min(distance) = 3; --set the minimum distance
Then you select the paths between those nodes.
But there's a different approach which is more generic and efficient, and can
be used for all the nodes whose distance is bigger than 2.
The idea is to select the immediate neighbours of the starting node that are
also in the path to the other node.
Depending on the distance, you can have either the shared nodes (distance = 2),
or the connections that could lead to the other node (distance > 2). In the
latter case, you could for instance show how A is connected to B:
" "
Linkedin
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string,
b AS direct_connection
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string,
tc.direct_connection
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 3
)
SELECT * FROM transitive_closure
--WHERE b=3 -- set the target node
ORDER BY a,b,distance
" "
Facebook: You might also know
A similar but slightly different requirement is to find those nodes that are
most strongly related, but not directly connected yet. In other words, it's
interesting to find out which and how many connected nodes are shared between
any two nodes, i.e. how many 'friends' are shared between two individuals. Or
better yet, to find those nodes sharing a certain (minimum) number of nodes
with the current one.
This could be useful to suggest a new possible friend, or in the case of
recommendation systems, to suggest a new item/genre that matches the user's
interests.
There are many ways of doing this. In theory, this is bordering on the domain
of collaborative filtering [6][7][8], so using Pearson's correlation [9] or a
similar distance measure with an appropriate algorithm [10] is going to
generate the best results. Collaborative filtering is an incredibly interesting
topic on its own, but outside the scope of this article.
A rough and inexpensive alternative is to find the nodes having distance equals
to 2, and filter those that either have a common characteristic with the source
node (went to the same school / worked at the same company, belong to the same
interest group / are items of the same genre) or have several mutual 'friends'.
" "
Facebook
This, again, is easily done once you have the transitive closure of the graph:
SELECT a AS you,
b AS mightknow,
shared_connection,
CASE
WHEN (n1.feat1 = n2.feat1 AND n1.feat1 = n3.feat1) THEN 'feat1 in common'
WHEN (n1.feat2 = n2.feat2 AND n1.feat2 = n3.feat2) THEN 'feat2 in common'
ELSE 'nothing in common'
END AS reason
FROM (
WITH RECURSIVE transitive_closure(a, b, distance, path_string) AS
( SELECT a, b, 1 AS distance,
a || '.' || b || '.' AS path_string,
b AS direct_connection
FROM edges2
WHERE a = 1 -- set the starting node
UNION ALL
SELECT tc.a, e.b, tc.distance + 1,
tc.path_string || e.b || '.' AS path_string,
tc.direct_connection
FROM edges2 AS e
JOIN transitive_closure AS tc ON e.a = tc.b
WHERE tc.path_string NOT LIKE '%' || e.b || '.%'
AND tc.distance < 2
)
SELECT a,
b,
direct_connection AS shared_connection
FROM transitive_closure
WHERE distance = 2
) AS youmightknow
LEFT JOIN nodes AS n1 ON youmightknow.a = n1.id
LEFT JOIN nodes AS n2 ON youmightknow.b = n2.id
LEFT JOIN nodes AS n3 ON youmightknow.shared_connection = n3.id
WHERE (n1.feat1 = n2.feat1 AND n1.feat1 = n3.feat1)
OR (n1.feat2 = n2.feat2 AND n1.feat2 = n3.feat2);
" "
Once you have selected these nodes, you can filter those recurring more often,
or give more importance to those having a certain feature in common, or pick
one randomly (so you don't end up suggesting the same node over and over).
Conclusion
In this article I had some fun with the new and powerful CTEs, and showed some
practical examples where they can be useful. I also showed some approaches at
solving the challenges faced by any social network or recommendation system.
You are advised that depending on the size of the graph and the performance
requirements of your application, the above queries might be too slow to run in
realtime. Caching is your friend.
Update: Many of the queries in this article have been revised, so please refer
to [51]http://www.slideshare.net/quipo/rdbms-in-the-social-networks-age for
changes.
References
[1] [52]http://willets.org/sqlgraphs.html
[2] [53]http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[3] [54]http://sqlblog.com/blogs/alexander_kuznetsov/archive/2009/06/25/
scalar-udfs-wrapped-in-check-constraints-are-very-slow-and-may-fail-for-multirow-updates.aspx
[4] [55]http://www.dbazine.com/oracle/or-articles/tropashko8
[5] [56]http://en.wikipedia.org/wiki/Collaborative_filtering
[6] [57]http://en.wikipedia.org/wiki/Slope_One
[7] blog.charliezhu.com/2008/07/21/implementing-slope-one-in-t-sql/
[8] bakara.eng.tau.ac.il/~semcomm/slides7/grouplensAlgs-Kahn.pps
[9] [58]http://www.slideshare.net/denisparra/
evaluation-of-collaborative-filtering-algorithms-for-recommending-articles-on-citeulike
[10] [59]http://virtuoso.openlinksw.com/
[11] [60]http://www.openlinksw.com/weblog/oerling/?id=1433
[12] [61]http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[13] [62]http://en.wikipedia.org/wiki/Transitive_reduction
You might also like...
[63]
A woman reviews code on her laptop
Headless commerce: everything you need to know
What the heck is headless? Discover the what, why, and when of headless
architectures with our guide to headless commerce.
[64]
Drupal consulting and web development at Inviqa
The Drupal 9 upgrade Config Split issue and how to fix it
In this article we look back at an issue we’ve encountered with Drupal Config
Split when upgrading Drupal 8 to 9 – and we share how to fix it, so you don’t
have to run into the same issue when upgrading to Drupal 9.
Inviqa, winner of Webby Awards The Webby Awards winner
Inviqa named one of Top 100 Agencies in Econsultancy Top 100 Digital Agencies
Inviqa UXUK Awards winner UXUK Awards winner
DADI Award winner of 'Best UX / Usability category' DADI Award winner
Footer Main Navigation
• [65]Home
• [66]Who we are
• [67]What we do
• [68]Case studies
• [69]Careers
• [70]Insights
• [71]Contact
• [72]Accessibility statement
About us
Together with your teams, we shape the digital products, teams, processes, and
software systems you need to meet diverse customer needs and accelerate your
business growth.
© 2007-2024, Inviqa UK Ltd. Registered No. 06278367. Registered Office: Havas
House, Hermitage Court, Hermitage Lane, Maidstone, ME16 9NT, UK.
Footer Legal Links
• [73]Covid-19
• [74]Privacy policy
• [75]Sitemap
References:
[1] https://inviqa.com/blog/storing-graphs-database-sql-meets-social-network#main-content
[4] https://inviqa.com/
[6] https://inviqa.com/who-we-are
[7] https://inviqa.com/who-we-are
[8] https://www.havas.com/
[9] https://inviqa.com/digital-sustainability-journey
[10] https://inviqa.com/what-we-do
[11] https://inviqa.com/what-we-do/digital-strategy-consulting-and-development
[12] https://inviqa.com/what-we-do/digital-roadmap-development
[13] https://inviqa.com/what-we-do/digital-product-design
[14] https://inviqa.com/what-we-do/user-research
[15] https://inviqa.com/what-we-do/usability-testing
[16] https://inviqa.com/what-we-do/technical-architecture-consulting-and-development
[17] https://inviqa.com/what-we-do/digital-platform-consulting-and-implementation
[18] https://inviqa.com/what-we-do/experience-optimisation
[19] https://inviqa.com/what-we-do
[20] https://inviqa.com/case-studies
[21] https://inviqa.com/case-studies?category=b2b
[22] https://inviqa.com/case-studies#fashion
[23] https://inviqa.com/case-studies#charity
[24] https://inviqa.com/case-studies?category=retail
[25] https://inviqa.com/case-studies#leisure
[26] https://inviqa.com/case-studies?category=travel
[27] https://inviqa.com/case-studies
[28] https://inviqa.com/partners
[29] https://inviqa.com/akeneo-pim-consulting-and-implementation
[30] https://inviqa.com/blog/bigcommerce-7-best-sites
[31] https://inviqa.com/drupal-consulting-and-web-development
[32] https://inviqa.com/magento-consulting-and-web-development
[33] https://inviqa.com/blog/spryker-commerce-platform-introduction
[34] https://inviqa.com/partners
[35] https://careers.inviqa.com/
[36] https://careers.inviqa.com/
[37] https://careers.inviqa.com/jobs
[38] https://inviqa.com/insights
[39] https://inviqa.com/insights/dtc-ecommerce-report-2023
[40] https://inviqa.com/insights/PIM-readiness-framework
[41] https://inviqa.com/insights/retail-optimisation-guide-2023
[42] https://inviqa.com/blog
[43] https://inviqa.com/insights
[44] https://inviqa.com/contact
[45] https://inviqa.com/contact
[46] https://inviqa.com/
[47] https://inviqa.de/
[48] https://inviqa.com/blog#Technology%20engineering
[49] https://www.amazon.com/Joe-Celkos-SQL-Smarties-Programming/dp/0123693799/157-5667933-6571053?ie=UTF8&redirect=true&tag=postcarfrommy-20
[50] https://en.wikipedia.org/wiki/Domain-key_normal_form
[51] http://www.slideshare.net/quipo/rdbms-in-the-social-networks-age
[52] http://willets.org/sqlgraphs.html
[53] http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[54] http://sqlblog.com/blogs/alexander_kuznetsov/archive/2009/06/25/scalar-udfs-wrapped-in-check-constraints-are-very-slow-and-may-fail-for-multirow-updates.aspx
[55] http://www.dbazine.com/oracle/or-articles/tropashko8/
[56] https://en.wikipedia.org/wiki/Collaborative_filtering
[57] https://en.wikipedia.org/wiki/Slope_One
[58] http://www.slideshare.net/denisparra/evaluation-of-collaborative-filtering-algorithms-for-recommending-articles-on-citeulike
[59] http://virtuoso.openlinksw.com/
[60] http://www.openlinksw.com/weblog/oerling/?id=1433
[61] http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.48.53
[62] https://en.wikipedia.org/wiki/Transitive_reduction
[63] https://inviqa.com/blog/headless-commerce-everything-you-need-know
[64] https://inviqa.com/blog/drupal-9-upgrade-config-split-issue-and-how-fix-it
[65] https://inviqa.com/we-craft-game-changing-digital-experiences
[66] https://inviqa.com/who-we-are
[67] https://inviqa.com/what-we-do
[68] https://inviqa.com/case-studies
[69] https://careers.inviqa.com/
[70] https://inviqa.com/insights
[71] https://inviqa.com/contact
[72] https://inviqa.com/accessibility-statement
[73] https://inviqa.com/covid-19-measures
[74] https://inviqa.com/privacy-policy-UK
[75] https://inviqa.com/sitemap