I Tried Semantic Search Over 60,000 DevelopersIO Articles with NumPy and Bedrock, Without a Vector DB

I Tried Semantic Search Over 60,000 DevelopersIO Articles with NumPy and Bedrock, Without a Vector DB

Fixing dedicated vector DBs' filtering weakness with NumPy pre-filtering. Semantic search over 60,000 articles on existing ECS for about $10/month, combining Bedrock Nova 2's Japanese understanding with 36ms related-article search.
2026.02.17

This page has been translated by machine translation. View original

On February 13, 2026, the number of published blog articles on DevelopersIO finally surpassed the 60,000 milestone.

pub_count_60017

To make this body of 60,000 technical articles easier to use, we implemented semantic search using vector search, as well as related article search.

search_image

The distinguishing feature is that we implemented semantic search over 60,000 articles with 'NumPy' and 'Bedrock', without using a dedicated vector DB. In addition, we adopted a multi-model configuration that automatically switches between two embedding models, Bedrock Nova 2 and Titan V2, depending on the language of the query.
This article covers everything from the data generation pipeline to the search API implementation and the background of our technology choices.

Overall Architecture

The system is broadly divided into two parts.

The AWS services used are as follows.

Component Technology
Search API ECS Fargate (FastAPI)
Vector data store Amazon DSQL / Amazon S3 (NumPy format)
Article metadata store Amazon DSQL / Amazon S3 (JSON format)
Search query vectorization Bedrock Nova 2 Embeddings / Bedrock Titan V2 Embeddings
AI summary generation Bedrock Claude Haiku 4.5
CDN CloudFront

Data Generation Pipeline

Generating AI Summaries

First, AI generates Japanese and English summaries from the article text. These summaries are used as the input for vectorization.

Item Value
Model Claude Haiku 4.5 (Bedrock)
Processing method On-demand (incremental / regeneration) / Batch Inference (initial run)
Input Contentful's content (body), excerpt, and title
Output Japanese summary (summary), English summary (summary_en), English title (title_en)

We processed approximately 60,000 articles, with a success rate of over 99.9%.
We used Bedrock Batch Inference for the initial bulk processing.

https://dev.classmethod.jp/articles/amazon-bedrock-batch-inference-efficiency/

When selecting the summarization model, we ran a comparison against a higher-tier model.

Model Evaluation Reason for adoption
Claude Sonnet 4.5 Top-level summarization accuracy, grasp of complex context Summary quality is top-level, but the difference from Haiku 4.5 was marginal for this summarization task
Claude Haiku 4.5 Fast, inexpensive, sufficient quality Comparison with Sonnet 4.5 showed that its technical term extraction and summarization accuracy are of sufficient quality to support the search foundation. Adopted

Being able to achieve search accuracy equivalent to the higher-tier model with a faster and more economical model was a major cost benefit.

Selecting Embedding Models — A Multi-Model Configuration with Nova 2 and Titan V2

For vectorization, we adopted two Amazon Bedrock embedding models and use them selectively depending on the use case.

Model Model ID Dimensions Region Use
Nova 2 Multimodal Embeddings amazon.nova-2-multimodal-embeddings-v1:0 1024 us-east-1 Japanese search, related articles
Titan Text Embeddings V2 amazon.titan-embed-text-v2:0 1024 ap-northeast-1 Short text / English search

We have also compared the search accuracy of the embedding models.

https://dev.classmethod.jp/articles/bedrock-nova-titan-embedding-comparison/

There are three reasons we adopted Bedrock embedding models.

  • Quality: In the overall score, OSS models (such as multilingual-e5-large) ranked higher, but for our semantic search we prioritize the use case where users vaguely type in the "problem they're facing" in natural language, rather than keyword matching. After evaluating understanding of intent and robustness to variations in notation, we concluded that Nova 2 is the best fit.

  • Cost: The cost of Bedrock batch inference is on par with the actual cost of EC2 instances, and considering that no infrastructure management such as GPUs is required, we judged Bedrock to be advantageous.

  • Real-time vectorization at search time: Search terms need to be vectorized at request time, but hosting a model on EC2 or Lambda posed issues with model load time and cold starts. With the Bedrock API, this problem does not exist, and vectorization was possible with stable latency.

We adopted Titan V2 for English search because its embedding quality for English text is equal to or better than Nova 2, and since it is available in the Tokyo region, it is also advantageous in terms of latency.

※As of February 2026, Nova 2 Embeddings is offered in regions such as US East (N. Virginia), so for Japanese search we call the API cross-region. Titan V2 is available in the Tokyo region.

Automatic Model Selection by Query Language

The search API automatically switches the model and index it uses depending on the user's input query.

Example query Model Index
AWSのベストプラクティス (Japanese) Nova 2 nova/ja-s
AWS Lambda best practices (English, short text) Titan V2 titan/en-s

Queries containing Japanese text are processed with Nova 2, and queries consisting of English words or short phrases are processed with Titan V2.

At present we use just these two models, but going forward we plan to optimize search accuracy with a multi-model setup that includes Cohere Embed and others.

For related article search, regardless of language, we consistently use nova/en-s (the Nova index based on English summaries). Since every article has an English summary, similarity between articles can be calculated without language barriers.

The input text for vectorization is the title and summary combined (at index build time). At search time, the user's input query is vectorized as is.

Indexes are managed with a "model/summary type" key structure.

Index key Model Input text Use
nova/ja-s Nova 2 Japanese title + Japanese summary (short) Japanese semantic search
nova/en-s Nova 2 English title + English summary Related article search
titan/en-s Titan V2 English title + English summary English semantic search
# nova/ja-s (Japanese articles only, short summary)
text = f"{記事タイトル}\n{日本語要約(1文)}"

# nova/en-s, titan/en-s (English-translated summaries for all articles)
text = f"{英語タイトル}\n{英語要約}"

For nova/ja-s, used for Japanese search, the input is a short one-sentence summary (about 200 characters) rather than the three-line bulleted summary. This is because we observed that short summaries tend to be semantically closer to search queries and improve search accuracy.

Storing Vector Data — Three Divided Roles

Vectorized data is stored in three places depending on the purpose.

Bedrock (ベクトル化)
  ├── Amazon S3 Vectors (ベクトルDB、ネイティブベクトル検索用)
  └── Amazon DSQL (blogpost_vectors テーブル、BYTEA形式)
        ↓ 定期エクスポート
      S3 (NumPy形式: vectors.npy + index_map.json.gz)
Storage Role
Amazon S3 Vectors For model evaluation, and also as a backup
Amazon DSQL Source of truth for vector data (stored as BYTEA), diff management and multilingual management
Amazon S3 (NumPy) High-speed read cache for API search (loaded into memory on ECS)

Amazon S3 Vectors

Originally we planned to use S3 Vectors for semantic search as well. However, although it has filtering capabilities, the number of results retrievable per query is limited to a maximum of 100, which was insufficient for text search over an index of about 60,000 items.

Currently, we use S3 Vectors as an inexpensive environment for evaluating model performance and characteristics, and also as a backup of vector data.

Amazon DSQL

The source of truth (master) for vector data is stored in Amazon DSQL. It is responsible for diff management and for centrally managing indexes for multiple models and languages. DSQL's distributed write performance and strong multi-region consistency are highly reliable as a foundation for holding the source-of-truth data, making it ideal for this role.

Amazon DSQL is PostgreSQL-compatible, but currently does not support vector search extensions such as pgvector. Therefore, vector data is stored in binary form as the BYTEA type.

CREATE TABLE blogpost_vectors (
  article_id VARCHAR(50) NOT NULL,
  language   VARCHAR(10) NOT NULL,
  vector     BYTEA NOT NULL,        -- 1024 dimensions × float32 = 4,096 bytes
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  PRIMARY KEY (article_id, language)
);

A 1024-dimension float32 vector becomes 4,096 bytes of binary, which is far more storage-efficient than JSON format (numbers as text). Date management via updated_at enables differential export, and including the language column in the composite primary key allows indexes for multiple languages to be managed in the same table.

When exporting to NumPy, only the records whose updated_at has been updated since the previous export are fetched and diff-merged into the existing .npy matrix. There is no need to perform a full scan of about 60,000 records each time, and a diff of tens to hundreds of records completes in a few seconds.

The flow of the initial full export is as follows.

  1. Fetch all vectors per index from DSQL (batch size of 2,000 articles)
  2. Restore the BYTEA with np.frombuffer(bytes, dtype=np.float32)
  3. Build an (N, 1024) matrix with np.stack()
  4. Upload to S3 as vectors.npy + index_map.json.gz

S3 NumPy Format

As the means to perform vector search and similarity calculation in Python, we adopted NumPy, Python's representative numerical computing library. We prepared an export process that converts the vector data stored in DSQL into NumPy arrays and saves them to S3 in .npy format.

my-vector-index/
  ├── nova/
  │   ├── ja-s/
  │   │   ├── vectors.npy          # shape: (56910, 1024), float32, 223MB
  │   │   └── index_map.json.gz    # {"article_id": array_index, ...}
  │   └── en-s/
  │       ├── vectors.npy          # shape: (60008, 1024), float32, 245MB
  │       └── index_map.json.gz
  └── titan/
      └── en-s/
          ├── vectors.npy          # shape: (60008, 1024), float32, 246MB
          └── index_map.json.gz

On startup, the ECS task downloads the three indexes specified by the environment variable ACTIVE_INDEXES from S3, and makes them immediately searchable with np.load().

Implementing the Search API

Loading Data at Startup

When an ECS task starts, the vector data for the three indexes is preloaded from S3 into memory.

# ACTIVE_INDEXES = "nova/ja-s,nova/en-s,titan/en-s"
for index_key in active_indexes:
    matrix = np.load(f'/tmp/vectors_{index_key}.npy', mmap_mode='r')
    norms = np.linalg.norm(matrix, axis=1, keepdims=True)
    normalized = (matrix / norms).astype(np.float32)

At startup, we compute the L2 norm of every vector and keep the normalized matrix. Because the inner product of normalized vectors equals cosine similarity, search needs only a simple matrix product (dot product).

Index Count File size Use
nova/ja-s 56,910 223MB Japanese search
nova/en-s 60,008 245MB Related articles
titan/en-s 60,008 246MB English search

The three indexes together use about 750MB of memory.

The search term is vectorized with the Bedrock model matching the query language (Nova 2 or Titan V2), and its similarity to all article vectors in the corresponding index is computed in one go using NumPy matrix operations.

q_norm = query_vector / np.linalg.norm(query_vector)
similarities = normalized @ q_norm              # Compute similarity for all items with a single matrix product
distances = 1.0 - similarities                  # Convert to cosine distance (closer to 0 means more similar)
top_indices = np.argpartition(distances, top_k)[:top_k]  # Quickly select the top K
top_indices = top_indices[np.argsort(distances[top_indices])]  # Sort by distance

A matrix product of about 60,000 items × 1024 dimensions completes in a few milliseconds.

Compound Search — The Pre-Filter Approach

When specific conditions such as author or tag are specified, the target articles are narrowed down before the vector search, and similarity is computed only for that limited set of articles.

全記事ベクトル (60,000件)
  ↓ 著者・タグなどで事前フィルタ
対象記事ベクトル (例: 500件)
  ↓ 対象のみ抽出して行列積
類似度順にソート → 結果返却

In the approach common to typical vector DBs, where the top 100 results by similarity are retrieved and then filtered (post-filtering), the results narrowed by conditions can become extremely few (in the worst case, zero). By adopting our mechanism (pre-filtering), articles that would have been missed by post-filtering are reliably hit, and the amount of computation is also reduced in proportion to the number of targets.

Implementation sketch:

# 1. Extract article IDs matching the conditions from the metadata
candidate_ids = get_filtered_article_ids(author="<author_id>", tag="AWS")

# 2. Convert article IDs to row indexes of the NumPy array
indices = []
valid_ids = []
for cid in candidate_ids:
    idx = id_to_idx.get(cid)
    if idx is not None:
        indices.append(idx)
        valid_ids.append(cid)

# 3. Slice only the target rows and take the matrix product (avoids computing all items)
similarities = normalized[indices] @ q_norm
distances = 1.0 - similarities

# 4. Sort by distance and return results
order = np.argsort(distances)
results = [{'key': valid_ids[i], 'distance': float(distances[i])} for i in order]

Related Article Search

This is the search used to display "Related articles" on article detail pages. Instead of a search term, it uses the vector of the reference article.

Search method Description
tags Similarity computed as AI tag overlap × relevance_score. With time decay
vector Cosine similarity between the reference article vector and all article vectors
hybrid Weighted average of the tag score (40%) + vector score (60%)
author+vector Vector similarity computed only over articles by the same author

Data Update Flow

Hourly Incremental Updates

Contentful (記事更新)
  → Webhook → Step Functions
    → AI要約生成 (Claude Haiku 4.5)
    → ベクトル化 (Nova 2 / Titan V2 Embeddings)
    → DSQL書き込み + S3 Vectors登録
    → NumPyエクスポート (S3)

Background Updates on ECS

Because ECS tasks may run for a long time, we built a mechanism to detect and reflect changes to S3 data in the background.

Target Check interval Detection method
S3 cache (master-data) 10 minutes head_object LastModified
S3 cache (delta / incremental update data) 10 minutes head_object LastModified
Vector data (vectors.npy) 1 hour head_object LastModified

Updates work by atomically swapping what the global variable references. Because the new data is fully built in the background before the reference is switched, requests in flight continue to safely use the old data, and from the next request onward the new data is referenced instantly.

Health Check

The health check (GET /health) verifies the load state of the cache and vector data.

// Normal (200)
{"status": "ok", "cache_ready": true, "vectors_ready": true}

// Not loaded (503)
{"status": "degraded", "cache_ready": false, "vectors_ready": false,
 "missing_keys": ["meta_by_id", ...],
 "missing_vectors": ["nova/ja-s", "nova/en-s", "titan/en-s"]}

On a 503 response, the ECS health check marks the task as unhealthy and it is removed from traffic.

Performance

Metric Value
Semantic search, Japanese (TTFB) 380ms
Semantic search, English (TTFB) 204ms
Related articles, vector search (TTFB) 36ms
Memory usage ~2,200MB (cache + vectors ×3)

※Measured from CloudShell in the Tokyo region

Breaking down the Japanese search (380ms) shows that the cost of the NumPy computation itself is very small.

Processing step Time required (approx.) Notes
Query vectorization (Bedrock API) ~350ms Includes RTT of the cross-region call
NumPy similarity calculation (60,000 items) < 10ms Strength of the in-memory matrix product
Metadata join and formatting ~20ms
Total (TTFB) ~380ms

English search (204ms) calls Titan V2 in the Tokyo region, so it has lower latency than Japanese search, which uses Nova 2 cross-region. Related article search (36ms) is fast because it consists solely of in-memory computation between already vectorized data. If Nova 2 becomes available in the Tokyo region, we can expect latency improvements for Japanese search as well.

Technical Points

Why We Don't Use a Dedicated Vector DB

DevelopersIO's search foundation has evolved step by step. In the past, we built and operated Amazon Elasticsearch Service (now OpenSearch), and there was also a period when we used a fully managed search SaaS.

https://dev.classmethod.jp/articles/site-search-in-corporate-site/

Powerful search SaaS is very useful, but weighing our operational requirement of "frequent index rebuilds" against its "pricing based on record count", there was room to consider optimizing costs and simplifying operations.

https://dev.classmethod.jp/articles/change-index-name-search-saas-algolia/

Conversely, when designing this semantic search, we focused on the data scale: "about 60,000 articles (only a few hundred MB even at 1024 dimensions)". Rather than putting this into a dedicated vector DB or external SaaS, which tend to be costly, we judged that loading it as a NumPy array onto the memory of our existing Fargate was the simplest and most sensible choice.

The specific benefits of forgoing a dedicated DB are as follows.

  • Index updates are simple and free: Simply download the new .npy from S3 and swap a variable in Python to complete an atomic update (a switchover with no service downtime). There is no need to worry about extra costs from temporary record growth as with SaaS
  • Startup speed and scaling safety: Amazon Aurora DSQL is an innovative service in terms of highly scalable distributed writes and multi-region consistency, but for the use case of bulk-loading tens of thousands of records at ECS task startup, memory-mapping a single binary on S3 (.npy) is faster with no protocol overhead. This prevents a full SELECT from concentrating on the database when tasks multiply through auto scaling, and lets DSQL focus on its true heavy responsibility of "holding source-of-truth data and managing diffs"
  • Sufficient performance: Even holding three indexes, it fits in memory (~750MB), and computing all 60,000 items with a NumPy matrix product completes in a few milliseconds. Doing matrix operations with the local CPU cache and NumPy cuts latency down to the limit compared to inserting a query (network RTT) to a dedicated DB
  • Flexibility in model selection: Because indexes are managed with a "model/summary type" key structure, adding new embedding models or summary formats is easy. The indexes in use can be switched just by changing environment variables
  • Minimal infrastructure operations: The only data stores are S3 and DSQL, which we already use. We avoided adding new operational components or points of failure for a dedicated vector DB

Even if the number of articles grows to hundreds of thousands in the future, we can handle it by boosting resources or splitting tasks per language, so future scalability is also sufficiently secured.

Reference: Estimated monthly cost

This is an estimate assuming 10,000 searches per day and 1,000 article updates per month.

Component Monthly
Fargate Spot, 2 tasks (1vCPU / 4GB) ~$28
Bedrock (search vectorization + AI summaries + article vectorization) ~$6
S3 / DSQL ~$2
Total ~$36

Since it is assumed to piggyback on the existing frontend container, the additional cost is only about the memory increment, which works out to roughly $10 per month in practice.

Efficiency Gains from Pre-Filtering

Because S3 Vectors limits the number of retrieved results to a maximum of 100, we confirmed that filtering from 100 vector search results makes it hard to get the expected results when narrowing by author or tag.
We also considered splitting indexes by period or language, but while we could not expect a significant improvement in the performance ceiling, we were concerned it would complicate operations.

We therefore adopted an in-memory approach using NumPy on Fargate, using a pre-filter method that first narrows down target articles via metadata and then performs vector computation. Because matrix operations can be executed on all items, articles matching the filter conditions can be searched without omission, and the amount of computation is reduced in proportion to the number of candidates. The Fargate cost is also sufficiently inexpensive, making it a practical choice.

After implementing semantic search, we received a request: "we want full-text search too." Because vector search is based on semantic similarity, searching for "Lambda", for example, does not necessarily bring articles with "Lambda" in the title to the top. In use cases where people search pinpoint for service or technology names, direct text matching is expected.

What came in handy here was the S3 cache data already loaded in memory. Article metadata (titles) and AI summaries (English titles, Japanese summaries, English summaries) are kept in memory for all articles for use as pre-filters in vector search. By repurposing this data as is for partial match search, we were able to achieve full-text search without additional infrastructure cost or DB queries.

Three-layer merge of search results:

When the search term is a single word, text match results are displayed above vector search results.

Priority Match target Score Sort
1 Title (Japanese / English) 100 Publication date, descending
2 Summary (Japanese / English) 99 Publication date, descending
3 Vector search (semantic) Based on similarity By similarity
if _is_single_word(q):
    title_ids, summary_ids = _text_match_ids(q, meta_by_id, summary_by_id, lang)
    # Merge in the order: title matches → summary matches → vector search results

Partial match search over about 60,000 titles and summaries completes in roughly 5 to 15ms using Python's string in operator (implemented in C). No additional API calls to Bedrock are made either.

Reusing data already loaded in memory for another purpose is a simple approach, but being able to achieve "using semantic search and full-text search together" at no additional cost can be called a secondary benefit of this architecture.

Summary

This article introduced a configuration that achieves semantic search and related article search over about 60,000 articles without using a dedicated vector database.

We believe this architecture is a reasonable conclusion at this point in time, balancing "minimal management cost" and "execution performance" for the data scale. By combining the newly adopted DSQL with the existing S3 and ECS (Fargate), we achieved an additional monthly cost of around $36 and low latency of around 36ms for related article search.

With the multi-model configuration that automatically switches between Nova 2 and Titan V2 depending on the query language, we achieve optimal search accuracy for each language, while adding or switching indexes is completed just by changing environment variables.

For vectors of 1024 dimensions at the scale of hundreds of thousands of items, NumPy operations on Fargate alone scale sufficiently. Being able to build a practical search foundation by combining familiar AWS service building blocks, without adding new points of failure or fixed costs, was a major benefit.

We hope this helps when you build a practical search foundation.

On the other hand, at AWS re:Invent 2025 at the end of last year (DAT441, etc.), there were also mentions of native vector search and generative AI support as part of the future roadmap for Amazon Aurora DSQL.

https://www.youtube.com/watch?v=sfZUQ6sFpxE&t

We look forward to the evolution of DSQL, and once native vector search is released, we would like to try comparing it with this NumPy logic and checking the differences in cost and performance.

Share this article

AWSのお困り事はクラスメソッドへ